Highlight
Swift 5.4 的 Result Builders 让开发者用 Swift 语法实现嵌入式领域特定语言,把闭包里的多条声明收集成数组、视图树或自定义数据结构,减少样板代码和维护错误。
核心内容
你维护一个食谱 App。每个 smoothie 都有 id、标题、描述、配料和付费状态。最直接的写法是给每个食谱建一个静态常量,再维护一个总数组。
问题很快出现。
新增一个食谱时,开发者可能只创建了常量,却忘了放进总数组。也可能把同一个食谱加了两次。配料行还很长,MeasuredIngredient、Measurement、measurement 这些词反复出现,真正重要的信息只是「1.5 杯橙子」。
Fruta 的食谱还会被设计师、市场同事和经理更新。代码越像初始化器参数清单,非工程师越容易改错。
Apple 在这场 session 里用 Fruta 展示了一个解法:用 Result Builders(结果构建器)写一个嵌入式 DSL(领域特定语言)。DSL 仍然是 Swift。它使用尾随闭包、@resultBuilder 和链式 modifier 方法,把「如何组装数组」藏起来,让维护者只写食谱本身。
最终目标是把食谱清单写成这样:函数体里直接列出 smoothie;需要付费内容时用普通 if;每个 smoothie 的闭包里先写描述,再写配料。Swift 编译器负责把这些语句传给 builder 的静态方法。
详细内容
Result Builder 做的事:收集闭包里的表达式
(09:26)SwiftUI 的 VStack 是最熟悉的例子。你在闭包里写多个 Text,没有显式返回数组,也没有手动调用 addSubview。
VStack {
Text("Title").font(.title)
Text("Contents")
}
关键点:
VStack接收一个尾随闭包。Text("Title").font(.title)创建第一个 view,并在同一条语句里应用 modifier。Text("Contents")创建第二个 view。- 闭包里的语句会被
ViewBuilder收集,形成VStack的内容。
(09:50)ViewBuilder 的核心形式如下。
@resultBuilder enum ViewBuilder {
static func buildBlock(_: View...) -> some View { … }
}
关键点:
@resultBuilder告诉编译器:这个类型能作为结果构建器使用。buildBlock接收闭包中产生的多个值。- 返回值是闭包最终交给调用者的结果。
- 这里的
…来自 session 代码片段,表示 SwiftUI 内部实现被省略。
Result Builder 是编译期特性。它不要求新的系统版本。Swift 5.4 把最终版本纳入语言,对应 Xcode 12.5。
先把多条 smoothie 语句变成数组
(24:05)Fruta 的第一步,是定义一个 SmoothieArrayBuilder。它把函数体里的多个 Smoothie 收集成 [Smoothie]。
@resultBuilder
enum SmoothieArrayBuilder {
static func buildBlock(_ components: Smoothie...) -> [Smoothie] {
return components
}
}
关键点:
enum SmoothieArrayBuilder没有 case,只作为静态方法容器使用。buildBlock的参数是Smoothie...,可以接收任意数量的 smoothie。return components把变长参数作为数组返回。- 这个 builder 适合最简单的清单:函数体里只有一行行
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标在函数上,函数体使用这个 builder。- 每个
Smoothie(...)表达式会先被保存到临时值。 - 最后调用
SmoothieArrayBuilder.buildBlock。 buildBlock的返回值成为函数体结果。
支持 if:buildOptional、buildBlock 和 buildExpression
(27:47)Fruta 需要根据 includingPaid 决定是否包含付费食谱。普通的 if 需要 builder 实现 buildOptional。
一开始这样写还不够。
@resultBuilder
enum SmoothieArrayBuilder {
static func buildOptional(_ component: [Smoothie]?) -> [Smoothie] {
return component ?? []
}
static func buildBlock(_ components: Smoothie...) -> [Smoothie] {
return components
}
}
关键点:
buildOptional处理没有else的if。- 条件成立时,
component是if体内收集到的数组。 - 条件不成立时,Swift 传入
nil。 - 这版会遇到类型问题,因为
if的结果是[Smoothie],旧的buildBlock仍然期待单个Smoothie。
(31:44)正确版本加入了 buildExpression。它把普通语句里的单个 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 }把多段数组拍平成一个数组。
这一步解决了 DSL 里的第一个真实维护问题:你可以在清单里直接使用 Swift 的 if,不用额外维护 hasFreeRecipe 和过滤逻辑。
支持 if-else 和日志语句
(32:48)带 else 的条件分支需要 buildEither(first:) 和 buildEither(second:)。Fruta 的 else 里还会调用 logger.log(...),这个表达式返回 Void,所以 builder 也要接受 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合并,不会向 smoothie 清单添加元素。
(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仍然是普通 Swift 条件。if分支里的Smoothie先经过buildExpression。- 分支内部再调用一次
buildBlock。 - 分支结果用
buildEither包装。 - 最外层再调用
buildBlock合并最终结果。
用 modifier 方法压缩配料写法
(36:41)配料行的样板代码太多。Apple 用两个 modifier 风格方法,把「单位」和「倍数」拆成链式调用。
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,measurement 乘上传入倍率。 Ingredient.orange.measured(with: .cups).scaled(by: 1.5)只保留食谱维护者关心的信息:橙子、杯、1.5。
这个设计还有一个额外收益。Fruta 的食谱页面本来会把倍率传到每一行配料视图里。scaled(by:) 出现后,可以先把配料本身缩放好,再交给 row view,视图层代码更少。
给每个 smoothie 的闭包再加一个 builder
(37:32)SmoothieArrayBuilder 只作用在 all 函数体。Smoothie(...) { ... } 里的尾随闭包是另一个函数体,外层 builder 不会自动影响它。
// 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的尾随闭包需要返回描述和配料。- 外层 builder 不会进入这个尾随闭包。
- 需要在
Smoothie初始化器的闭包参数上标注新的 builder。
(39:22)新的 builder 叫 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])。- 初始化器把 tuple 拆成
description和ingredients。 - 最后一行调用已有初始化器,传入
description和measuredIngredients。 SmoothieBuilder.buildBlock的第一个参数是String,所以闭包第一行必须是描述。components: MeasuredIngredient...收集后续配料。
为错误写专门的重载
(44:55)如果维护者忘了写描述,Swift 默认会报「第一个配料不能转换成 String」。这个错误准确,但对 DSL 用户不够直接。
Apple 的做法是让 builder 匹配这个错误写法,再用 @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是合法路径:描述字符串加配料。 - 第二个
buildBlock匹配「只有配料、没有描述」的错误路径。 @available(*, unavailable, message:)让这条路径在编译时报错。fatalError()只是让函数体类型检查通过,这个方法不会成功运行。- 错误消息直接说明 DSL 规则:第一条语句必须是描述字符串。
核心启发
-
做什么:给 App 的路由表写一个 DSL。 为什么值得做:session 里提到服务器框架可以让用户声明 URL 和 handler,再由框架注册。Result Builder 适合收集一组声明。 怎么开始:定义
Route类型,再写@resultBuilder enum RouteBuilder,用buildBlock(_ components: Route...) -> [Route]收集路由。 -
做什么:把复杂表单定义改成声明式清单。 为什么值得做:表单字段经常由产品或运营调整。DSL 可以减少数组括号、逗号和重复初始化器参数。 怎么开始:为
FormField建 builder,用buildOptional支持按用户角色显示字段,用 modifier 方法描述校验规则。 -
做什么:为游戏关卡或 NPC 对话写 Swift DSL。 为什么值得做:session 指出文本冒险游戏的房间地图和 NPC 对话常由设计师、写作者维护。DSL 能让他们只关注内容结构。 怎么开始:定义
Room、DialogLine、Choice,用嵌套 builder 分别收集房间和对话分支。 -
做什么:把功能开关配置写成可编译检查的 Swift 代码。 为什么值得做:Result Builder 可以把普通 Swift 条件语句纳入 DSL,避免手动拼接临时数组。 怎么开始:定义
FeatureFlag,实现buildExpression把单个 flag 包成数组,再实现buildEither支持环境分支。 -
做什么:为设计系统组件写 preview 数据 DSL。 为什么值得做:示例数据重复、数量多、经常需要调顺序。builder 可以把多条示例收集成数组,modifier 方法可以压缩常用属性。 怎么开始:定义
ComponentExample,用@ExampleBuilder收集示例;为示例添加.state(...)、.locale(...)这类 modifier。
关联 Session
- What‘s new in Swift — 了解 Swift 5.5 的语言更新,包含影响日常编码的语法和工具改进。
- Meet async/await in Swift — 学习 Swift 如何用语言特性简化异步代码。
- Explore structured concurrency in Swift — 理解 Swift 并发模型如何组织任务和取消传播。
- Discover and curate Swift Packages using Collections — 了解如何为团队和社区组织 Swift 包集合。
- Meet the Swift Algorithms and Collections packages — 认识 Swift 官方包中的算法和集合类型扩展。
评论
GitHub Issues · utterances