An idea for improving function builders

Function builders have been a pretty dividing topic here. While I see how they are useful in some scenarios, I believe that their inner workings should be changed to allow for greater flexibility and broader applicability.

The biggest problem that I see is the N argument limit (e.g. 10 arguments in SwiftUI) as well as the inability to have more complex control flow in function builder controlled closures.

As function builders were never formally accepted and are still an inofficial feature, I hope that the following proposed changes – while breaking existing function builder implementations – will still be considered. Function builder closures would stay unchanged, only the underlying syntactic transformation is affected.

Both of the above issues could potentially be addressed by making function builder closures produce state variables while executing from top to bottom instead of combining everything at the end.

Given the following code

func runBuilder<Result>(@ViewBuilder build: () -> Result) -> Result 

runBuilder {
    Text("Hello")
    Text("World")
    Button(action: {}) {
        Text("Press Me!")
    }
}

currently, the following transformation is applied:

runBuilder {
    let arg0 = ViewBuilder.buildExpression(Text("Hello"))
    let arg1 = ViewBuilder.buildExpression(Text("World"))
    let arg2 = ViewBuilder.buildExpression(Button(...))
    return ViewBuilder.buildBlock(arg0, arg1, arg2)
}

I propose to use context values instead, which would result in the following transformation:

runBuilder {
    let state0 = ViewBuilder.makeContext()
    let state1 = ViewBuilder.combine(context, Text("Hello"))
    let state2 = ViewBuilder.combine(context, Text("World"))
    let state3 = ViewBuilder.combine(context, Button(...))
    return ViewBuilder.finalize(state3)
}

What advantages does this bring?

  1. Type checking could potentially be improved by checking line by line instead of one huge function call at the end.
  2. It is possible to express much more complex relations between views. By introducing new state variables at each step, it would potentially be possible to express more complicated automatons (even pushdown automatons or similar things using generic types). The function builder would then act as a "parser" that parses a sequence of instances of types.

An example implementation for view builder could then look something like this:

@_functionBuilder
enum ViewBuilder {
    static func makeContext() -> ViewBuilderContext<Empty> { ... }
    
    static func combine<ViewList, ViewType: View>(_ context: ViewBuilderContext<ViewList>, _ view: ViewType) -> ViewBuilderContext<Cons<ViewType, ViewList>> { ... }

    static func finalize<ViewList>(_ context: ViewBuilderContext<ViewList>) -> some View { ... }
}

This example works with immutable state values, whose types can differ. Therefore, it is also possible to express accepting states through generic constraints or overloads of the finalize function.

Changing function builders to this pattern would increase the flexibility of function builders by a lot. It would also open up the possibility to have loops and other kind of control flow elements in them.

Note that the design presented here is only a rough sketch and could be changed somewhat while keeping the original idea, e.g. by moving the combine static function to instances of the context.

4 Likes

There isn't a limit for function builder argument limit any more than for a normal function, check out this code sample that uses 12 arguments:

@_functionBuilder
struct HelloBuilder {
    static func buildBlock(_ items: String...) -> Int {
        return items.count
    }
}

@HelloBuilder
func hello() -> Int {
    "1"
    "2"
    "3"
    "4"
    "5"
    "6"
    "7"
    "8"
    "9"
    "10"
    "11"
    "12"
}

print(hello()) // prints 12

10 argument limit in SwiftUI comes from the fact that they wanted it to be fully generic, and not erase type information

@cukr I know that there is no limit if you use varargs but without variadic generics, you will lose type information and more importantly, you can't use heterogeneous values conforming to a protocol with associated type constraints.

My proposal aims to keep type information while still allowing an arbitrary number of values in the function builder closure.

Are you aware that Variadic Generics is the intended solution to this problem?

11 Likes

I am sure that the issue could be somewhat addressed with variadic generics.

However, variadic generics are also still in the discussion stage. I could imagine that it would take quite a while until we get them, because they are quite a significant change to the language.

Furthermore a potential solution with variadic generics is not as powerful as this solution, as they could not express the same constraints as this proposal.

For example, with stateful builders, it would be possible to express a grammar like aⁿbⁿ:

protocol State {}
struct EmptyState {}
struct Cons<Element, Prev: State> {}

@_functionBuilder
enum Builder {
    static func makeContext() -> EmptyState { ... }

    static func combine<Element: A, Prev: State>(_ context: Prev, _ next: Element) -> Cons<Element, Prev> { ... }

    static func combine<Element: B, PrevElement, PrevPrev>(_ context: Cons<PrevElement, PrevPrev>, _ next: Element) -> PrevPrev { ... }

    static func finalize(_ context: EmptyState) -> Foo { ... }
}

A @Builder closure in this case would only compile if there are n values conforming to A followed by n values conforming to B. This cannot be expressed by variadic generics.

Also, as stated, they would type check each value separately instead of having to resolve one giant function in the end and variadic generics do not address this issue.

4 Likes