[Pitch #2] Function builders

@Douglas_Gregor do you think this is a reasonable statement? I can't help to think that we're missing a lot of use cases where this capability would be required. I think a good design goal of function builders would be to allow users to swap any initializer they can write today with arrays or variadic arguments with function builders and type inference should work exactly the same way. I believe this is a reasonable expectation since, as I understand it, this is the design goal of function builders, to make these kinds of APIs more ergonomic for developers.

In general, I really like how DSLs can be embedded into Swift with the help of functionBuilders. From a user perspective the "unused values are picked up by the DSL" works really well for me.

From the perspective of a DSL implementor though I have some questions that come up up immediately:

  1. Why do I have to implement all these build... methods?
  2. Why can't I use arbitrary Swift code in DSL blocks?

Knowing where function builders are coming from, I think I can answer those questions myself, but my point is that for most DSLs it's not necessary to know anything about the code structure that is used to create the values that are produced by the builder block, the only thing that's needed is the produced values. Therefore my expectation for a general feature that targets DSL creation would be that the only thing one would need to implement would be a callback method that gets called for each produced item one by one, directly on the parent object.

As an example let's consider a DSL for UIKit stack views:

class UIStackView: UIView {

  var axis: Axis

  init( axis: Axis = .vertical, @Builder<Self> content: () -> Void) {
    super.init()
    self.axis = axis
    Builder.apply( content, to: self)
  }

  func handleBuilderValue( _ value: UIView) {
    self.addArrangedSubview( value)
  }
}

The idea would be that for each unused expression in the builder block, the compiler would check if there is an overload for the handleBuilderValue method. If yes, the compiler would emit code to call the method and if no, the compiler would emit a warning (unless it's a @discardableResult).

An example for the call site could look like this:

class ExampleController: UIViewController {

  let button = UIButton( "Add")
  let items: [Item]

  init( items: [Item]) {
    self.items = items
  }

  override func loadView() {
    self.view = UIStackView( axis: .vertical) {
      button // pass to handleBuilderValue
      var count = 0
      for item in items {
        if item.isVisible {
          UILabel( text: item.title) // pass to handleBuilderValue
        }
      }
      UILabel( text: "\(count) visible items") // pass to handleBuilderValue
    }
  }
}

I think this approach would also solve the issues that @ktoso, @Paulo_Faria and others have brought up.

@functionBuilders (maybe named differently) could then be used as a specialized implementation of the generic @Builder annotation to enable better performance (e.g. for SwiftUI), at the expense that not all Swift features are available in the build blocks.

2 Likes

Separating used and unused expression is very confusing, and very prone to error. Users can slip in, or opt out of some expression unintentionally. We'd want the list of untransformed expression/statement to be very limited.


Also, I don't see how your handleBuilderValue is different from build method.

Separating used and unused expression is very confusing, and very prone to error.

Maybe I chose the wrong term. By "used" I mean something like "free":

UILabel() // would be passed
let label = UILabel() // would not be passed, even if not used in block

Users can slip in, or opt out of some expression unintentionally.

Do you have an example?

Also, I don't see how your handleBuilderValue is different from build method.

  1. handleBuilderValue would be implemented directly on the target class / struct (the one that contains the list of children)
  2. It's the only method that needs to be implemented. All Swift features (if, else, switch, for ... in, ...) would be available automatically.

You haven't outlined the rules for used and unused, so I’m only saying relatively abstractly that the blacklist/whitelist (or should I say allowlist/denylist?) should be very small, with very clear signals at the call site.

Relying on things like the return type of functions takes the clarity away.

block {
  foo()
}

I don’t want to inspect what foo returns just to figure if it is to be included. What if it has @discardableResult? The answer to the latter question will dissatisfy users half of the time, and that’d be a pretty big footgun.

This one elevates the building closure to a new class of function, which IIRC was already challenged during the first pitch.

1 Like

If I understand correctly, the proposal works like, “First gather all the construction materials, then build the house.”

And @lassejansen’s idea is more like, “As each piece of material arrives, attach it to the house.”

1 Like

Yea, I see it now. That makes it impossible to figure the entire hierarchy at compile-time, which I believe is one of the main requirement for this feature (to work with SwiftUI).

That makes it impossible to figure the entire hierarchy at compile-time, which I believe is one of the main requirement for this feature (to work with SwiftUI).

I addressed that in the last paragraph. My point is that I see function builders as a performance optimization to a general builder functionality: If you need the performance, you can trade in access to some Swift functionality and increased implementation complexity. But I think the default should be the simple version, with unrestricted access to Swift features.

Design of buildArray

One more small detail I’d like to see addressed is a more advanced buildArray method. I understand how the current design could help with simplicity, but seeing how controlling the input-data of a for-in statement is useful, I would like to see a more customizable design. Due to the current limitations, SwiftUI can’t replace its ForEach View with a more natural control flow statement. That’s - at least in my understanding - a consequence of the inadequate capabilities the current method provides.

What do I propose?

I think that a sequence value - containing the for-in data - combined with a closure would suffice. That is, the method would take a value that would be required to conform to Sequence and a closure that would transform an element of the sequence to the Result/Component type:

// Inside MyFunctionBuilder 
...

static func buildArray<Data: Collection>(
    _ data: Data, 
    _ transform: (Data.Element) -> Component
) where Data.Element: Identifiable {
    ...
}

Maybe a different name altogether could be used, like buildForLoop or something else.

What do you think?

Filip

6 Likes

I've been thinking about this a lot. I've come to the conclusion that ForEach doesn't work well with continue and break so likely we'll need to choose one.

1 Like

I notice that the Swift Shortcuts example has to take some unfortunate workarounds in order to utilize shortcut outputs:

ShortcutGroup {
  Comment("This Shortcut was generated in Swift.")
  AskForInput(prompt: "WHAT 👏 DO 👏 YOU 👏 WANT 👏 TO 👏 SAY")
    .usingResult { providedInput in
      ChangeCase(variable: providedInput, target: .value(.uppercase))
    }
    .usingResult { changedCaseText in
      ReplaceText(variable: changedCaseText, target: "[\\s]", replacement: " 👏 ", isRegularExpression: true)
    }
    .usingResult { updatedText in
      ChooseFromMenu(items: [
        MenuItem(label: "Share") {
          Share(input: updatedText)
        },
        MenuItem(label: "Copy to Clipboard") {
          CopyToClipboard(content: updatedText)
        },
      ])
    }
}

It seems like we could fix this by allowing custom building of declarations, not just to add a component as @ktoso suggested, but to control the binding value as well:

public static func buildDeclaration<C0>(_ rhs: ResultShortcut, _ continuation: @ShortcutBuilder (Variable) -> C0) -> some Shortcut where C0: Shortcut {
   rhs.usingResult(continuation)
}

where the continuation would be everything that comes after the declaration. So writing:

let providedInput = AskForInput(prompt: "Blah blah blah")
// ...

transforms into:

let v0 = ShortcutBuilder.buildDeclaration(AskForInput(prompt: "Blah blah blah")) { providedInput in 
  // ...
}

and we can just write:

ShortcutGroup {
  Comment("This Shortcut was generated in Swift.")

  let providedInput = AskForInput(prompt: "WHAT 👏 DO 👏 YOU 👏 WANT 👏 TO 👏 SAY")
  let changedCaseText = ChangeCase(variable: providedInput, target: .value(.uppercase))
  let updatedText = ReplaceText(variable: changedCaseText, target: "[\\s]", replacement: " 👏 ", isRegularExpression: true)

  ChooseFromMenu(items: [
    MenuItem(label: "Share") {
      Share(input: updatedText)
    },
    MenuItem(label: "Copy to Clipboard") {
      CopyToClipboard(content: updatedText)
    },
  ])
}
3 Likes

Giving the builder explicit control over continuation in this manner seems to me to violate this aspect of the current design:

The power of these builder transformations is intentionally limited so that the result preserves the dynamic semantics of the original code: the original statements of the function are still executed as normal, it's just that values which would be ignored under normal semantics are in fact collected into the result.

Perhaps there's a way to preserve that invariant with a yet-nonexistent @once annotation for closure parameters that has been discussed before, though (or, an ad-hoc equivalent used just for function builders at first).

The power of these builder transformations is intentionally limited so that the result preserves the dynamic semantics of the original code: the original statements of the function are still executed as normal, it's just that values which would be ignored under normal semantics are in fact collected into the result.

This makes sense as a goal, but it isn't currently upheld depending on what is meant by "ignored":

let _ = Text("display me!!")

we would probably say that this expression is being ignored, but the text will not make it into the view. In ordinary semantics, let _ = f() and f() are equivalent statements, so as it is ordinary semantics are not preserved. Though we definitely don't want to enforce this equivalency, I think DSL designers should at least have the option of preserving it. (Though this wouldn't require allowing for continuations as I suggested.)

@propertyWrapperMagic let thingSchema = Schema(id: "thing")

Could there be a propertyWrapperMagic we could use to expose these values to function builders while also being able to reuse?

This may (under some definition of "ignored," as you note) violate the latter clause of the quoted portion ("values which would be ignored under normal semantics are in fact collected into the result"), but IMO the more important part is the previous clause, i.e., "the original statements of the function are still executed as normal".

Within a builder closure, the programmer does not have to know anything about the builder transformation to reason about the execution of their code—each line is executed in the same order, on the same thread, etc. as it would be without applying the transformation.

I'm all for folding variable bindings into the builder transform somehow, but it should be done in such a way that "conventional" reasoning about program execution within a builder closure remains valid regardless of the semantics of the builder object which receives the transformation.

A decent compromise might be to nix continuations, but allow for let statements to transform into a function with two return values, one for binding and one for building?

public static func buildDeclaration(_ rhs: AssignmentExpression) -> (BoundValue, Component)

to transform

let myVar = Thing()

into

let (myVar, v0) = Builder.buildDeclaration(Thing())

This would require some gymnastics to make effective use of, but it prevents doing anything crazy with a continuation.

SwiftUI can't replace its ForEach view with a for loop because SwiftUI wants very different semantics — it's essentially a lazy parallel map rather than an imperative loop construct.

8 Likes

I don't see how this could actually work under any sort of reasonable static and separate compilation model. An assignment into a property wrapper destructures the RHS expression, figures out that it calls a function that's implemented as a function builder, and then rewrites that function?

Even that feels not quite restrictive enough—it would allow builders to break code like:

let index = nonEmptyCollection.getAnyIndex()
nonEmptyCollection[index] // Depending on builder semantics, this may crash!

I think you could get closer by having the transformation take the form:

let myVar = Thing()
let v0 = Builder.buildDeclaration(myVar)

You'd run into violations of the invariant if Thing were a reference type and Builder.buildDeclaration modified its argument, but this possibility already exists with any of the other build* calls.

This transformation could run into trouble with mutable variables, though—we'd probably want the builder to capture the final value of myVar, rather that the value it was initialized with. So what you'd actually end up with is a DAG of value dependencies that you'd use to generate the buildExpression and buildDeclaration calls in a topological ordering at the end of a block. I'm wary of whether we could define the transformation in a well-founded way...

With the motivating example of SwiftShortcuts, though, it's not immediately clear to me that the API you suggest:

couldn't be designed with the current iteration of the function builder design. E.g., it seems reasonable that ChangeCase could provide an additional constructor which accepts (instead of a Variable) a Shortcut with the appropriate output. Of course, I haven't played around extensively with writing my own builders, so maybe there's other tradeoffs that make this approach infeasible.

I haven’t figured out a reasonable way to make this work either.

What I would like to do is to use the same expression tree fed into ViewBuilder with different @functionBuilder implementations that would produce different results.

Unfortunately, this is likely already incompatible with the existing SwiftUI views, since, for example, Button “embeds” the specific @ViewBuilder wrapper for its body. How do you change it from outside its implementation? (And potentially from outside its module?)

One can dream :thinking: :thought_balloon: