Thursday, September 24, 2020

A deep dive into Swift function builders

Swift’s function builders feature is arguably one of the most interesting recent additions to the language, for a few different reasons. Introduced alongside SwiftUI as part of Swift 5.1, function builders play a huge role in enabling the highly declarative API that SwiftUI offers, while still not being a fully released language feature.

This week, let’s take a closer look at function builders, and how they can give us some really valuable insights into how SwiftUI’s DSL-like API operates under the hood.

⚠️ Before we begin, I just want to make it crystal clear that, given that the function builders feature hasn’t yet (at the time of writing) gone through Swift Evolution, I don’t recommend writing production code using it. However, as mentioned above, I still think it’s worth studying how that feature works, which is what we’ll focus on in this article.

For me, one of the best ways to truly understand how a given Swift feature works is to actually build something using it, so that’s what we’ll do. As an example, let’s say that we’re working on an app that includes an API for defining various settings — using a Setting type that looks like this:

struct Setting {
    var name: String
    var value: Value
}

extension Setting {
    enum Value {
        case bool(Bool)
        case int(Int)
        case string(String)
        case group([Setting])
    }
}

The above example type is using associated enum values to ensure complete type safety even though various settings can contain different types of values. To learn more, check out the Basics article about enums.

Since the above type includes support for nested settings (through its group value), we’re able to use it to construct hierarchies. For example, here we’ve created a dedicated group for our settings that are considered experimental:

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))
    ]))
]

While there’s nothing really wrong with the above API (in fact, it’s quite nice!), let’s see what it could end up looking like if we were to give it a “function builders makeover” — which in turn could let us transform it into more of a DSL, similar to what SwiftUI offers.

Like its name implies, Swift’s function builders feature essentially lets us build the contents of a function into a single value. Within SwiftUI, that’s used to transform the contents of one of its many containers (such as HStack or VStack) into a single enclosing view, which can be seen by calling the type(of:) function on such a container instance:

import SwiftUI

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


print(type(of: stack))

In general, anytime we see TupleView, that means that a function builder has been used to combine multiple views into one.

SwiftUI uses a number of different function builder implementations, such as ViewBuilder and SceneBuilder, but since we’re not able to look into the source code for those types, let’s instead get started with building our own function builder for the settings API that we took a look at above.

Just like a property wrapper, a function builder is implemented as a normal Swift type that’s annotated with a special attribute — @_functionBuilder in this case. Then, specific method names are used to implement its various capabilities. For example, a method named buildBlock with zero arguments is used to build the contents of an empty function or closure:

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

The return type of the above function (an array of Setting values in our case) then determines the type of function that our builder can be applied to. For example, we might choose to implement our top-level settings API as a global function that applies our new SettingsBuilder to any closure that was passed into it — like this:

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

With the above in place, we can now call makeSettings with an empty trailing closure and we’ll get an empty array back:

let settings = makeSettings {}

While our new API is not yet very useful, it’s already showed us a few aspects of how function builders work. But now, let’s actually start building things.

To enable our SettingsBuilder to accept input, all that we have to do is to declare additional overloads of buildBlock with arguments matching the input that we’re looking to receive. In our case, we’ll simply implement a single method that accepts a list of Setting values, which we’ll then return as an array — like this:

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

Above we’re using a variadic argument list, which SwiftUI can’t currently use, since its View protocol contains an associated type. Instead, SwiftUI’s ViewBuilder defines 10 different overloads of buildBlock, each with a different number of arguments — which is why a SwiftUI view can’t have more than 10 children. However, that limitation does not apply to our SettingsBuilder.

With that new buildBlock overload in place, we’ll now be able to fill our makeSettings call with Setting values, and our function builder (with some help from the compiler) will combine all of those expressions into an array, which is then returned:

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

While the above is arguably already a slight improvement over the inline array that we were previously using, let’s continue to take inspiration from SwiftUI, and also add a function builder-powered API for defining groups. To make that happen, let’s start by defining a new SettingsGroup type, that also annotates a closure with the @SettingsBuilder attribute to connect it to our function builder:

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

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

An alternative approach would’ve been to store our builder closure and then execute it later, rather than doing so within our initializer. However, that’d require that closure to be escaping, which isn’t a pattern that SwiftUI uses for most of its containers.

With the above in place, we’re now able to define groups the exact same way as we define top-level settings, by simply expressing each Setting within a closure — like this:

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

However, if we actually try to place the above group within our makeSettings closure, we’ll end up getting a compiler error — since our function builder’s buildBlock method currently expects a variadic list of Setting values, and our new SettingsGroup is a completely different type.

To fix that issue, let’s introduce a thin abstraction that can be shared between both Setting and SettingsGroup, for example in the shape of a protocol that lets us convert any instance of those types into an array of Setting values:

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

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

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

Then, we simply have to modify our function builder’s buildBlock implementation to accept SettingsConvertible instances, rather than concrete Setting values, and flatten that new argument list using flatMap:

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

With the above in place, we can now define all of our settings in a very “SwiftUI-like” way, by constructing groups just like how we’d organize our various SwiftUI views into stacks and other containers:

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

Really nice! So the buildBlock overloads that a given function builder contains directly determines what type of expressions that we’ll be able to place within each closure or function that has been annotated to use that builder.

Next, let’s take a look at how we can add support for evaluating conditionals within our function builder-powered closures. Initially, it might seem like that should “just work”, given that Swift itself supports all kinds of different conditionals. However, that’s not the case — so with our current SettingsBuilder implementation we’ll end up getting a compiler error if we try to do something like this:

let shouldShowExperimental: Bool = ...

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

The above once again shows us that the code that’s being executed within a function builder-annotated closure isn’t treated the same way as “normal” Swift code — as each expression needs to be explicitly handled by our builder, including conditionals like if statements.

To add that sort of handling code, we’ll need to implement the buildIf method, which is what the compiler will map each stand-alone if statement to. Since each such statement can evaluate to either true or false, we’ll get its body expression passed as an optional — which in our case will look like this:


extension Array: SettingsConvertible where Element == Setting {
    func asSettings() -> [Setting] { self }
}

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

With the above in place, our if statement from before now works just as we’d expect. But let’s also add support for combined if/else statements, which can be done by implementing two overloads of the buildEither method — one with the parameter label first, and one with second, each corresponding to the first and second branch of a given if/else statement:

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

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

We’ll now be able to add an else clause to our if statement from before, for example in order to let users request access to our app’s experimental settings if those are not yet shown:

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

Finally, starting in Swift 5.3, those buildEither methods that we just implemented now also enable switch statements to be used within function builder contexts, without requiring any additional build methods.

So for example, let’s say that we’re looking to refactor our above shouldShowExperimental boolean into an enum, in order to support multiple access levels. We could then simply switch on that enum within our makeSettings closure, and the Swift compiler will automatically route those expressions into our buildEither methods from before:

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

One additional thing worth noting about the above code is that we’re using a new Setting.Empty type within our switch statement’s .restricted case. That’s because we’re not (yet) able to use the break keyword within a function builder switch statement, so we’ll need to express some kind of value with each code branch. So just like how SwiftUI has EmptyView, our new Settings API now has a Setting.Empty type for those kinds of situations:

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

And with that, our function builder-powered settings API is now finished, and while we probably shouldn’t use something like this in production until the function builders feature has officially gone through Swift Evolution, it’s quite fascinating just how little code that’s required to build a SwiftUI-like DSL using it.

With features like property wrappers and function builders, Swift is moving into some very interesting new territories, by enabling us to add our own logic to various fundamental language mechanisms — like how expressions are evaluated, or how properties are assigned and stored.

Granted, those new features do also make Swift more complicated, even though (at least in the best of worlds), they could also let library designers — both at Apple and in the wider developer community — hide that complexity behind well-formed APIs.

I personally hope to see the function builders feature go through Swift Evolution sooner rather than later, to get that underscore removed in front of its keyword, and so that we could start using it within our projects without having to worry that their behaviors might change between Swift versions.

What do you think? Are you looking forward to using function builders within your code, and did you gain some additional insight into how SwiftUI’s API works by reading this article? If so, feel free to share it, and you’re also more than welcome to contact me (either via Twitter or email) if you have any questions, comments, or feedback.

Thanks for reading! 🚀



from Hacker News https://ift.tt/32YPaC4

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.