使用 Swift 特性 Result Builder 定制 DSL

发表于 2年以前  | 总阅读数:672 次

何为 DSL

如何定义 DSL

DSL 全称是 Domain-Specific Language,中文翻译为领域特定语言(下文称 DSL)。与 DSL 相对的就是 GPL,即 General Purpose Language 通用编程语言。GPL 就是我们很熟悉的 C、C++、Objective-C、Java 等等。

DSL 用来解决一些固定场景的问题,是建立在问题空间基础上的语言。在 DSL 中的代码,只需要描述一些业务需求,解释器会在简单的语法描述背后,执行许多隐式行为(Implicit Behavior)从而帮助开发者解决问题。

DSL 语言通常是声明式(Declarative)的,在完成问题的准确描述后,即可解决问题,无需使用编程语言编写详尽准确的指令逻辑。

嵌入式 DSL (Embedded DSLs)

目前我们可以使用 Swift 语言作为宿主语言的语法特性来编写 DSL 的语法规则,然后在 Swift 的代码中来引入 DSL,这种方式我们称之为嵌入式 DSL(Embedded DSLs)。

实现嵌入式 DSL 显然要比设计一个完整独立的 DSL 并为之编写编译器要容易的多,因为这是站在巨人的肩膀上,我们的基础是一个已经确定了语法并自带编译器的高级语言,此时将 DSL 的代码与非 DSL 的代码混合使用也更为容易。

使用嵌入式 DSL 也是目前项目中更合理的方式,因为往往我们仅用 DSL 语法来实现项目中的部分需求问题,而这个问题仅是 App 中的一个特定领域的问题。

Swift 旨在支持嵌入式 DSL,如果你使用过 SwiftUI,那其实你已经接触了嵌入式 DSL。SwiftUI 的视图描述语法,是和我们设备屏幕上的布局视图相对应的。所以当你使用 SwiftUI DSL 进行代码编写时,我们实现的仅仅是对于视图的描述,而底层的构建,全部交予 SwiftUI 进行处理。

使用嵌入式 DSL 的场景我们可以看看下面这个例子:

使用 SwiftUI 可以很好的描述布局,有着更高的可读性。与此相比,我们可以来看一下不使用 SwiftUI 的 DSL,我们的代码是什么样子:

比较之下,使用 Swift 的原生语法需要创建大量的临时变量来实现,而且其代码无法展示出视图布局的层级关系。SwiftUI 则更加接近视图的描述,可读性更强。

SwiftUI 中的 DSL 包括什么

我们用下面代码为例:

  • ① 这个是 Swift 中的 Property Wrapper 特性,通过这种标记方式,可以对 structclass 中的成员添加某些操作行为。
  • ② 这种 func 后直接跟随一个闭包的语法是 Swift 中的 Trailing Closure Arguments,这种闭包的语法更加简洁。另外,这里还妙用了 Swift Class 中的初始化构造器(Initializers)从而简化了写法。
  • ③ 这是 Swift 中的 Result Builders 特性,能够将局部作用域内一个或多个值聚合为单个的返回值进行处理。
  • ④ 这是 SwiftUI 中的 Modifier-style Method,通过这种方式可以构造出一种链式调用的形式,提高代码可读性。

这些语法和特性都在「WWDC19 Modern Swift API design」[2]的 Session 中有详细的介绍,今天我们就不讨论它们的书写方法。在 SwiftUI 中,我们主要通过 Result Builder 对视图做了很多隐式处理,所以我们下面来聊一聊这个语法特性。

Result Builder 的运作方式

Result Builder 特性的目的

在 Swift Forums 中的 _SE-0289 Result Builders_ 提案[3]中有讲述 Result Builders 这个特性的目的:

  1. 实现 DSL 中的特定表达;
  2. 简化常规方法、计算属性的 getter 方法或闭包的语法;
  3. 封装方法调用,语义上是使用返回值;
  4. 接管方法的返回值;
  5. 是一个编译时的特性,可无视系统版本差异;

如何运作?

我们用一个 SwiftUI 的例子来举例说明 SwiftUI 中 DSL 的运作原理,其中包括了 Result Builder 和其他的几个特性。

首先我们来看这个代码:

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

VStack 中,竖直排列两个 Text 控件。这里的 VStack 之所以可以这样表达,是因为这是一个 Trailing Closure Argument,在后面的花括号中就是闭包具体内容。

我们可以看一下 VStack 的实现代码:

struct VStack<Content: View>: View {
    ...
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
}

所以在 SwiftUI 项目中,我们可以将其构造方法补充完整,就变成了下面这样:

VStack.init(content: {
    Text("Title").font(.title)
    Text("Contents")
    return // TODO: build results using `ViewBuilder`
})

可以看到,其中花括号包裹着的部分,其实是构造方法的闭包参数。

在这段代码中,我们还需要注意有这么一个关键字 - @ViewBuilder。我们在源码中找到它的实现:

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

这些语句的内容又是如何被 Result Builder 捕获处理的呢?

我们发现 @ViewBuilder 是一个 Result Builder,它通过标记的 @resultBuilder 属性告知编译器它描述的是一个结果值,且该结果是一个 View 类型(View 类型严格说来应该是 some View 类型)。所以在花括号中的所有内容,将会被 Result Builder 捕获,合成一个值进行返回。

我们将 @ViewBuilder 的行为进行展开,所以可以得到以下展开功能后的代码:

VStack.init(content: {
    let v0 = Text("Title").font(.title)
    let v1 = Text("Contents")
    return ViewBuilder.buildBlock(v0, v1)
})

在上述代码中,我们还需要解释一个关键字 - .font(.title) ,这其实是 Swift 当中的另一个新特性 - Modifier-style Method ,它会修改 Text("Title") 返回值的一些属性,再次返回相同的类型结果。

这里可以看到,Result Builder 首先会为能够返回结果的语句创建临时变量,然后将所有的这些临时变量作为参数去调用一个名为 buildBlockstatic 方法,并将其返回值作为整体的返回结果。

这种做法在 SwiftUI 中十分常见,例如我们可以继续增加一个修改字体颜色的方法:

let v0 = Text("Title").font(.title).foregroundColor(.green)

于是我们可以发现,在 SwiftUI 中,Modifier-style Method 特性和Result Builder 特性很好的协同工作,共同构成了一个 DSL 描述性语言。从这点出发,以下两点也是我们开发者在创建 DSL 的一个原则:语法特性和用户预期表现保持平衡

Result Builder 的限制

虽然我们可以使用 Swift 的 Result Builder 进行一些 DSL 的设计,但是其并不是完全开放的。因为我们需要受到以下限制:

  1. Result Builder 方法中,仍需遵守 Swift 的语法规则:例如花括号匹配规则等;
  2. 当使用 Result Builder 时,某些功能将无法使用:例如一些条件控制语句,异常捕获语句等。除非你的 DSL 也去兼容它们;
  3. 部分 Swift 关键字会被限制使用,因为这可能会造成编译器无法正常编译;

设计一个 DSL

一些设计 DSL 的思考

我们体验了 SwiftUI 中的 DSL 语法和开发体验,接下来我们聊聊如何设计一个 DSL。

其实设计一个 DSL ,和设计 API 十分类似。首先,我们需要考虑如何使用 Swift 来表达对应的想法和行为,设计 DSL 仅仅是使用了设计 API 时通常不会用到的一些额外的语法特性

另外设计 DSL 更多的是为了提升自己的开发体验,所以设计 DSL 是一个主观性较强的操作,相比之下 API 的设计更多地需要考虑他人的需求。由于 DSL 是我们开发者角度的主观表达,所以目标相对来说更加清晰。

例:Fruita App 中关于 DSL 的设计

为了更好的展示如何设计一个 DSL,下面我们使用 Fruita App 的例子来讲述。可以在 Apple 的 SwiftUI 官方文档「Fruta: Building a Feature-Rich App with SwiftUI」[4]来下载这个示例。

繁琐代码痛点分析

我们来看下面这个代码:

extension Smoothie {
    static let berryBlue = Smoothie(
        id: "berry-blue",
        title: "Berry Blue",
        description: "Filling and refreshing*, this smoothie will fill you with joy!",
        measuredIngredients: [
            MeasuredIngredient(.orange, measurement: Measurement(value: 1.5, unit: .cups)),
            MeasuredIngredient(.blueberry, measurement: Measurement(value: 1, unit: .cups)),
            MeasuredIngredient(.avocado, measurement: Measurement(value: 0.2, unit: .cups)),
        ],
        hasFreeRecipe: true
    )

    static let carrotChops = Smoothie(...)
    static let crazyColadaChops = Smoothie(...)
    ...
}

extension Smoothie {
    private static let allSmoothies: [Smoothie] = [
        .berryBlue,
        .carrotChops,
        .crazyColada,
    ]

    static func all(includingPaid: Bool = true) -> [Smoothie] {
        if includingPaid {
            return allSmoothies
        }
        logger.log("Free smoothies only")
        return allSmoothies.filter { $0.hasFreeRecipe }
    }
}

解释一下上面的代码,首先我们需要定义很多 static 的变量来代表各种口味的冰沙(Smoothie),然后我们定义一个 allSmoothies 数组,其中包括所有种类的冰沙,并且定义一个 all(includingPaid:) 方法,来过滤得到免费或非免费的冰沙集合。

现在我们的写法十分繁琐,因为如果我们增加一种新的种类,就要修改 static 变量部分以及 allSmoothies 数组部分。并且 measuredIngredients 的这个象征着冰沙成分的数组实在是太冗长了,每声明一个都会头痛好久。

另外我们单独拿一个成分的描述来分析:

MeasuredIngredient(.orange, measurement: Measurement(value: 1.5, unit: .cups))

这样一个成分的对象,我们仅仅需要关注 .orange1.5.cups 这三个信息即可,剩下的都是无用信息。

综上,关于冰沙这个对象的设计十分冗长,所以我们需要一个 DSL 来精简其写法。在这个 DSL 中需要修改以下这些痛点:

  1. 干掉 hasFreeRecipe 属性;
  2. 将冰沙的定义合并到 list 中;
  3. 让成分属性 measuredIngredients 的描述更加精简;
  4. 分离 descriptionmeasuredIngredients 的声明,并置之于数组之外;

在这个例子中,我将主要讲述我是如何做前三点的,第四点作为扩展,可以继续讨论。

逐一确定目标

首先,针对于第 1 点和第 2 点,我会将沙冰(Smoothie)的定义直接放到 all(includingPaid:) 方法中,这样就避免了二次声明的繁琐。另外,使用 Result Builder,我们可以直接将结果写在闭包中,并且直接使用 if...else 条件语句来分离 includingPaid 满足条件的结果。

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    Smoothie(...)
    Smoothie(...)
    if includingPaid {
        Smoothie(...)
        Smoothie(...)
    } else {
        logger.log("Free smoothies only")
    }
}

然后针对于第 3 点,我们打算使用 Modifier-style Method 这种链式表达的方式,来描述每个沙冰的成分,它看起来像下面这样:

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    Smoothie(
        id: "berry-blue",
        title: "Berry Blue",
        description: "Filling and refreshing*, this smoothie will fill you with joy!",
        measuredIngredients: [
            Ingredient.orange.measured(with: .cups).scaled(by: 1.5),
            Ingredient.blueberry.measured(with: .cups),
            Ingredient.avocado.measured(with: .cups).scaled(by: 0.2)
        ]
    )
    Smoothie(...)
    if includingPaid {
        Smoothie(...)
        Smoothie(...)
    } else {
        logger.log("Free smoothies only")
    }
}

现在让我们来关注一下最后一点,精简 idtitledescription 这几个属性的声明。在这里,我们可以参考 App 的 UI 设计方案,从而得到了以下的 DSL 方式:

Smoothie(id: "berry-blue", title: "Berry Blue") {
    "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)
)

我们可以看到 title 正好在 navigationBar 上,处于 UI 的最上方, description 紧凑地布局在 title 之下,所以我们用一个 String 紧跟在闭包的第一个位置。再往下,就是各个成分列表,使用我们上面精简后的语法就得到了如图样式。

一些小建议

当我们要去设计 DSL 的时候,我希望下面的方法论可以给你一些启发:

  1. 首先要建立一个明确的目标;
  2. 查阅并阅读别人的 DSL 描述方法;
  3. 统筹兼顾,适合需求;
  4. 尽可能通过设计规避错误,让错误在编译阶段可发现或者根本无法写出错误逻辑的代码;
  5. 不断修正问题,并找到最佳的方案。

好的,有了上面的设计经验,下面让我们来实现这个 DSL 吧。

使用 Result Builder 实现 DSL

buildBlock 方法

仿照之前使用 SwiftUI 的方式,我们对于 all(includingPaid:) 方法也应该像 SwiftUI 那样,通过一个 builder 进行多个对象的依次构造。就像以下代码:

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    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, ...)
}

为了使用 Result Builder,我们先来介绍一下其 API 以及每个方法的作用。具体介绍可以查看 「The Swift Programming Laungage - Advanced Operators」中的 Result Builder 章节[5] 以及 「The Swift Programming Lauguage - Attributes」中的 Result Builder API 介绍[6]。

首先我们先来声明这么一个 Result Builder:

@resultBuilder
enum SmoothieArrayBuilder {}

我们发现编译器需要我们至少定义 buildBlock(_ components:) 这个方法,它是用来将部分结果的数组合并成一个结果返回值,所以是一个必须实现的方法。

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

buildOptional 条件处理

如此,我们实现了部分 Smoothie 构造时的报错。为什么是部分呢?因为我们发现在 if... 条件语句的位置,仍旧有报错信息:

这个报错是因为我们仅仅实现了 Result Builder 的 buildBlock(_ components:) 方法,还不能兼容 if...else 这种条件语句。此时我们点击 Xcode 的自动修复按钮,Xcode 提示我们需要增加一个 buildOptional(_ component: [Smoothie]?) 方法。

首先我们从方法签名上来看,这个 buildOptional 方法需要我们传入一个 Optional<[Smoothie]> 类型,其实就是因为 if... 由于具有条件不确定性,所以进行构造的对象可能并不存在,即值为 nil

也许你会问,为什么会通过 nil 处理未命中 if 条件下对象不存在的情形,而不是采用直接跳过这个逻辑,不往最后的 buildBlock 传递参数这种直截了当的方式呢? 我们继续像一开始那样,拆解一下具有 if... 条件语句的情况:

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    let v0 = Smoothie(id: "berry-blue", ...) { ... }
    let v1 = Smoothie(id: "carrot-chops", ...) { ... }

    // Notice - if ... else case
    let v2: [Smoothie] // ①
    if includingPaid {
        let v2_0 = Smoothie(id: "crazy-blue", ...) { ... }
        let v2_1 = Smoothie(id: "hulking-lemonade", ...) { ... }
        let v2_block = SmoothieArrayBuilder.buildBlock(v2_0, v2_1) // ②
        v2 = SmoothieArrayBuilder.buildOptional(v2_block) // ③
    } else {
        v2 = SmoothieArrayBuilder.buildOptional(nil)
    }
    // 但是如果当 includingPaid 是 false 的时候,v2 则为 uninitialized 对象
    return SmoothieArrayBuilder.buildBlock(v0, v1, v2)
}

首先在位置 ① ,Result Builder 由于发现了是条件判断,就会增加一个临时变量 v2 ,并且只是做了声明,未对其进行初始化。

在位置 ② 和 ③,仅局限于条件内部,对 v2_0v2_1 进行构造,并调用 buildBlock 进行组合,最终 v2 通过 buildOptional 构造出来。相反,如果进入了 else 则传入 nil 进行构造。

如果直接跳过 nil 的处理,v2 的初始化由于未覆盖完全所有可能执行的路径,并不完备,从而违反前文中提到 DSL 中必须遵循 Swift 语法规则的限制。

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

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

所以总结一下,当我们需要在 DSL 中进行 if... 条件判断,此时可以使用 buildOptional 进行构造。buildOptional 支持部分结果是 nil 的情况。但是这里一定要注意,以上讨论的,都是 if... 条件,不包括 else if 这种子句的情况。

buildExpression 预处理

虽然我们上面已经完成了 if..else 条件语句的兼容,但是回头来看代码,其实还存在一个返回值类型不匹配的问题。因为在 if...else 语句中声明的的 v2_0v2_1 共同构造了 v2 这个 Smoothie 类型数组 - [Smoothie] ,而 v0v1 只是一个实例。

所以接下来我们需要对类型进行一个变换,从而达到统一的效果。

第一步,我们将 buildBlock 的入参调整为一个数组,并且使用 flatMap 进行展开(详见以下代码 L7-L9):

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

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

此时,我们兼容了 if..else 中的 v2 数组类型,但是上面的 v0v1 不再兼容。针对于此,我们增加一个 buildExpression 方法,来对传入对象进行一个预处理,将其转换成 [Smoothie] 即可完成需求(详见代码 L11-L13)。

@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]
    }
}

完成之后,仍旧拆解转换一下我们的 DSL 代码,此时会变成以下这样:

@SmoothieArrayBuilder
static func all(includingPaid: Bool = true) -> [Smoothie] {
    let v0 = SmoothieArrayBuilder.buildExpression(Smoothie(id: "berry-blue", ...) { ... })
    let v1 = SmoothieArrayBuilder.buildExpression(Smoothie(id: "carrot-chops", ...) { ... })

    // Notice - if ... else case
    let v2: [Smoothie] // ①
    if includingPaid {
        let v2_0 = SmoothieArrayBuilder.buildExpression(Smoothie(id: "crazy-blue", ...) { ... })
        let v2_1 = SmoothieArrayBuilder.buildExpression(Smoothie(id: "hulking-lemonade", ...) { ... })
        let v2_block = SmoothieArrayBuilder.buildBlock(v2_0, v2_1)
        v2 = SmoothieArrayBuilder.buildOptional(v2_block)
    } else {
        v2 = SmoothieArrayBuilder.buildOptional(nil)
    }
    return SmoothieArrayBuilder.buildBlock(v0, v1, v2)
}

我们看到,L3-L4 以及 L9-L10 对于单个 Smoothie 类型的对象,已经增加了 buildExpression 预处理,从而完成了对类型的统一。

buildEither 多条件子句处理

前面我们完成了大多数情况的 DSL 语句处理,但是当我们再次编译时,可以发现虽然我们使用 buildOptional 处理了单 if... 形式的条件语句,但是目前无法处理具有 else 或者 else if 这种子句的情况。

根据 Xcode 的提示,对于这类多条件子句,在 Result Builder 中需要使用 buildEither 进行处理。实现代码如下(详见下面代码 L3-L9):

@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]
    }
}

为什么我们要在这里补充两个 buildEither 方法呢?因为我们的条件选择子句是多个的,需要处理多种情况下的场景。同样的,展开 DSL 的代码,其背后的原理如以下代码描述:


@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)
}

可以看出,ifelse 两个判断分支分别对应了 buildEither(first:)buildEither(second:) 这两个方法。

那如果是多条件的语句呢,需要写一个 third 吗?其实目前是不提供这种超出 3 个条件的方法的,Result Builder 在多条件的场景下,会逐一拆解成双条件语句进行解释。下面这个图展示了多条件语句时的原理:

对于 3 个及以上的条件分支,可以采用递归的方式处理:每次处理都把当前所有的条件分支拆分为 1 个条件分支和剩余的其他条件分支,这时按照 2 个条件分支的处理可以满足需要;接下来对于剩余的条件分支按照递归展开的方式继续处理即可,直到最后展开到仅剩余 2 个条件分支处理完毕。

buildExpression 空处理

至此,我们离大功告成越来越接近了,Xcode 报错只剩下一个问题:

提示我们说,这个语句是一个 Void 返回值,而不是一个 Smoothie 类型。同样的,我们为 buildExpression 方法增加一种参数类型即可。这里继续使用 buildExpressionVoid 进行处理(详见代码 L22-L24):

@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 []
    }
}

至此,我们对于 Smoothie 设计的 DSL 全部完成。

解决内层 DSL 问题

我们之前在设计 DSL 的时候,除了对于 Smoothie 的设计之外,构造每一款沙冰的成分列表 - makeIngredients 方法 ,也希望能够遵循 DSL 的规则。虽然他们不必像 Smoothie 的 DSL 那么复杂,但是我们仍旧需要支持一下。

由于 makeIngredientsSmoothie 的成员变量,且在构造时就会传入相应的 DSL,所以我们需要修改一下 Smoothieinit 方法,在 makeIngredients 参数前,增加一个自定义 Result Builder 关键字,这里我们命名为 IngredientsBuilder (详见 L5):

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

同样的我们也为其增加 buildBlock 方法:

@resultBuilder
enum IngredientsBuilder {
    static func buildBlock(
        _ description: AttributedString,
        _ ingredients: MeasuredIngredient...
    ) -> (AttributedString, [MeasuredIngredient]) {
        return (description, ingredients)
    }
}

注意这里 buildBlock 方法的参数声明,buildBlock 方法并不限制参数的个数和类型,只要能够匹配到可以调用的 buildBlock 方法即可

至此,我们大功告成,完成了整个 DSL 的设计!

定制编译器诊断说明

由于 DSL 是我们自己定义的语法,所以在与他人协作开发的时候,由于配合者并不一定参与 DSL 的全部设计,所以他们对 DSL 会有一些错误使用。DSL 是我们自己设计的,所以 Xcode 并不能给出有效的诊断提示。而 Result Builder 中,我们可以使用相关 Attribute 来增加编译器诊断说明。

例如在上面的例子中,构造 makeIngredients 极有可能出现不传 description 字符串的情况,这时候我们可以增加以下方法来提示使用者(详见 L11-L20):

@resultBuilder
enum IngredientsBuilder {
    static func buildBlock(
        _ description: AttributedString,
        _ ingredients: MeasuredIngredient...
    ) -> (AttributedString, [MeasuredIngredient]) {
        return (description, ingredients)
    }


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

如此,我们增加了诊断提示,提升了开发体验。

总结

本文通过总结 「WWDC21 - Write a DSL in Swift using result builders」[7],学习了如何使用 Swift 5.5 的特性来设计并实现一个定制化 DSL。并且通过 Fruita App 的例子,直接上手设计完成 DSL 的代码实现。

通过编写 DSL,我们有以下具体收获:

  1. 使用 DSL 可以化繁为简,将复杂的 Swift 语法更加清晰;
  2. Result Builder 特性可以在 DSL 中充分利用,让语义描述更加清晰;
  3. Modifier-style Method 与 Result Builder 两个特性结合使用,可以大量的简化代码,并增强可读性和可拓展性;
  4. Swift 也提供了 DSL 在协助开发时的诊断定制化提示,让他人更加容易上手。

参考资料

[1]「一瓜技术」: https://www.desgard.com/qrcode

[2]「WWDC19 Modern Swift API design」: https://developer.apple.com/videos/play/wwdc2019/415/

[3]SE-0289 Result Builders 提案: https://forums.swift.org/t/se-0289-review-2-result-builders/40585

[4]「Fruta: Building a Feature-Rich App with SwiftUI」: https://developer.apple.com/documentation/swiftui/fruta_building_a_feature-rich_app_with_swiftui

[5]「The Swift Programming Laungage - Advanced Operators」中的 Result Builder 章节: https://docs.swift.org/swift-book/LanguageGuide/AdvancedOperators.html#ID630

[6]「The Swift Programming Lauguage - Attributes」中的 Result Builder API 介绍: https://docs.swift.org/swift-book/ReferenceManual/Attributes.html#ID633

[7]「WWDC21 - Write a DSL in Swift using result builders」: https://developer.apple.com/videos/play/wwdc2021/10253/

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/Rq1c26L9AnjdOtPSA96Ovw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237229次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8063次阅读
 目录