WWDC Quick Look 💓 By SwiftGGTeam
Write a DSL in Swift using result builders

Write a DSL in Swift using result builders

元の動画を見る

ハイライト

Swift 5.4 の Result Builders を使用すると、開発者は Swift 構文を使用して埋め込みドメイン固有言語を実装し、クロージャー内の複数の宣言を配列、ビュー ツリー、またはカスタム データ構造に収集して、ボイラープレート コードとメンテナンス エラーを削減できます。

主要内容

あなたはレシピ アプリを管理しています。各スムージーには ID、タイトル、説明、材料、支払いステータスがあります。これを記述する最も簡単な方法は、レシピごとに静的定数を作成し、全体の配列を維持することです。

問題はすぐに現れました。

新しいレシピを追加するとき、開発者は定数を作成するだけで、それらを全体の配列に含めるのを忘れる可能性があります。同じレシピを2回追加することも可能です。食材の列はまだ長いです。MeasuredIngredientMeasurementmeasurementこれらの言葉は何度も出てきますが、本当に重要なメッセージは「オレンジ 1.5 カップ」です。

Fruta のレシピは、デザイナー、マーケティング担当者、マネージャーによっても更新されます。コードが初期化子の引数リストに似ているほど、エンジニア以外の人でもコードを修正するのが簡単になります。

このセッションで、Apple は Fruta を使用して、結果ビルダーを使用して埋め込み DSL (ドメイン固有言語) を作成するというソリューションをデモンストレーションしました。 DSLは依然としてSwiftです。末尾クロージャを使用します。@resultBuilderそして、連鎖修飾子メソッドは「配列の組み立て方法」を隠し、メンテナーはレシピ自体を書くことだけを可能にします。

最終的な目標は、レシピ リストを次のように記述することです。スムージーは関数本体に直接リストされます。有料コンテンツが必要な場合は、通常のコンテンツを使用してくださいif; 各スムージーの留め具には、最初に説明を書き、次に材料を書きます。 Swift コンパイラは、これらのステートメントをビルダーの静的メソッドに渡す役割を果たします。

詳細

Result Builder の機能: クロージャ内の式を収集します

(09:26) SwiftUIVStack最も身近な例です。クロージャに複数を書きますText明示的に配列を返したり、手動で呼び出したりすることなく、addSubview

VStack {
    Text("Title").font(.title)
    Text("Contents")
}

キーポイント:

  • VStack末尾のクロージャを受け取ります。
  • Text("Title").font(.title)最初のビューを作成し、同じステートメントで修飾子を適用します。
  • Text("Contents")2 番目のビューを作成します。
  • クロージャ内のステートメントは次のようになります。ViewBuilder集める、形成するVStackコンテンツ。

09:50ViewBuilder基本的な形式は次のとおりです。

@resultBuilder enum ViewBuilder {
    static func buildBlock(_: View...) -> some View {  }
}

キーポイント:

  • @resultBuilderこの型を結果ビルダーとして使用できることをコンパイラーに伝えます。
  • buildBlockクロージャで生成された複数の値を受け取ります。
  • 戻り値は、呼び出し元に与えられたクロージャの最終結果です。
  • ここセッション コード スニペットから、SwiftUI の内部実装が省略されていることを示します。

Result Builder はコンパイル時の機能です。新しいシステム バージョンは必要ありません。 Swift 5.4 では、Xcode 12.5 に対応する最終バージョンが言語に組み込まれています。

まず複数のスムージー ステートメントを配列に変換します

(24:05) Fruta の最初のステップは、SmoothieArrayBuilder。関数本体に複数を入れますSmoothieに集められる[Smoothie]

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildBlock(_ components: Smoothie...) -> [Smoothie] {
    return components
  }
}

キーポイント:

  • enum SmoothieArrayBuilder大文字と小文字の区別はなく、静的メソッドのコンテナとしてのみ使用されます。
  • buildBlockパラメータは次のとおりです。Smoothie...、スムージーを何個でも受け取ることができます。
  • return components可変長パラメータを配列として返します。
  • このビルダーは、関数本体に 1 行のみの最も単純なリストに適しています。Smoothie

(24:39) コンパイラは関数本体を次のような形式に変換します。

// How ‘buildBlock(…)’ works

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) {
    /* let v0 = */ Smoothie(id: "berry-blue", title: "Berry Blue") {  }

    /* let v1 = */ Smoothie(id: "carrot-chops", title: "Carrot Chops") {  }

    // …more smoothies…

    /* return SmoothieArrayBuilder.buildBlock(v0, v1, …) */
}

キーポイント:

  • @SmoothieArrayBuilder関数にマークが付いている場合、関数本体はこのビルダーを使用します。
  • それぞれSmoothie(...)式は最初に一時値に保存されます。
  • 最後の電話SmoothieArrayBuilder.buildBlock
  • buildBlock戻り値は関数本体の結果になります。

### サポートifbuildOptionalbuildBlockそしてbuildExpression

(27:47) Fruta は以下に基づく必要があります。includingPaid有料レシピを含めるかどうかを決定します。普通ifビルダーの実装が必要ですbuildOptional

最初はこのように書くだけでは十分ではありませんでした。

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
    return component ?? []
  }

  static func buildBlock(_ components: Smoothie...) -> [Smoothie] {
    return components
  }
}

キーポイント:

  • buildOptional処理番号elseif
  • 条件が満たされると、componentはいifin vivoで収集されたアレイ。
  • 条件が満たされない場合、Swift が通過しますnil
  • このバージョンでは型の問題が発生します。if結果は[Smoothie]、 古いbuildBlockまだまだシングル楽しみですSmoothie

(31:44) 正しいバージョンが追加されましたbuildExpression。通常のステートメント内の 1 つの単語を次のように変換します。Smoothieにも変換されました[Smoothie]

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
    return component ?? []
  }

  static func buildBlock(_ components: [Smoothie]...) -> [Smoothie] {
    return components.flatMap { $0 }
  }

  static func buildExpression(_ expression: Smoothie) -> [Smoothie] {
    return [expression]
  }
}

キーポイント:

  • buildExpression(_ expression: Smoothie)まずはそれぞれを裸にするSmoothie単一要素の配列にラップされます。
  • buildOptionalこちらも返却[Smoothie]
  • buildBlock複数受信[Smoothie]
  • flatMap { $0 }複数の配列を 1 つの配列にフラット化します。

このステップは、DSL の最初の実際のメンテナンスの問題を解決します。Swift のif、追加のメンテナンスは必要ありませんhasFreeRecipeそしてフィルタリングロジック。

### サポートif-elseおよびログステートメント

(32:48)else条件分岐には以下が必要ですbuildEither(first:)そしてbuildEither(second:)。フルッタさんのelseとも呼ばれますlogger.log(...)、この式は次の値を返します。Void、したがって、ビルダーも受け入れる必要がありますVoid

@resultBuilder
enum SmoothieArrayBuilder {
  static func buildEither(first component: [Smoothie]) -> [Smoothie] {
    return component
  }

  static func buildEither(second component: [Smoothie]) -> [Smoothie] {
    return component
  }

  static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
    return component ?? []
  }

  static func buildBlock(_ components: [Smoothie]...) -> [Smoothie] {
    return components.flatMap { $0 }
  }

  static func buildExpression(_ expression: Smoothie) -> [Smoothie] {
    return [expression]
  }

  static func buildExpression(_ expression: Void) -> [Smoothie] {
    return []
  }
}

キーポイント:

  • buildEither(first:)引き継ぐif分岐結果。
  • buildEither(second:)引き継ぐelse分岐結果。
  • Fruta はどの分岐が選択されるかを気にしないため、どちらのメソッドもパラメータを直接返します。
  • buildExpression(_ expression: Void)ログ ステートメントを空の配列に変換します。
  • 空の配列はbuildBlock結合すると、スムージー リストに要素は追加されません。

(32:53) コンパイラif-else変換方法はこのように理解できます。

// How ‘if’-‘else’ statements work with ‘buildEither(…)’

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    /* let v0: [Smoothie] */
    if includingPaid {
        /* let v0_0 = SmoothieArrayBuilder.buildExpression( */ Smoothie() {  } /* ) */
        /* let v0_block = SmoothieArrayBuilder.buildBlock(v0_0)
           v0 = SmoothieArrayBuilder.buildEither(first: v0_block) */
    }
    else {
        /* let v0_0 = SmoothieArrayBuilder.buildExpression( */ logger.log("Only got free smoothies!") /* ) */
        /* let v0_block = SmoothieArrayBuilder.buildBlock(v0_0)
           v0 = SmoothieArrayBuilder.buildEither(second: v0_block) */
    }

    /* return SmoothieArrayBuilder.buildBlock(v0) */
}

キーポイント:

  • includingPaidまだ通常のスイフトの状態です。
  • if支店でSmoothie最初にパスするbuildExpression
  • 支店内で再度電話をかけるbuildBlock。 -分岐結果についてbuildEitherパッケージ。
  • 最外層を再度呼び出しますbuildBlock最終結果をマージします。

修飾子メソッドを使用して成分を圧縮する記述メソッド

(36:41) 材料行の定型コードが多すぎます。 Apple は 2 つの修飾子スタイルのメソッドを使用して、「ユニット」と「複数」をチェーン呼び出しに分割します。

extension Ingredient {
  func measured(with unit: UnitVolume) -> MeasuredIngredient {
    MeasuredIngredient(self, measurement: Measurement(value: 1, unit: unit))
  }
}

extension MeasuredIngredient {
  func scaled(by scale: Double) -> MeasuredIngredient {
    return MeasuredIngredient(ingredient, measurement: measurement * scale)
  }
}

キーポイント:

  • measured(with:)で定義されていますIngredient優れた。
  • 受信したものを使用しますUnitVolume数量1を作成しますMeasuredIngredient
  • scaled(by:)で定義されていますMeasuredIngredient優れた。
  • 新しいものを返しますMeasuredIngredient、測定値に入力倍率が乗算されます。
  • Ingredient.orange.measured(with: .cups).scaled(by: 1.5)レシピ管理者が気にする情報 (オレンジ、カップ、1.5) のみを保持します。

この設計には追加の利点もあります。 Fruta のレシピ ページでは、成分ビューの各行に比率が渡されます。scaled(by:)表示されたら、最初に成分自体を拡大縮小してから、ビュー レイヤーのコードを少なくして、それらを行ビューに渡すことができます。

各スムージー クロージャにビルダーを追加します

37:32SmoothieArrayBuilderでのみ動作しますall関数本体。Smoothie(...) { ... }内側の末尾のクロージャは別の関数本体であり、外側のビルダーは自動的にそれに影響を与えません。

// Closures and result builders

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    /* let v0 = SmoothieArrayBuilder.buildExpression( */ Smoothie() {
        "Filling and refreshing, this smoothie will fill you with joy!"

        Ingredient.orange.measured(with: .cups).scaled(by: 1.5)
        Ingredient.blueberry.measured(with: .cups)
        Ingredient.avocado.measured(with: .cups).scaled(by: 0.2)
    } /* ) */

    /* let v1 = SmoothieArrayBuilder.buildExpression( */ Smoothie() {
        "Packed with vitamin A and C, Carrot Chops is a great way to start your day!"

        Ingredient.orange.measured(with: .cups).scaled(by: 1.5)
        Ingredient.carrot.measured(with: .cups).scaled(by: 0.5)
        Ingredient.mango.measured(with: .cups).scaled(by: 0.5)
    } /* ) */

    /* return SmoothieArrayBuilder.buildBlock(v0, v1) */
}

キーポイント:

  • 外層@SmoothieArrayBuilder集めるSmoothie(...)これらの表現。
  • Smoothie説明と成分を返すには、末尾のクロージャが必要です。
  • 外側のビルダーは、この後続クロージャに入りません。
  • にいる必要があるSmoothieイニシャライザのクロージャパラメータで新しいビルダーをマークします。

(39:22) 新しいビルダーが呼び出されます。SmoothieBuilder。最初のステートメントが説明文字列であり、その後に任意の数の説明文字列が続く必要があります。MeasuredIngredient

extension Smoothie {
  init(id: Smoothie.ID, title: String, @SmoothieBuilder _ makeIngredients: () -> (String, [MeasuredIngredient])) {
    let (description, ingredients) = makeIngredients()
    self.init(id: id, title: title, description: description, measuredIngredients: ingredients)
  }
}

@resultBuilder
enum SmoothieBuilder {
  static func buildBlock(_ description: String, components: MeasuredIngredient...) -> (String, [MeasuredIngredient]) {
    return (description, components)
  }
}

キーポイント:

  • @SmoothieBuilderクロージャパラメータの前に記述されます。
  • makeIngredients実行後に戻る(String, [MeasuredIngredient])
  • イニシャライザはタプルを次のように分割します。descriptionそしてingredients
  • 最後の行は既存のイニシャライザを呼び出して渡します。descriptionそしてmeasuredIngredients
  • SmoothieBuilder.buildBlockの最初のパラメータはStringしたがって、クロージャの最初の行は説明でなければなりません。
  • components: MeasuredIngredient...後続の材料を集めます。

エラー用の特別なオーバーロードを作成する

(44:55) メンテナが説明を書き忘れた場合、Swift はデフォルトで「最初の成分を文字列に変換できません」と報告します。このエラーは正確ですが、DSL ユーザーにとっては十分に分かりやすいものではありません。

Apple のアプローチは、ビルダーにこの誤った記述を照合させてから、@available(*, unavailable, message:)専門的な情報を提供します。

extension Smoothie {
  init(id: Smoothie.ID, title: String, @SmoothieBuilder _ makeIngredients: () -> (String, [MeasuredIngredient])) {
    let (description, ingredients) = makeIngredients()
    self.init(id: id, title: title, description: description, measuredIngredients: ingredients)
  }
}

@resultBuilder
enum SmoothieBuilder {
  static func buildBlock(_ description: String, components: MeasuredIngredient...) -> (String, [MeasuredIngredient]) {
    return (description, components)
  }

  @available(*, unavailable, message: "first statement of SmoothieBuilder must be its description String")
  static func buildBlock(_ components: MeasuredIngredient...) -> (String, [MeasuredIngredient]) {
    fatalError()
  }
}

キーポイント:

  • 初めbuildBlock正当なパスです: 説明文字列と成分。
  • 2番目buildBlock「材料のみ、説明なし」に一致するパスが間違っています。
  • @available(*, unavailable, message:)このパスを指定するとコンパイル時にエラーが発生します。
  • fatalError()関数本体の型チェックを通過させるだけでは、このメソッドは正常に実行されません。
  • エラー メッセージには DSL ルールが直接記載されています。最初のステートメントは説明文字列である必要があります。

重要ポイント

  • 何をすべきか: DSL をアプリのルーティング テーブルに書き込みます。 実行する価値がある理由: セッションでは、サーバー フレームワークによりユーザーが URL とハンドラーを宣言できるようになり、フレームワークがそれらを登録できると述べました。 Result Builder は、一連のステートメントを収集するのに適しています。 開始方法: 定義Route入力してから書き込みます@resultBuilder enum RouteBuilder、使用buildBlock(_ components: Route...) -> [Route]ルートを収集します。

  • 対処方法: 複雑なフォーム定義を宣言型リストに変更します。 実行する価値がある理由: フォーム フィールドは製品や業務によって調整されることがよくあります。 DSL は、配列括弧、カンマ、および繰り返される初期化パラメータを減らすことができます。 開始方法:FormFieldビルダーをビルドして使用するbuildOptionalユーザーの役割に応じたフィールドの表示をサポートし、修飾子メソッドを使用して検証ルールを記述します。

  • やるべきこと: ゲーム レベルまたは NPC ダイアログ用に Swift DSL を作成します。 実行する価値がある理由: セッションは、テキスト アドベンチャー ゲームのルーム マップと NPC の対話はデザイナーやライターによって維持されることが多いと指摘しています。 DSL を使用すると、コンテンツ構造のみに集中できます。 開始方法: 定義RoomDialogLineChoice、ネストされたビルダーを使用して、ルームと会話のブランチを個別に収集します。

  • 何をすべきか: 関数スイッチ設定をコンパイル可能でチェック済みの Swift コードに書き込みます。 実行する価値がある理由: Result Builder は、通常の Swift 条件ステートメントを DSL に組み込んで、一時配列の手動スプライシングを回避できます。 開始方法: 定義FeatureFlag、成し遂げるbuildExpression単一のフラグを配列にラップして実装するbuildEither環境の分岐をサポートします。

  • 何をすべきか: デザイン システム コンポーネントのプレビュー データ DSL を書き込みます。 これを行う価値がある理由: サンプル データは反復的で量が多く、順序を調整する必要があることがよくあります。ビルダーは複数の例を配列に収集でき、修飾子メソッドは共通の属性を圧縮できます。 開始方法: 定義ComponentExample、使用@ExampleBuilder例を収集します。例に追加.state(...).locale(...)このタイプの修飾子。

関連セッション

コメント

GitHub Issues · utterances