Result builders
01 何が問題だったのか
HTML や UI のビューツリー、構造化データなど、リストやツリー状の構造を宣言的に組み立てたい場面は多くあります。Swift の標準的な表現力だけでこれを書こうとすると、どのアプローチを選んでも無視できない不便さが残ります。
ネストした関数呼び出しで書く方法の問題
たとえば HTML ノードを表す HTMLNode と、子要素のリストを受け取る補助関数を用意して書くと、次のようになります。
return body([
division([
header1("Chapter 1. Loomings."),
paragraph(["Call me Ishmael. Some years ago"]),
paragraph(["There is now your insular city"])
]),
division([
header1("Chapter 2. The Carpet-Bag."),
paragraph(["I stuffed a shirt or two"])
])
])
この書き方にはいくつかの課題があります。
- コンマ、括弧、角括弧などの記号が多く、構造の本質が見えにくくなります。
- 子要素を配列リテラルで渡すため、要素の型が同じでないといけません。SwiftUI のように子の型情報を親に伝えて最適化したいケースでは制約になります。
- 全体が一つの式なので、途中で変数を宣言したり、条件によって要素を出し分けたりするのが難しくなります。たとえば章タイトルを出すかどうかを切り替えるだけでも、次のように配列の連結が必要になります。
division((useChapterTitles ? [header1("Chapter 1. Loomings.")] : []) +
[paragraph(["Call me Ishmael. Some years ago"]),
paragraph(["There is now your insular city"])])
普通の文で組み立てる方法の問題
逆に、普通のローカル変数と文で組み立てようとすると、今度は構造が文の列に潰れてしまいます。
let d1header = useChapterTitles ? [header1("Chapter 1. Loomings.")] : []
let d1p1 = paragraph(["Call me Ishmael. Some years ago"])
let d1p2 = paragraph(["There is now your insular city"])
let d1 = division(d1header + [d1p1, d1p2])
// ...
return body([d1, d2])
親子関係がすべて明示的な変数経由になり、木構造が目で追いにくくなります。条件付きの要素を挟むたびに同じような定型コードを書く必要もあり、コピーペースト由来のバグも起きやすくなります。
欲しいのは両者のいいとこ取り
本当に欲しいのは、次のような書き味です。
return body {
let chapter = spellOutChapter ? "Chapter " : ""
division {
if useChapterTitles {
header1(chapter + "1. Loomings.")
}
paragraph {
"Call me Ishmael. Some years ago"
}
paragraph {
"There is now your insular city"
}
}
}
- ローカル変数の宣言や
if/forなどの制御フローが普通の文として使えます。 - それでいて、ブロック内に並べた値が暗黙に「親の子要素」として収集されるので、木構造がコードの見た目にそのまま現れます。
この「ブロックの各文の値を暗黙に集めて一つの結果にまとめる」仕組みを、ライブラリ側が自由に設計できる形で言語に取り込むのが本提案の狙いです。なお本機能は Swift 5.1 以降 function builder として非公式に存在していましたが、SE-0289 で正式な機能として導入され、名称も result builder に変更されました。
02 どのように解決されるのか
result builder は、関数やクロージャの本体に並んだ文を、ライブラリが定義した型(result builder 型)の transform メソッド 群を介して一つの値に組み立てる仕組みです。DSL を設計する側は、@resultBuilder を付けた型に必要な static メソッドを実装するだけで、利用者側は普通の Swift の文法でツリーを書けるようになります。
result builder 型の定義
result builder 型は次の 2 つの条件を満たす型です。
@resultBuilder属性が付いている- 少なくとも 1 つの static な
buildBlockメソッドを持つ
そのほか、サポートしたい構文に応じて transform メソッドを追加していきます。すべての transform メソッドは static であり、result builder 型のインスタンスは生成されません。
以下は、本提案で紹介されている全 transform メソッドをまとめたテンプレートです。
@resultBuilder
struct ExampleResultBuilder {
typealias Expression = /* 文として書く式の型 */
typealias Component = /* 部分結果として持ち回る型 */
typealias FinalResult = /* 最終的に関数が返す型 */
// 必須。ブロック内の部分結果をまとめる。
static func buildBlock(_ components: Component...) -> Component { ... }
// 任意。式ステートメントを Component に「持ち上げる」。
static func buildExpression(_ expression: Expression) -> Component { ... }
// 任意。else を持たない if 用。
static func buildOptional(_ component: Component?) -> Component { ... }
// 任意。if/else と switch 用(両方セットで実装)。
static func buildEither(first component: Component) -> Component { ... }
static func buildEither(second component: Component) -> Component { ... }
// 任意。for..in 用。
static func buildArray(_ components: [Component]) -> Component { ... }
// 任意。if #available の部分結果から型情報を消すために呼ばれる。
static func buildLimitedAvailability(_ component: Component) -> Component { ... }
// 任意。最外層の buildBlock の結果を最終結果に変換する。
static func buildFinalResult(_ component: Component) -> FinalResult { ... }
}
Expressionは式ステートメントとして書ける値の型(buildExpressionが無ければComponentと同じ扱い)、Componentは部分結果、FinalResultは最終的な返り値の型を表します。- 型エイリアスは説明のために書いているだけで、実際には
buildBlockなどをジェネリックにすれば、「HTMLに適合する任意の型」のように柔軟な設計もできます。
属性を付けられる場所
@SomeBuilder のように属性として書ける位置は次の 3 つです。
func/var/subscriptの宣言。varとsubscriptでは getter 本体に適用されます。この場合は関数のインターフェイスではなく実装詳細で、ABI には影響しません。- 関数型のパラメータ(プロトコル要件のパラメータも含む)。このパラメータに渡される明示的なクロージャに transform が適用されます。ただし、クロージャ内に
return文があるときは transform が抑制されます。これはインターフェイスの一部としてソース互換性に影響します。 - 保存プロパティ。この場合、暗黙のメンバーワイズイニシャライザの対応するパラメータに属性が付与されます。プロパティ型が関数型でないときは、
() -> 型を受け取る初期化子が合成され、その呼び出し結果が代入されます。
変換の全体像
変換はブロック単位で再帰的に行われます。大枠は次の通りです。
- 各文はそれぞれ変換され、必要に応じて「部分結果」を生成します。部分結果は、一意なローカル変数に束縛された式と考えて差し支えありません。
- ブロックの末尾で、すべての部分結果を引数にして
buildBlockが呼ばれ、「結合結果」が生成されます。 - 最外層のブロックでは、結合結果を(必要なら
buildFinalResultにくぐらせて)returnします。それ以外のブロックでは、結合結果を親ブロックの部分結果として伝搬します。 - ローカル宣言(
let/var)は変換せずそのまま残ります。これにより、部分式をローカル変数に括り出すリファクタリングが安全に行えます。
式ステートメント
代入ではない式ステートメントは次のように扱われます。
buildExpressionが定義されていれば、その式を引数に呼び出します。- その結果(
buildExpressionが無ければ元の式そのもの)を一意なローカル変数に束縛し、その変数が部分結果になります。
buildExpression を使うと、DSL の入力型(Expression)と内部表現(Component)を分離できます。たとえば HTML を DSL の入力として受け取りつつ内部では [HTML] にしておき、String も HTML も書けるようにするといった設計が可能になります。代入式は常に () を返すので、必要なら buildExpression(_: ()) -> Component をオーバーロードして特別扱いすることもできます。
if / switch
選択ステートメントは、結果を生成するケースの数と else の有無をもとに次のように変換されます。
else の無い if(buildOptional パターン)
if i == 0 {
"0"
}
は、概ね次のように変換されます。
var vCase0: String?
if i == 0 {
let thenVar = "0"
vCase0 = BuilderType.buildOptional(.some(BuilderType.buildBlock(thenVar)))
} else {
vCase0 = BuilderType.buildOptional(.none)
}
if-else / switch(buildEither パターン)
buildEither(first:) / buildEither(second:) が定義されていると、コンパイラは各ケースを「平衡二分木」の葉に一意に対応づけ、根から葉までの経路に沿って buildEither(first:) / buildEither(second:) を入れ子にします。たとえば次のコードは、
if i == 0 {
"0"
} else if i == 1 {
"1"
} else {
generateFibTree(i)
}
次のように変換されます。
let vMerged: PartialResult
if i == 0 {
let firstBlock = BuilderType.buildBlock("0")
vMerged = BuilderType.buildEither(first: firstBlock)
} else if i == 1 {
let secondBlock = BuilderType.buildBlock("1")
vMerged = BuilderType.buildEither(second:
BuilderType.buildEither(first: secondBlock))
} else {
let elseBlock = BuilderType.buildBlock(generateFibTree(i))
vMerged = BuilderType.buildEither(second:
BuilderType.buildEither(second: elseBlock))
}
各分岐の型情報を保ったまま 1 つの値にまとめられるのがポイントで、SwiftUI の ViewBuilder が分岐ごとの View の型をそのまま持ち回れるのは、この仕組みが基になっています(木の具体的な形は実装依存である点に注意してください)。
for..in
buildArray(_:) が定義されていると、ループの各反復で得られた部分結果が配列に集められ、その配列が buildArray に渡されます。
for person in employees {
"Hello, \(person.preferredName)"
}
は、概ね次のように変換されます。
var vArray: [Component] = []
for person in employees {
let v0 = Builder.buildExpression("Hello, \(person.preferredName)")
vArray.append(Builder.buildBlock(v0))
}
let v = Builder.buildArray(vArray)
buildArray が無ければ for..in はエラーになります。
if #available と buildLimitedAvailability
限定可用性(if #available(...))のブロックでは、新しい OS でしか使えない API を使っているため、そのブロックで生じる値の型をそのまま外へ持ち出すと、古い OS 向けのビルドでコンパイルが通りません。buildLimitedAvailability は、このブロック内の部分結果から型情報を「消す」ために呼ばれます。
たとえば SwiftUI の ViewBuilder では次のように定義できます。
static func buildLimitedAvailability<Content: View>(_ content: Content) -> AnyView {
.init(content)
}
これにより、次のコードは
if #available(macOS 11.0, iOS 14.0, *) {
LazyVStack { }
} else {
VStack { }
}
次のように変換され、buildEither(first:) に渡される時点では AnyView に統一されているので、古い OS 向けのビルドでも型が解決できます。
let vMerged: _
if #available(macOS 11.0, iOS 14.0, *) {
let v1 = ViewBuilder.buildBlock(LazyVStack { })
let v2 = ViewBuilder.buildLimitedAvailability(v1)
vMerged = ViewBuilder.buildEither(first: v2)
} else {
let v4 = ViewBuilder.buildBlock(VStack { })
vMerged = ViewBuilder.buildEither(second: v4)
}
その他の文の扱い
do文(catch無し)は単なるブロックのラッパとして変換されます。throwと#warning/#errorはそのまま残ります。returnは、attribute を明示的に付けたfuncや getter の本体でのみ禁止されます。クロージャにreturnがあると、そのクロージャは transform の対象外になります。break/continue/guard/defer/catch付きのdoは現時点で禁止されています。
HTML EDSL の例
冒頭のモチベーションに出てきた HTML を、result builder で書けるようにしてみます。
@resultBuilder
struct HTMLBuilder {
typealias Expression = HTML
typealias Component = [HTML]
static func buildExpression(_ expression: Expression) -> Component {
[expression]
}
static func buildBlock(_ children: Component...) -> Component {
children.flatMap { $0 }
}
static func buildOptional(_ children: Component?) -> Component {
children ?? []
}
static func buildEither(first child: Component) -> Component { child }
static func buildEither(second child: Component) -> Component { child }
}
func body(@HTMLBuilder makeChildren: () -> [HTML]) -> HTMLNode {
HTMLNode(tag: "body", attributes: [:], children: makeChildren())
}
func division(@HTMLBuilder makeChildren: () -> [HTML]) -> HTMLNode { ... }
func paragraph(@HTMLBuilder makeChildren: () -> [HTML]) -> HTMLNode { ... }
これで、利用側は次のように書けます。
return body {
let chapter = spellOutChapter ? "Chapter " : ""
division {
if useChapterTitles {
header1(chapter + "1. Loomings.")
}
paragraph {
"Call me Ishmael. Some years ago"
}
paragraph {
"There is now your insular city"
}
}
}
if による条件付きの要素挿入も、文字列と HTMLNode の混在も自然に書けています。
SwiftUI の ViewBuilder と、プロトコル要件からの推論
result builder DSL の代表例が SwiftUI の ViewBuilder です。SwiftUI のビューは通常、次のように書きます。
struct ContentView: View {
var body: some View {
Image(named: "swift")
Text("Hello, Swift!")
}
}
ここで body に @ViewBuilder を毎回書かずに済んでいるのは、プロトコル要件に付いた result builder 属性が適合側に推論されるためです。
protocol View {
associatedtype Body: View
@ViewBuilder var body: Body { get }
}
この推論は以下のいずれかに該当するときには行われません。
- 適合側の宣言に明示的に別の result builder 属性が書かれている
- 関数本体や getter 本体に明示的な
return文が含まれる
保存プロパティと暗黙のメンバーワイズイニシャライザ
保存プロパティに result builder 属性を付けると、暗黙のメンバーワイズイニシャライザの対応するパラメータにも同じ属性が引き継がれます。
struct CustomVStack<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack {
content()
}
}
}
上記は次の初期化子を手で書いたのと等価になります。
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
プロパティの型が関数型に構造的に類似していない場合は、() -> プロパティ型 を受け取る初期化子が合成され、ボディ内でそのクロージャが呼び出されます。
struct CustomHStack<Content: View>: View {
@ViewBuilder let content: Content // 関数型ではない
var body: some View { HStack { content } }
}
// 合成される初期化子:
// init(@ViewBuilder content: () -> Content) {
// self.content = content()
// }
これにより、小さなコンテナ型を大量に定義する SwiftUI ライクな設計でも、初期化子のボイラープレートを書かずに result builder 構文を使えます。
型推論に関する注意
result builder 本体の型推論は、変換後のコードを素直に型検査したときの挙動になります。各部分結果の型はそれぞれ独立に決まり、buildBlock の型から「逆方向」に推論が伝播することはありません。たとえば次のブロックでは、
{
42
3.14159
}
v1: Int、v2: Double が先に決まり、そのうえで buildBlock(v1, v2) が呼ばれます。buildBlock<T>(_ a: T, _ b: T) -> T のように同じ型しか受けられない buildBlock では、42 を Double に「寄せる」ことはなく、型エラーになります。これは、通常のクロージャや関数本体の型推論とモデルを揃えるためと、型チェッカの計算量を抑えるためです。
今後の見通し
本提案は将来の拡張を見据えて最小の機能セットに絞られており、方向性としては次のようなアイデアが議論されています(いずれも speculative で、実現が約束されているものではありません)。
- シンプルな result builder プロトコル:
buildFinalResultだけを要件とするプロトコルを用意し、他の transform メソッドは拡張でデフォルト実装する形にして、小さな DSL を少ないコードで定義できるようにする方向。 - ステートフルな result builder: transform メソッドをインスタンスメソッドにして、変換の先頭で builder のインスタンスを作って持ち回れるようにする方向。
- 宣言の transform:
buildDeclarationのようなメソッドで、let宣言を builder に通知できるようにする方向。 - 仮想化された AST:
for..inやifを実際に実行する代わりに、クロージャとして builder に渡して DSL 側で評価戦略を選べるようにする方向。SwiftUI のForEachのような遅延評価を言語レベルでサポートする布石になりえます。