Function builders

I think the important point here is that all of those questions are about the language design of static attributes and not somehow general to all uses of user-defined attributes.

I very like this addition to Swift, but I think, as some others already mentioned above, there should be some visible difference between a normal closure and a function builder (builder function? result builder? whatever).

Therefore I quite like the suggestion by @anandabits, to add some lightweight annotation in front of every function builder closure, like for example an @.

Especially with SwiftUI, where there is a mix between normal closures (e.g. Button actions) and the function builders it could be very helpful.

Also there could be further advantages to this system. We could have variables containing such a function builder (this would probably need a different function type as well, something like @BuilderType () -> ReturnType), which could be passed to functions expecting a function builder type.
I imagine something like this:

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

func chapter(_ number: Int) -> @HTMLBuilder () -> [HTML] {
    return @{
        "Chapter \(number)."
    }
}

div @{
    h1(chapter(1))
    p @{
        "Call me Ishmael. Some years ago"
    }
    p @{
        "There is now your insular city"
    }
    
    h1(chapter(2))
    p @{
        "I stuffed a shirt or two"
    }
}

Of course you could also type annotate the builder like this:

let builder = @HTMLBuilder {
    "Foo"
    "Bar"
}
// builder is of type `@HTMLBuilder () -> [HTML]`
2 Likes

Having had some days to get familiar with SwiftUI, I don’t see the benefits of the magic part in DSL , i.e. Implicitly collecting types instead of explicit return on the ”closure”.

Now we have ”if” statements that only work sometimes, have to use forEach instead of normal for, no guard, issues with let/var, workarounds with AnyView()... And still sometimes you end up having to use explicit return anyway.

Instead of all that non-standard stuff, it would be much nicer to stick with the normal statements and use always explicit return (a single value or tuple or array). Maybe then the typechecker would also be sane again instead of complaining about errors in unrelated areas of valid code...

I do love what SwiftUI does, but there’s no need for the inscrutable magic in it. And ideally would love to see function builders to have less magic in them.

12 Likes

Kinda curious, what kind of design are you thinking about?

If you look up-thread, examples of what would be required to make SwiftUI manually have been posted. It's not pretty. You're trading a far more complex usage site (a Swift antipattern) for more flexibility (fixable with builders in the future) and the possibility (but not guarantee) of better error messages. I agree that much more work should be put into diagnostics here ASAP, but you'd make the pattern generally harder to use without the builders at all.

2 Likes

This is an example of the AnyView / explicit return "mess" I was inferring to by Matt (not me) from this gist:
https://gist.github.com/mattgallagher/eaa5d3242d83360a52c45c9706479e34

struct ContentView: View {
	@ObjectBinding var step: PassthroughBindable<UInt64>
	var body: some View {
		GeometryReader { proxy in
			ZStack(alignment: .topLeading) { 
				Spacer()
				ForEach(0..<circleCount) { index -> AnyView in
...
					let rect = randomRect(in: CGRect(origin: .zero, size: proxy.size))
					return AnyView(StrokedShape(shape: Circle(), style: StrokeStyle())
...
					)
				}
			}...
		}
	}
}

What would be nicer is to have the DSL "closure" be just regular closure with regular behaviour, and then return the "builder" stuff as the return value, so:

var body: some View {
       guard let value = someOptional else { return nil }
   
       // do some regular stuff
  
       let headerView = value.headerTitle ? HeaderView(value.headerTitle) : nil

      return ( // tuple or array
          headerView,
          ContentView(value)
     )
}

Don't know how implementable that would be, but can't be harder that current thing, right?

1 Like

Unfortunately it'd still suffer from the same problem that current function builder has (minus the magic part).

With the (hard?) requirement that the design must allow for type propagation, most of the elements inside the closure can't go through branching (if/else/for) without being awkward.

var body some View {
    let branchingResult: Either<Expression1, Expression2>
    // Note on the `Either` type

    if condition {
        branchingResult = .first(expression1)
    } else {
        branchingResult = .second(expression2)
    }

    return (branchingResult) // Now we have `SomeView<Either<Expression1, Expression2>>`
}

The problem arises when expression1 and expression2 doesn't have the same type. Swift is strongly typed, so that's not gonna fly. What we can do is to introduce the Either type. Things get ugly really quick when you want to include nested function, esp. now that you need to specify the type yourself.

The problem with branching doesn't lie within the magic of gathering (see what I did there? :-) expressions, but rather the requirement of type propagation. Though it doesn't seem to be long-term problem; we could potentially add support for looping variable assignment over time.

7 Likes

Wow, was that reference to MtG? Never thought to see such in swift forums :joy: Anyways, good point about branching + strong types being the real issue here...

2 Likes

Just thinking out loud here, but the issue with branching and strong types also seems to be present with opaque result types:

protocol P { }
extension Int : P { }
extension String : P { }

func f2(i: Int) -> some P { // ok: both returns produce Int
  if i > 10 { return i }
  return 0
}

func f2(flip: Bool) -> some P {
  if flip { return 17 }
  return "a string" // error: different return types Int and String
}

As with the builder/gatherer DSL function syntax proposed here, opaque result types have difficulty handling situations where branching paths have different return types.

1 Like

If anything, both features do respond to the same underlying need; to easily propagate type information.

You know, it just occurred to me that opaque types could be used to simplify the signatures of the ViewBuilder.buildBlock methods. i.e. instead of this:

static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(
  _ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4,
  _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9
) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)>
where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View,
      C5 : View, C6 : View, C7 : View, C8 : View, C9 : View

you could have this:

static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(
  _ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4,
  _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9
) -> some TupleView
where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View,
      C5 : View, C6 : View, C7 : View, C8 : View, C9 : View

Hmm…maybe that's not quite as much of an improvement as I thought it would be.

After writing SwiftUI code for a bit one becomes used to using a conditional in a view builder to create a ConditionalContent. It's natural to want to do that at the top level of a view's body, but unfortunately it doesn't work. I'm wondering if we could support explicit annotation on a declaration to support this:

@ViewBuilder var body: some View {
    if showText {
         Text("hello")
    } else {
         Color.blue
    }
}

This would work on any declaration with an opaque result type including methods and subscripts in addition to computed properties.

2 Likes

In lieu of language support, I have discovered an extension that gets pretty close, so maybe we don't need this.

extension ViewBuilder {
    static func of<V: View>(@ViewBuilder _ factory: () -> V) -> V {
        factory()
    }
}

    var body: some View {
        ViewBuilder.of {
            if showText {
               Text("hello")
            } else {
                Color.blue
            }
        }
    }
2 Likes

That should already work. I’ve heard reports of a bad interaction between function builders on funcs/getters with opaque result types, though.

Hmm, ok. It wasn’t working for me in beta 2. Maybe it is working on a later toolchain though? Otherwise it sounds like it could be a bug.

A bug is what I meant by a "bad interaction", yes.

I am thinking about how to support for loops in function builders.
Was something like the following already considered?

func collect(_ f: @Wrapper () -> WrappedResult) { ... }
collect {
    a()
    for elem in [1, 2, 3, 4] {
        b(elem)
    }
    c()
}

could be transformed to:

collect {
    var _wrapper = Wrapper()
    _wrapper.expressionWithIgnoredResult(a())
    for elem in [1, 2, 3, 4] {
        _wrapper.expressionWithIgnoredResult(b())
    }
    _wrapper.expressionWithIgnoredResult(c())
    return _wrapper.finish()
}

That is, instead of static methods, just use normal methods and always create some instance of the Wrapper. This wrapper instance would have to collect all the results, instead of all of the individual variable assignments in the original proposal. In this model, support for if or for would be really straightforward, as they simply lead to some methods being called conditionally or within the loop.
If more detailed knowledge over the structure of the wrapped function is necessary, then we could introduce even more methods which would be inserted into the function (e.g. beginConditional / endConditional / beginLoop / endLoop or whatever we'd need).

3 Likes

This bug will be fixed in the 5.1 release ([5.1] Two function-builders fixes by rjmccall · Pull Request #25944 · apple/swift · GitHub).

Note that unfortunately function builders will remain a private feature in 5.1; we'll have to maintain compatibility with existing code that uses the @_functionBuilder attribute, but we can still pursue alternative designs for the un-underscored feature.

5 Likes

:smiley:

Is this a sufficiently static feature that SwiftUI could eventually adopt the final version without ABI impact as long as all of the relevant symbols the old @_functionBuilder implementation used remain available?

1 Like

It needs cooperation from the library implementation, but yes, we’ve intentionally done that. You would just need a new SDK that exposes the new interface, but it should deploy back.