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

Write a DSL in Swift using result builders

观看原视频

Highlight

Swift 5.4 的 Result Builders 让开发者用 Swift 语法实现嵌入式领域特定语言,把闭包里的多条声明收集成数组、视图树或自定义数据结构,减少样板代码和维护错误。

核心内容

你维护一个食谱 App。每个 smoothie 都有 id、标题、描述、配料和付费状态。最直接的写法是给每个食谱建一个静态常量,再维护一个总数组。

问题很快出现。

新增一个食谱时,开发者可能只创建了常量,却忘了放进总数组。也可能把同一个食谱加了两次。配料行还很长,MeasuredIngredientMeasurementmeasurement 这些词反复出现,真正重要的信息只是「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:50ViewBuilder 的核心形式如下。

@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 的返回值成为函数体结果。

支持 ifbuildOptionalbuildBlockbuildExpression

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 处理没有 elseif
  • 条件成立时,componentif 体内收集到的数组。
  • 条件不成立时,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:32SmoothieArrayBuilder 只作用在 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 拆成 descriptioningredients
  • 最后一行调用已有初始化器,传入 descriptionmeasuredIngredients
  • 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 能让他们只关注内容结构。 怎么开始:定义 RoomDialogLineChoice,用嵌套 builder 分别收集房间和对话分支。

  • 做什么:把功能开关配置写成可编译检查的 Swift 代码。 为什么值得做:Result Builder 可以把普通 Swift 条件语句纳入 DSL,避免手动拼接临时数组。 怎么开始:定义 FeatureFlag,实现 buildExpression 把单个 flag 包成数组,再实现 buildEither 支持环境分支。

  • 做什么:为设计系统组件写 preview 数据 DSL。 为什么值得做:示例数据重复、数量多、经常需要调顺序。builder 可以把多条示例收集成数组,modifier 方法可以压缩常用属性。 怎么开始:定义 ComponentExample,用 @ExampleBuilder 收集示例;为示例添加 .state(...).locale(...) 这类 modifier。

关联 Session

评论

GitHub Issues · utterances