Swift的result builders 功能可以说是该语言最近添加的最有趣的功能之一, 因为它在swift的声明性、类DSLAPI的工作方式中扮演着核心⻆色。事实上,result builders 最初是作为一种半官方的语言特性“function builders”引入的,它是Swift 5.1版本的一部分,伴随着Swift的引入, 但在Swift 5.4中被提升为语言的正式组成部分。

在本文中,让我们进一步了解result builders是如何工作的,以及我们如何使用它们,以及它们如何为我们提供一些真正有价值的⻅解,让我们了解SwiftUI的API是如何幕后运作的。

Setting things up

对我来说,真正了解Swift特性如何工作的最好方法之一就是用它来构建一些东⻄,这就是我们将要做的。举个例子,假设我们正在开发一个应用程序,它包含一个API来定义各种设置⸺使用的设置类型如下所示:

struct Setting {
    var name: 
    var value: Value
}
# The basics of how result builders work
extension Setting {
    enum Value {
        case bool(Bool)
        case int(Int)
        case string(String)
        case group([Setting])
    }
}

上面的示例类型使用关联的enum值来确保完整的类型安全,即使各种设置可以包含不同类型的值。

由于上述类型包括对嵌套设置的支持(通过其组值),我们可以使用它来构建层次结构。例如,这里我们为所有实验性的设置创建了一个专⻔的组:

let settings = [
    Setting(name: "Offline mode", value: .bool(false)),
    Setting(name: "Search page size", value: .int(25)),
    Setting(name: "Experimental", value: .group([
        Setting(name: "Default name", value: .string("Untitled")),
        Setting(name: "Fluid animations", value: .bool(true))
    ]))
]

虽然上面的API确实没有什么问题(事实上,它非常好!),让我们看看如果我们对它进行“结果构建器改造”,它最终会变成什么样子 - 这反过来可以让我们把它转变成一个DSL,就像SwiftUI所提供的那样。

The basics of how result builders work

正如它的名字所暗示的那样,Swift的结果构建器功能本质上允许我们通过将多个表达式组合成一个值来构建结果。在SwiftUI中,它用于将其众多容器中的一个(如HStack或VStack)的内容转换为单个封闭视图,可以通过在这样的容器实例上调用type(of:)函数来查看:

import SwiftUI

let stack = VStack {
    Text("Hello")
    Text("World")
    Button("I'm a button") {}
}

// Prints 'VStack<TupleView<(Text, Text, Button<Text>)>>'
print(type(of: stack))

通常,当我们使用SwiftUI时,只要看到TupleView,就意味着使用了结果生成器将多个视图组合成一个视图。

SwiftUI使用了许多不同的 result builder 实现,比如ViewBuilder和SceneBuilder,但由于我们无法查看这些类型的源代码,让我们为上面介绍的设置API构建自己的result builder。

就像property wrapper 一样,result builder被实现为普通的Swift类型,在本例中带有特殊属性- @resultBuilder注释。然后,使用特定的方法名来实现它的各种功能。 例如,一个名为buildBlock的方法使用零参数来构建一个空函数或闭包的结果:

@resultBuilder
struct SettingsBuilder {
    static func buildBlock() -> [Setting] { [] }
}

然后,上述函数的返回类型(在本例中是一个设置值数组)决定了我们的构建器可以应用到的函数或闭包的类型。例如,我们可以选择将顶层设置API作为一个全局函数来实现,这个全局函数将新的SettingsBuilder应用于传递给它的任何闭包,就像这样:

func makeSettings(@SettingsBuilder _ content: () -> [Setting]) -> [Setting] {
    content()
}

有了上面的内容,我们现在可以用一个空的结尾闭包调用makeSettings,我们将得到一个空数组:

let settings = makeSettings {}

虽然我们的新API还不是很有用,但它已经向我们展示了result builder如何工作的几个方面。但是现在,让我们开始构建一些正确的结果。

Combining multiple values into a single result

为了使SettingsBuilder能够接受输入,我们需要做的就是声明额外的buildBlock重载,并使用与我们希望接收的输入相匹配的参数。在我们的例子中,我们将简单地实现一个方法,它接受一系列设置值,然后我们将以数组的形式返回⸺像这样:

extension SettingsBuilder {
    static func buildBlock(_ settings: Setting...) -> [Setting] {
        settings
    }
}

上面我们使用了可变参数列表,这是SwiftUI目前不能使用的,因为它的视图协议包含一个关联的类型。相反,SwiftUI的ViewBuilder定义了 10 个不同的buildBlock重载,每个重载都有不同数量的参数-这就是为什么SwiftUI视图不能有超过 10 个子视图。 然而,这个限制不适用于SettingsBuilder。

有了这个新的buildBlock重载,我们现在可以用设定值填充我们传递给makeSettings的任何闭包, 而我们的result builder(在编译器的帮助下)将把所有这些表达式组合成一个数组,然后返回:

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    Setting(name: "Experimental", value: .group([
        Setting(name: "Default name", value: .string("Untitled")),
        Setting(name: "Fluid animations", value: .bool(true))
    ]))
}

虽然上面的方法与我们之前使用的内联数组相比已经有了一些改进,让我们继续从SwiftUI中汲取灵感,还添加了一个结果构建器支持的API,用于定义组。
为了实现这一点,让我们首先定义一个新的SettingsGroup类型,它还使用@SettingsBuilder属性注释一个闭包(这次存储在一个属性中),以便将其连接到我们的result builder:

struct SettingsGroup {
    var name: String
    @SettingsBuilder var settings: () -> [Setting]
}

另一种方法是实现一个自定义初始化器(而不是依赖Swift的memberwise初始化器特性),然后立即调用我们的settings closure并存储它的结果,而不是存储对closure本身的引用。这样做的好处是避免了必须对闭包进行转义,而代价是实现稍微繁琐一些,这可能会有更好的性能,但也没有那么灵活(因为闭包现在只会被预先调用一次):

struct SettingsGroup {
    var name: String
    var settings: [Setting]

    init(name: String,
         @SettingsBuilder builder: () -> [Setting]) {
        self.name = name
        self.settings = builder()
    }
}

有了上述两种实现中的任何一种(现在让我们使用第一种),我们现在就能够以定义顶级设置时完全相同的方式定义组 - 通过简单地在闭包中表达每个嵌套的设置,像这样:

SettingsGroup(name: "Experimental") {
    Setting(name: "Default name", value: .string("Untitled"))
    Setting(name: "Fluid animations", value: .bool(true))
}

然而,如果我们真的尝试将上述组放在我们的makeSettings闭包中,我们最终会得到一个编译器错误 -由于我们的结果生成器的buildBlock方法目前需要一个设置值的可变列表,而我们的新SettingsGroup是一个完全不同的类型。

为了解决这个问题,让我们引入一个可以在Setting和SettingsGroup之间共享的细抽象, 例如,协议的形式允许我们将这些类型的任何实例转换为一个设置值数组:

protocol SettingsConvertible {
    func asSettings() -> [Setting]
}

extension Setting: SettingsConvertible {
    func asSettings() -> [Setting] { [self] }
}

extension SettingsGroup: SettingsConvertible {
    func asSettings() -> [Setting] {
        [Setting(name: name, value: .group(settings()))]
    }
}

然后,我们只需修改结果生成器的buildBlock实现以接受SettingsConvertible实例, 而不是具体的Setting值,然后我们将使用flatMap来平铺新的参数列表:

extension SettingsBuilder {
    static func buildBlock(_ values: SettingsConvertible...) -> [Setting] {
        values.flatMap { $0.asSettings() }
    }
}

有了上面的这些,我们现在可以用一种非常“类似于SwiftUI”的方式来定义所有的设置,通过构造组,就像我们使用栈和其他容器来组织各种swift视图一样:

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    SettingsGroup(name: "Experimental") {
        Setting(name: "Default name", value: .string("Untitled"))
        Setting(name: "Fluid animations", value: .bool(true))
    }
}

因此,buildBlock重载一个给定的result builder所包含的表达式,直接决定了我们能够在每个被注释为使用该生成器的闭包或函数中放置什么类型的表达式。

Conditionals

接下来,让我们看看如何在结果生成器支持的闭包中添加对条件求值的支持。一开始,这看起来应该“刚刚好”,因为Swift本身支持各种不同的条件句。然而,情况并非如此⸺因此,在我们当前的SettingsBuilder实现中,如果我们尝试这样做,最终会得到一个编译器错误:

let shouldShowExperimental: Bool = ...

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    // Compiler error: Closure containing control flow statement
    // cannot be used with result builder 'SettingsBuilder'.
    if shouldShowExperimental {
        SettingsGroup(name: "Experimental") {
            Setting(name: "Default name", value: .string("Untitled"))
            Setting(name: "Fluid animations", value: .bool(true))
        }
    }
}

上面的例子再次向我们展示了在一个带有result builder注解的闭包中执行的代码并没有像“正常”Swift代码那样被对待-因为每个表达式都需要由我们的生成器显式地处理,包括条件语句,如if语句。

要添加这类处理代码,我们需要实现buildIf方法,编译器将每个独立的if语句映射到这个方法。因为每个这样的语句都可以求值为true或false,我们将得到它的body表达式作为可选参数传递⸺在我们的例子中,它看起来像这样:

// Here we extend Array to make it conform to our SettingsConvertible
// protocol, in order to be able to return an empty array from our
// 'buildIf' implementation in case a nil value was passed:
extension Array: SettingsConvertible where Element == Setting {
    func asSettings() -> [Setting] { self }
}

extension SettingsBuilder {
    static func buildIf(_ value: SettingsConvertible?) -> SettingsConvertible {
        value ?? []
    }
}

有了上面的内容,我们之前的if语句就可以像我们预期的那样工作了。但是,我们还要添加对组合if/else语句的支持,这可以通过实现buildEither方法的两个重载来实现 一个参数标号为first,另一个参数标号为second,分别对应给定if/else语句的第一个和第二个分支:

extension SettingsBuilder {
    static func buildEither(first: SettingsConvertible) -> SettingsConvertible {
        first
    }

    static func buildEither(second: SettingsConvertible) -> SettingsConvertible {
        second
    }
}

我们现在可以在前面的if语句中添加else子句,例如,为了让用户请求访问我们的应用程序的实验设置,如果那些还没有显示:

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    if shouldShowExperimental {
        SettingsGroup(name: "Experimental") {
            Setting(name: "Default name", value: .string("Untitled"))
            Setting(name: "Fluid animations", value: .bool(true))
        }
    } else {
        Setting(name: "Request experimental access", value: .bool(false))
    }
}

最后,我们刚刚实现的那些buildEither方法(从Swift 5.3开始)也允许switch语句在结果生成器上下文中使用,而不需要任何额外的构建方法。

例如,我们想要将上面的shouldShowExperimental布尔值重构为enum,以支持多个访问级别。然后我们可以简单地在我们的makeSettings闭包中打开enum, Swift编译器会自动将这些表达式路由到我们之前的buildEither方法中:

enum UserAccessLevel {
    case restricted
    case normal
    case experimental
}

let accesssLevel: UserAccessLevel = ...

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    switch accesssLevel {
    case .restricted:
        Setting.Empty()
    case .normal:
        Setting(name: "Request experimental access", value: .bool(false))
    case .experimental:
        SettingsGroup(name: "Experimental") {
            Setting(name: "Default name", value: .string("Untitled"))
            Setting(name: "Fluid animations", value: .bool(true))
        }
    }
}

关于上面的代码还有一件值得注意的事情,那就是我们正在使用一个新的设置。switch语句的.restricted case中的空类型。这是因为我们(还)不能在结果生成器切换语句中使用break关键字,因此,我们需要在每个代码分支中表达某种值。就像SwiftUI有EmptyView一样,我们新的设置API现在有了一个设置。空类型用于这些情况:

extension Setting {
    struct Empty: SettingsConvertible {
        func asSettings() -> [Setting] { [] }
    }
}

这样,我们新的结果生成器驱动的设置API现在就完成了! 使用这个新的语言特性来构建一个类似于swiftui的DSL只需要很少的代码,这真的很吸引人。

Conclusion

通过属性包装和结果构建器等特性,Swift正在进入一些非常有趣的新领域,它让我们能够将自己的逻辑添加到各种基本的语言机制中-例如如何计算表达式,或如何分配和存储属性。

当然,这些新特性也让Swift变得更加复杂,即使(至少在最好的情况下),它们也可以让库的设计者⸺无论是在苹果还是在更广泛的开发者社区⸺把这种复杂性隐藏在格式良好的api背后。

原文链接