Function builders

It ought to be, though a few small tweaks (like making the package name, which can only appear once, a parameter to package(…)) might make it a lot easier.

2 Likes

Hmm :thinking: I’m not sure what code-completion and such would look like. I’m not entirely convinced that it would be an improvement over the current design, which clearly shows you all the options at your disposal.

For the other stuff people have mentioned (especially about control-flow), it seems to me that coroutines/generator functions would be a simple solution. SwiftUI’s special ForEach will likely remain, as I would hope it doesn’t eagerly iterate your entire data-source. Perhaps it could be given a better, less eager-evaluationy-sounding name.

I wonder if coroutines were considered by the team as an alternative. I’d be interested to hear why it couldn’t be used in this case.

Even for optimisations - perhaps the receiver that iterates the generator function could check the specific type and dispatch to an optimised layout function. If that small part was inline able, it should be possible to achieve the same performance (in theory).

+1 to this line of thought. I find it especially helpful in the Button initializer, distinguishing the action anonymous function from the subview definition.

2 Likes

Overall, I like the proposal and understand the need for DSLs support inside Swift.

However, I think there's a need for clear syntax boundary separation in the code marking the scope where writing DSL is possible. Take for instance Swift UI: you have your struct which looks exactly like any other struct in Swift. And in fact you can write any legit Swift there. But then there's body var, which looks exactly like any other computable property, only it contains SwiftUI DSL. We know it is DSL only because we learned that from tutorials / videos.

Now consider some future DSL in a project you just cloned from GitHub. There's no obvious way to tell where Swift ends and DSL starts.

The solution would be to introduce a clear marker around DSL blocks. That will allow the reader to clearly understand that different syntactic rules apply here. What do you think?

9 Likes

I don't think we need explicit marker for when we enter the transforming block, but if it's needed, +1 for @{ ... }

3 Likes

+1 for DSLs. I still give my vote to the concern that we might overcomplicate Swift for too little return.

Is there a way at all to achieve the same thing with existing language features but uglier syntax?

As a Swift user without knowledge of building compilers, I would expect SwiftUI to look like this:

VStack { builder in
   builder.add(Text("Label 1"))
   builder.add(Text("Label 2"))
   if weAreInAGoodMood {
      builder.add(Text("Label 3"))
   }
}

... or a bit more streamlined:

VStack { builder in
   builder.text("Label 1")
   builder.text("Label 2")
   if weAreInAGoodMood {
      builder.text("Label 3")
   }
}

Edit: And if I understand correctly: Of the SwiftIUI features we've seen, mainly (only) the stacks build views from multiple View expressions. Lists and possible future Grids aren't even touched by function builders. So an "ugly" syntax like pictured above wouldn't even be that pervasive in practical DSL scenarios ... (?)

1 Like

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