Function builders

-1 For me, I really don't like this approach.

My main concern is that it's proposed usage looks identical to a normal Swift block, but the two aren't interchangeable, and one has subtle differences for how individual lines are interpreted and what is/isn't allowed.

Regarding the interchangeability issues, I would expect to be able to do the following. But my understanding is that, because the transformation is done at compile time, this isn't possible?

func div(@HTMLBuilder makeChildren: () -> [HTML]) -> HTMLNode { ... }

let sectionFactory: () -> [HTML] = {
    h1 { "Title" }
    p { "Some paragraph" }
}

// Doesn't work
let sectionDiv = div(makeChildren: sectionFactory)

struct NavBuilder {
    let pages: [String]

    func makeNav() -> [HTML] { ... }
}

let builder = NavBuilder(pages: ["Home", "Products", "About"])

// Doesn't work
let navDiv = div(makeChildren: builder.makeNav)

Regarding the subtle differences. The proposal states that every expression in the block gets passed to the relevant buildX method of the builder type. Void expressions are ignored and expressions creating types not handled by the build methods produce errors. Already there has been some discussion about how @discardableResult functions should be handled.

Now consider the following example, the use of metasyntactic names is intentional:

div {
    foo()
    bar()
    baz()
    qux()
}

Someone who reads this for the first time has no way, short of looking up each individual function, which methods are contributing to the building and which aren't.

Also consider the following, which I would also expect to work, but actually does nothing:

let sections: [String:String] = ["Title 1": "Body 1", "Title 2": "Body 2"]

func makeSection(title: String, body: String) -> [HTML] { ... }

div {
    // Does't work
    sections.forEach(makeSection)
}

I know that replacing forEach with map would fix this particular case, but it don't think it's clear to the programmer why this change should be made, and there probably many other cases where the solution is even less obvious.

Overall there seems to be too many cases where plain normal swift code either doesn't work within the context of builder functions, or behaves in non-obvious ways.

I agree that being able to create DSL's in swift would be an incredibly useful functionality for the language, but I think that creating an entire feature specifically just to support this is the wrong approach.

My preference would be to use Kotlin as an inspiration, where the ability to create DSL's is just a natural extension of combining standard languages features. Features that, individually, are useful and powerful in their own right.

As many people have mentioned, the main feature missing from swift that would allow this is Closures with Receivers. This feature is useful in it's own right, and also eliminates the main source of boiler plate preventing normal blocks from being used as DSL's (the need to prefix $0.). It also solves one of the problems with the current proposal, not being able to scope things to specific block levels in the DSL

17 Likes

I don't find these sort of examples convincing (and generally don't like the use of β€œfoo”, β€œbar”, etc in example code) because if you randomise the names of things in any piece of code then it becomes confusing. This is why names are so important, and why Swift has the API Design Guidelines document. For well-designed DSLs with reasonable names I'm not sure this will be a real issue. e.g. I haven't seen any SwiftUI code so far where I was confused about this.

4 Likes

Just my two cents, but I don't find the syntax all that confusing. I think we just need to make these functions support full control flow and make them more like normal swift closures.

3 Likes

Just released the new version of Graphiti, a library for defining GraphQL schemas, using the new function builders.

We already used the builder pattern before as you can see here:

This new feature makes the schema much cleaner. It was possible to translate everything we had before. One issue I had is that we used to be able to pass parameters to each builder. With the new approach we have to first call the builder function and then when we get the result we can pass a reference type to fill it out. We're also using classes as the return types for the function builders because we rely HEAVILY on generics. With protocols and associated types I wasn't able to make the buildBlock functions work.

One other thing I wasn't able to do is to allow nested structs inside the builders like we had here:

But that actuallly led me to a bettter design where we split the schema declaration and the resolve functions. This enforces the extraction of the resolve functions from the schema into a proper type, which allows this type to be tested independently of the schema declaration. Overall I'm very happy with this feature. I must admit I sometimes felt like the "I don't know what I'm doing" dog meme, but that's because the code requires insane amounts of generics and that's not the function builder's fault.

11 Likes

Oh, another thing I forgot to mention is that we had throwing builders before. I opted to fatalError because it makes the schema cleaner and, to be honest, I haven't checked if throwing would work with the new function builders. I just assumed they wouldn't. One thing that would be nice, if possible at all, is to translate these fatalErrors into compiler errors.

3 Likes

Throwing builder would be nice (to have)! I don't see it in the proposal though.

Quoting the relevant part of the proposal:

Exception-handling statements

throw statements are left alone by the transformation.

defer statements are left alone by the transformation. It is ill-formed if the deferred block produces a result.

do statements with catch blocks are provisionally ill-formed when encountered in transformed functions unless none of the nested blocks produce a result. Some cases of these statements may be supported in the future.

I'm thinking about something more along the line of builder itself throwing, rather than the expression is throwing :slight_smile: .

/// In builder
static func buildBlock(_: Expression...) throws -> Component { ... }

/// In transforming closure
try expression1 // the expression itself doesn't throw, but the builder does.

Ahh, that makes sense. Would functions using the builder have to be throwing? Or would non-throwing functions still be able to use the builder if it had other non-throwing buildBlock overloads? Given the ad-hoc nature of this feature the latter seems like the right behavior.

1 Like

If closure uses only non-throwing builders, the closure shouldn't need to throw, even if there are throwing builders. Then the throw-ness will be attached to the closure, and it can utilize throw-rethrow mechanism.

Yeah, that sounds perfect.

1 Like

Why do we need separate buildEither(first:) and buildEither(second:) instead of one buildEither(_:)?

Oh, another big issue I had were the vague compiler messages when there were errors inside the builder. I know it's beta software, but this needs a lot of attention before the final release.

6 Likes

The variants give the DSL information about the structure at the point of use. It might be useful to look at how SwiftUI is using it:

https://developer.apple.com/documentation/swiftui/viewbuilder/3278694-buildeither
https://developer.apple.com/documentation/swiftui/viewbuilder/3278695-buildeither
https://developer.apple.com/documentation/swiftui/conditionalcontent

2 Likes

I think somewhere in this thread a member of core team already mentioned that improving those error messages is an area of focus for them.

1 Like

Right, there shouldn't be any special considerations for throwing functions. Remember that the goal of the feature is that the original semantics of the function are preserved as much as possible, and it's just that some values are captured and built into the result. If the original function throws, that's fine, it just means that the result never gets fully built.

Now, I don't think it's a good idea to allow the implicit code in the function (e.g. calls to buildBlock) to throw, because that would be implicit control flow that the programmer probably wouldn't anticipate and anyway won't be able to affect.

2 Likes

Should DSLs be added to Swift before variadic generics? Is this a cart-before-horse proposal?

2 Likes

This is a good idea; I'm fine with that rename.

I'm less sure about "builder functions" as the name for the feature. How about something like "result builders"?

6 Likes

Result builder works for me. It doesn't have the potential for confusion that "function builder" does.

I think people are getting hung up about variadic generics because SwiftUI specifically would benefit from them, but most users don't need them; normal variadic functions are fine if you don't need type propagation.

7 Likes