@builderOperator attribute

I've been digesting the Function Builders pitch since WWDC. I love SwiftUI, and can't wait to start using it, but the changes to the language don't quite sit right with me.

I'm proposing a straightforward new attribute, that achieves much of the same but in a simpler, less-ambiguous way.

Code example:

div {
	if useChapterTitles {
		-h1("1. Loomings.")
	}
  	
	-p {
		-"Call me Ishmael. Some years ago"
	}
	-p {
		-"There is now your insular city"
	}
}

This is achieved by annotating one of the closure parameters with the @builderOperator attribute:

func div(parameter: () -> Void)
func div(parameter: (@builderOperator("-") inout HTMLBuilder) -> Void)

This will cause the compiler to insert the argument before every matching start-of-line prefix operator. This is all easily validated and type-safe.

So in the above example, this:

-p { …

Will become:

$0-p { …

This translates the above example into this:

div {
	if useChapterTitles {
		$0-h1("1. Loomings.")
	}
  	
	$0-p {
		$0-"Call me Ishmael. Some years ago"
	}
	$0-p {
		$0-"There is now your insular city"
	}
}

(Note: the above example works today in all versions of Swift. This proposal simply defines a way of eliminating the $0 for readability)

Here’s the custom operator:

extension HTMLBuilder {
	static func - (lhs: inout HTMLBuilder, rhs: HTML) -> Void {
	    lhs.add(rhs)
    }
}

You could easily combine a second parameter with an operator, and add an extra dimension to things:

div {
    +("id", "container")

    <a {
        +("href", URL(string: "http://www.google.com/"))
        +("title", "Google")
        +("class", "button")

        <"Go to Google"
    }

	if useChapterTitles {
		<h1("1. Loomings.")
	}
  	
	<p {
        <"Call me Ishmael."
        <br()
        <"Some years ago"
	}
	<p {
        <"There is now your insular city"
    }
}

An optional name could also be supplied, e.g. @builderOperator("<", name: "html"). This would introduce a hidden $html variable alongside $0, for the compiler to use in transformed code.

This creates a scope for this feature to work within nested closures (forEach, map etc) without having to clutter code each time with temporary variables.

Here’s a SwiftUI example:

var body: some View {
    VStack {
		+MapView()
			.frame(height: 300)

		+Text("Turtle Rock")
			.font(.title)

		let hasImage = true

		if hasImage {
			+Image("turtlerock")
				.clipShape(Circle())
				.shadow(radius: 10)
		}

		let strings = ["Hello", "World"]

		strings.forEach {
			+Text($0)
		}
	}
	.padding()
}

How this compares to function builders:

  • Explicit, user-definable syntax for yielding values
  • Easier to figure out that something is going on. To see what specifically, the user can jump to the operator definition and inspect its behaviour, as usual.
  • Just a regular closure. No weird constraints and supports all usual kinds of control flow
  • No arbitrary limit on number of yielded expressions
  • This is a minor, non-invasive change to the language. Just simple sugar, rather than a complex new feature to understand.

I haven't wrapped my head around the merits of the static/generic approach of Function Builders yet, so maybe I've missed the mark here. Let me know your thoughts.

I'm not sure that this is truly equivalent. The current function builder rewrites functions at the statement level, but your pitch requires it to be modified at the expression level. For instance, you can't write -if useChapterTitles { ... }, which is a use case that @functionBuilder currently supports.

Ah, so the branching gets embedded into the builder output? Ok buildIf makes a lot more sense now. I guess otherwise you'd have to keep re-evaluating the closure to have a SwiftUI layout react to conditional changes.

1 Like

The capitalised If isn't reserved in Swift, so in the SwiftUI example, you could probably construct a conditional using an expression. So:

var body: some View {
    VStack {
		+MapView()
			.frame(height: 300)

		+Text("Turtle Rock")
			.font(.title)

		+If(hasImage) {
			+Image("turtlerock")
				.clipShape(Circle())
				.shadow(radius: 10)
		}

		let strings = ["Hello", "World"]

		strings.forEach {
			+Text($0)
		}
	}
	.padding()
}

This way you could differentiate an ordinary Swift if statement, that immediately evaluates as expected, and a SwiftUI conditional that gets built into the DSL.

This feels like a relatively small twist on either the receiver closures pitch or the function-builders pitch, so I'm not sure it needs a separate thread. But if you want to keep it separate, I'm fine with that.

I also think it is quite strange that we can use if but have it reinterpreted as something completely different, for me that is one of the most confusing aspects of SwiftUI and functionBuilders. Since we don't allow normal forEach, why do we allow normal looking if?

1 Like