Swift Digest
SE-0348 | Swift Evolution

buildPartialBlock for result builders

Proposal
SE-0348
Authors
Richard Wei
Review Manager
Ben Cohen
Status
Implemented (Swift 5.7)

01 何が問題だったのか

result builder はブロックの中に並べられた複数のコンポーネントを一つの値にまとめるための仕組みで、SwiftUI の ViewBuilder / SceneBuilder や Regex DSL の RegexComponentBuilder など、型を保ったまま組み合わせるDSLの基盤として広く使われています。

これまで、ブロック内のコンポーネントを結合する方法は buildBlock 一つしかありませんでした。ジェネリックな型パラメータを保ったまま複数のコンポーネントを結合するには、アリティ(引数の数)ごとに buildBlock をオーバーロードする必要があります。たとえば SwiftUI の SceneBuilder はおおよそ次のような形をしています。

extension SceneBuilder {
  static func buildBlock<Content>(_: Content) -> Content where Content: Scene
  static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> some Scene where C0: Scene, C1: Scene
  // ... 10個分の引数までオーバーロードが並ぶ
  static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(
    _ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4,
    _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9
  ) -> some Scene where C0: Scene, /* ... */ C9: Scene
}

Swiftには可変長ジェネリクスが無いため、サポートしたいアリティごとに同じようなオーバーロードを手で並べるしかありません。これはコードサイズやビルド時間、ドキュメントを膨らませ、メンテナンスも苦痛になります。

さらに厄介なのが、コンポーネントの型をもとに新しい型を組み立てるDSLです。Regex builder DSL では、各コンポーネントの capture 型を連結した新しい Regex 型を作る必要があります。

let regex = Regex {
    "a"                             // => Regex<Substring>
    OneOrMore {
        Capture { OneOrMore("b") }  // => Regex<(Substring, Substring)>
        "c"
        Capture { OneOrMore("d") }  // => Regex<(Substring, Substring)>
    }                               // => Regex<(Substring, Substring, Substring)>
    "e"
    Optionally { Capture("f") }     // => Regex<(Substring, Substring?)>
}                                   // => Regex<(Substring, Substring, Substring, Substring?)>

capture を持つ/持たないの組み合わせをすべて扱うため、buildBlock のオーバーロードはアリティに対して O(arity!)(階乗)で増えていきます。アリティ10では300万を超えるオーバーロードが必要で、コンパイル自体が現実的でなくなるほどの組み合わせ爆発を引き起こしていました。

つまり、「並んだコンポーネントを、型を保ったまま一つにまとめる」という result builder の基本的な使い方が、アリティごとの buildBlock オーバーロードという仕組みの上では実用的に表現できないケースが増えてきた、というのが本Proposalが解こうとしている問題です。

02 どのように解決されるのか

result builder に新しいカスタマイズポイントとして、コンポーネントを2つずつ畳み込む buildPartialBlock を追加します。ブロック全体を一度に受け取るのではなく、先頭のコンポーネントを buildPartialBlock(first:) で初期値に変換し、それ以降のコンポーネントを buildPartialBlock(accumulated:next:) で順に結合していくイメージです。

@resultBuilder
enum Builder {
    /// ブロックの先頭のコンポーネントから部分結果を作る
    static func buildPartialBlock(first: Component) -> Component

    /// これまでの累積と次のコンポーネントを合成する
    static func buildPartialBlock(accumulated: Component, next: Component) -> Component
}

この2つがどちらも定義されていれば、ブロックは buildBlock ではなく buildPartialBlock を使って上から順に展開されます。

// 元のブロック
{
    expr1
    expr2
    expr3
}

// 変換後(buildExpression / buildFinalResult は定義されているときだけ呼ばれる点は従来通り)
{
    let e1 = Builder.buildExpression(expr1)
    let e2 = Builder.buildExpression(expr2)
    let e3 = Builder.buildExpression(expr3)
    let v1 = Builder.buildPartialBlock(first: e1)
    let v2 = Builder.buildPartialBlock(accumulated: v1, next: e2)
    let v3 = Builder.buildPartialBlock(accumulated: v2, next: e3)
    return Builder.buildFinalResult(v3)
}

アリティごとのオーバーロードを畳む

ペアワイズに結合するようになったため、アリティごとの buildBlock オーバーロードはほぼ不要になります。SwiftUI の SceneBuilder は次の2つのメソッドまで縮められます。

extension SceneBuilder {
    static func buildPartialBlock(first: some Scene) -> some Scene
    static func buildPartialBlock(accumulated: some Scene, next: some Scene) -> some Scene
}

Regex DSL のように、コンポーネントの型の組み合わせごとに振る舞いを変えたい場合でも、2引数のオーバーロードだけを列挙すれば済みます。これにより O(arity!) の組み合わせ爆発が O(arity^2) に落ち、アリティ10では300万超から100程度のオーバーロードに激減します。

extension RegexComponentBuilder {
    static func buildPartialBlock<M>(first regex: Regex<M>) -> Regex<M>
    static func buildPartialBlock<W0, W1>(accumulated: Regex<W0>, next: Regex<W1>) -> Regex<Substring>
    static func buildPartialBlock<W0, W1, C0>(accumulated: Regex<(W0, C0)>, next: Regex<W1>) -> Regex<(Substring, C0)>
    static func buildPartialBlock<W0, W1, C0>(accumulated: Regex<W0>, next: Regex<(W1, C0)>) -> Regex<(Substring, C0)>
    // ...
}

pitch段階のフィードバックでは、pointfreeco/swift-parsing が buildPartialBlock の採用によって生成コードを21,000行削除でき、デバッグビルドのコンパイル時間が20秒から2秒以下に短縮されたと報告されています。

buildBlock との関係

@resultBuilder が付いた型は、これまで少なくとも1つの buildBlock を持っている必要がありましたが、本Proposal以降は「buildBlock を1つ以上持つ」か「buildPartialBlock(first:)buildPartialBlock(accumulated:next:) の両方を持つ」のどちらかを満たせばよくなります。両方が定義されていれば、result builder transform は常に buildPartialBlock 側を優先します。これは、result builder transform が型推論の前に実行されるため、引数の型を見てどちらを呼ぶか選ぶと型チェックが複雑化するのを避けるための設計です。

空のブロックを許したい場合は、引数なしの buildBlock() を従来通り定義しておけば、その経路で対応できます。逆に SwiftUI の SceneBuilder のように空ブロックを許したくない場合は、buildPartialBlock(first:) だけを出発点にすることで、1要素以上のブロックのみを受け付けられます。

また、宣言側の利用可能性(availability)は buildPartialBlock(first:) / buildPartialBlock(accumulated:next:) の利用可能性を満たしている必要があります。満たさない場合は従来通り buildBlock 側の変換にフォールバックするため、OSバージョンごとに古い buildBlock と新しい buildPartialBlock を共存させることもできます。