Function builders

Seems like the inevitable way to go, if (when) this feature is accepted.

That loses a lot of compile-time checking/optimization. Compilers generally doesn't reason (and so, optimize) well with accumulation procedure.To get what's advertised we'd need to do it more tediously:

VStack { builder in
  let _b0 = builder.text("Label 1")
  let _b1 = builder.text("Label 2")

  let _b2: Component?
  if weAreInAGoodMood {
    _b2 = builder.optional("Label 3")
  } else {
    _b2 = nil
  }

  return builder.gather(_b0, _b1, _b2)
}
1 Like

So this boiler plate code cannot be moved into the Builder because the Builder would need to know that a third label might appear dependent on state?

Your approach doesn't allow the builder to preserve type information that must be preserved in SwiftUI. That is why each expression must be assigned to a local and combined by the builder in a final step. This code cannot be moved into the builder without losing type information. The result type of the builder function depends on how the builder processes each expression as well as the combining operations.

4 Likes

I'm sure I haven't read everything about this proposal, and it's likely that I missed important facts - but given what I've seen, this very much looks like a terrible variant of premature optimization.

3 Likes

Perhaps the optimization is premature, but I'm way more sold on checking, especially that it can enforces structure like Header Body... Footer. A lot of DSLs could leverage this.

1 Like

I haven’t found the time to respond to as much as I should over the last few days, but type propagation is a requirement. One of the chief advantages of the ad-hoc builder approach is that we can support radically different building styles based on what methods the builder actually provides, so if there’s a substantially simpler building scheme (e.g. creating a builder instance and then calling a method on it for each partial result), it can exist in parallel with the other facilities; but I have no interest in completely eliminating the ability to do type propagation, and in general simplicity of the specification is not a primary goal.

11 Likes

I still haven‘t understood this. Can you give an example where this is relevant?

1 Like

Would it also be possible that the builder support custom conditions in the if-statement?

E.g:

h1 { "Title" }
if context.name == "some value" {
    div { ... }
}

Where context.name is a key-path e.g. \Context.name with the use of dynamicMemberLookup. This could generate:

builder.add(h1 { ... })
builder.addCondition(IsEqual(keyPath: \Context.name, value: "some value"), withContent: div { ... })

This would make it possible to implement a builder that pre-renders most of the static content and evaluates the key-paths and some logic like if, for, while, etc. when rendering. Like this library.

This is probably not needed in all DSLs, but this could be very powerful in Server-Side-Swift and HTML rendering.

I'm excited to see this proposal! However, I'm eager to know the difference between using builder types (structures that accumulate child builders and builds) and using builder functions.

For example, I can declare a naive protocol like this:

protocol HTMLBuilder {
    var openTag: String { get }
    var children: [HTMLBuilder] { get set }
    var closeTag: String { get }
    func build() -> String
}
extension HTMLBuilder {
    func build() -> String {
        return openTag + children.reduce("") { $0 + $1.build() } + closeTag
    }
    subscript(builders: HTMLBuilder...) -> HTMLBuilder {
        var builder = self
        builder.children = builders
        return builder
    }
}

With builder structures such as:

struct HTML: HTMLBuilder {
    var openTag: String {
        return "<html>\n"
    }
    var children: [HTMLBuilder] = []
    var closeTag: String {
        return "\n</html>"
    }
}

And then use them like this:

html [
    div [
        a.href("about:blank") [
            "Click here"
        ],
        " to show a blank page."
    ],
    div [
        "Hello!"
    ]
]

The question is: if the control flow statements are heavily restricted in builder functions, maybe we could consider making the DSL look more like arrays than closures?

As a normal Swift user I found myself hard to grasp how builder functions accumulate values. For example, I'm still a bit unsure if the values in nested non-builder closures are captured. Using array-like syntax might bring more predictability, maybe?

4 Likes

I’ll second a lot of what @anandabits wrote:

  • Agreed that the name “function builder” is confusing.
  • I’m not entirely sure do is unnecessary, but I would like to see a use case. Also, if we’re deferring support for other far more common compound statements (for) for separate review, it’s not clear why do slips into this proposal.
  • Devoting more careful thought to Void and @discardableResult in the initial proposal is wise. (More below)
  • Agreed that stateful eDSLs are important, and indeed seem like a small addition. If this proposal is too large, we should at least validate that they will fit nicely.
  • Agreed that variadic generic support bumps up in priority with this proposal being centered in SwiftUI.
  • Agreed that scoping is an issue, and not just for operators. The ability to make certain things available only within the scope of a build of a particular type seems important. That might fit with the design of stateful builders Matthew sketches.
  • Agreed that I would like to see at least a sketch, a mini-manifesto, of what full control flow support will look like.
  • Agreed that the mutual nesting of regular Swift and builder Swift, e.g. with callbacks nested in a SwiftUI view builder nested in a Swift type, could make it hard to know which language mode a given piece of code is in. I’m not totally sold on the @{…} syntax, but I do see the advantage. I agree with @griotspeak that Swift is already a context-changes-semantics language, and we shouldn’t shy away from that. As with specially marking dynamic member accesses, I tend toward “Xcode should show it with syntax highlighting.” I certainly we be good to have some kind of user-visible indication of which mode a given piece of code is in.

Regarding Void and @discardableResult: I’m still looking for the simple mental model, a heuristic, that developers can use to reason successfully about builders without understanding all the gory details. It seems like it might be along these lines:

A (function builder | builder function | eDSL | whatever it’s called) takes a closure you give it, interjects itself into the flow of statements as it sees fit, and collects all the unused results.

Given that heuristic, it sure would be nice to simply say “a builder collects anything that would normally generated an unused result warning.”

As Matthew points out, that’s at odds with the fact that a builder might sometimes want to collect a @discardableResult, which suggests forcing users to use an explicit _ = discardableResultFunc(). However, most functions are marked @discardableResult precisely because you don’t want to think about the fact that they return a value, and a builder seems like exactly the context where this principle is relevant. So I’m not thrilled about the _ = solution.

Would it make sense instead to provide a mechanism for explicitly marking that a builder should collect a discardable result? That seems like the far less common case, and the better one to make exceptional.

5 Likes

It's the difference between the return type of the builder function being fixed by the builder itself and the return type being determined by an interplay of the builder with the function it is interacting with. I can't think of a better example than SwiftUI itself. I recommend spending time to really grok how the SwiftUI DSL is put together.

Without this proposal all of SwiftUI's builder functions would need to erase type information and return AnyView. That breaks the entire design and would have far reaching consequences including reduced performance and worse animations (IIUC).

You changed my mind about @discardableResult :smiley:.

The one key nature of @discardableResult is that you need to explicitly request for it to be used (by assigning it to variable).

Perhaps we can add new syntax to emit Expression from @discardableResult, or we can just ignore all of them, and users who want to opt-in must do:

let nonDiscardableResult = someDiscardableResult(...)
nonDiscardableResult // emit `Expression` here
1 Like

I really feel that it’s super unclear as proposed and used by SwiftUI as an initial demo. If a function’s return type says it returns 1 object view in SwiftUI’s body case it should be clear in its return keyword usage. It’s totally confusing to see such a function never even say return and then list a handful or dozens of statements which aren’t packed in a collection or wrapper type but somehow it is. How does one ever differentiate between a statement that is returning and is combined with other statements or not. Stuff it all in a collection and make it simple.

6 Likes

Every example has one (1) top-level object in body function, and so, as per SE-0255, it has all the rights to omit return . All of them are in a wrapper of some kind (likely some View initializer).

Looking at sample code again, maybe I said what I meant incorrectly.

var body: some View {
    VStack {
        Text("Hello World")
        Text("Hello World")
    }
}

It would appear that there are two Texts returned from the VStack initializer but that’s confusing since there are not being put in a collection due to lack of collection syntax around them or commas between them. There is no return used in there despite having the two returned somehow which doesn’t aline with SE-255 which only does one. Why does Swift have to omit so many characters that could make what is happening much more clear. We read way more then we type so omitting clarification isn’t always good for complex things.

1 Like

A naming nitpick I just noticed: buildOptional sounds as if it is meant to take any value whose type happens to be Optional. But optionality is not the distinguishing feature that causes this function to be call; conditional execution is. Perhaps buildConditional would be a better name? Or something else that is a clear parallel to buildEither and does not overload an existing term?

6 Likes

Initiailzers don't have return values so this interpretation doesn't make any sense. If you mean it looks like there are two Texts returned from the builder closure passed to the VStack` initializer, there are!

ViewBuillder combines the Texts into a TupleView and that is the return value of the builder closure. The result of this is that the full type of the VStack is VStack<TupleView<(Text, Text)>>. If you had used two images instead, it would be VStack<TupleView<(Image, Image)>>. It is also possible to capture heterogeneous values subview types this way, for example VStack<TupleView<(Image, Text, Color)>>. This type-level distinction is why function builders are necessary and a more familiar builder approach as seen in Ruby and Kotlin is insufficient.

One really incredible aspect of this design is that it allows us to create a view that can specify valid combinations of subviews and have this statically verified by the compiler. For example, you could write a MyContainerView requires a MyHeaderView followed by n MyItemViews as its children. This potential is not (yet?) taken advantage of by SwiftUI and is not necessarily trivial to implement for the library author, but it is extremely interesting and I believe will be extraordinarily useful.

I can't wait to see what we as a community do with a tool like this in our hands and a superb example to learn from in SwiftUI. It's an exciting time to be a Swift programmer!

7 Likes

I agree with this. The name should focus on the role of the function in combining results, not a type detail about how the function is implemented.

Given Swift don't have variadic generics yet, what swift ui will do if the ViewBuilder returns a number of views greater than the supported TupleView arity overloads?

2 Likes