(I started writing this on Saturday, and although I’ve tried to follow the discussion since, I probably haven’t read everything. Sorry if this duplicates anything previously discussed.)
Some comments directly on the pitch:
Variation in the DSL’s dialect
The contents of a function builder are a distinct dialect of Swift. Some features, like variable declarations, work basically as usual; others, like expression statements, are slightly modified; still others, like catch
blocks, are banned.
A few constructs, like if
, for
, and do
, are banned by default unless the function builder declares support for them. I am a bit skeptical of this last category. Function builders are sort of an inherently confusing feature—you are putting the language into a special mode where it behaves differently from usual. That’s bad enough, but these knobs introduce additional variation in capabilies, reducing user confidence in their behavior. It seems like they ought to require additional justification.
(If this seems like I’m making a mountain out of a molehill—there are only three of these, after all—it’s because I have one eye on the future evolution of function builders. I could easily imagine there being a dozen of these things controlling break
and while
and catch
and all sorts of other language features, and I could easily imagine many function builders not supporting some of these features purely by accident—because a developer forgot to turn something on or because they were designed before a particular feature had a build
function. I’d like to establish a design direction at the outset that would avert this.)
There’s at least one obvious good reason for for
to be controllable: a DSL which provides its own loop-like constructs, like SwiftUI.ForEach
, may want to disable for
to avoid it being an attractive nuisance. I can certainly imagine DSLs which would have simliar alternatives to if
and would want to disable the built-in one for the same reason. I’d like to see the proposal explore this question, but I think there’s probably adequate motivation for these.
But why do
? With catch
blocks disabled, do
’s only effect is to limit the scope of variables. I can’t imagine a reason why a function builder might want to prevent you from using it. On that basis, I think do
should be supported in function builders all of the time, not just when buildDo
is implemented.
(In fact, I’m not sure we really need buildDo
at all. buildOptional
, buildEither
, and buildArray
are all necessary because they reflect different patterns of control flow, but do
doesn’t actually affect control flow. Instead, we could either represent do
with a bare buildBlock
or inline its values into the parent block’s.)
buildDo
’s arity
Even if we keep buildDo
, though, I think it should be a unary function with a buildBlock
call nested inside it, rather than directly taking the values generated by its block.
Blocks can have more than one statement, so buildBlock
needs to take a variable number of parameters. This means that type-preserving function builders (as opposed to ones which erase all inner types) need to provide many overloads of buildBlock
with different arities (at least until variadic generics arrive). This is unfortunate, but necessary for now.
But the proposed design puts buildDo
in the same boat for no apparent benefit. If we instead had buildDo
take one parameter and nested a call to buildBlock
inside buildDo
, there would be no need to complicate buildDo
to support multiple arities.
This would also make buildDo
more similar to buildOptional
, buildEither
, and buildArray
, which all use buildBlock
to group together the values they’re passed. I think “Groups of statements are always passed through buildBlock
” would create a simpler mental model for implementors of function builders—see a scope, mentally translate it into a buildBlock
.
So if buildDo
does survive, I think it should take one parameter and it should be expected that a buildBlock
call will produce that parameter.
buildOptional
and buildEither
I don’t like the redundancy of buildOptional
and buildEither
.
Broadly, I think of function builders as falling into two categories:
- Type-erasing function builders, which are just trying to collect values from a computation to marshal to some common type.
HTMLBuilder
is an example of a type-erasing function builder.
- Type-preserving function builders, which are trying to capture not only the values produced by that computation, but also the domain of possible values it could produce in the form of a complicated generic type.
SwiftUI.ViewBuilder
is an example of a type-preserving function builder.
Type-preserving function builders are probably almost always going to want to capture the distinction between an if
/else
and two adjacent if
statements. So they will always want to implement buildEither
; buildOptional
is an unwelcome complication. As far as these are concerned, we could treat an else-less if
as having an empty else
block, represented as B.buildEither(second: B.buildBlock())
.
(If SwiftUI.ViewBuilder
wants to keep using Optional
for one-way branches, it could provide a second set of buildEither
overloads where second: EmptyView
.)
So that means buildOptional
is mainly a convenience for type-erasing function builders. But it’s only going to save one method declaration over a world with no buildOptional
; if we instead provided a fallback from buildEither
to buildArray
, that would save three declarations with no loss of functionality.
So I don’t think buildOptional
carries its weight.
Ad-hoc method patterns
Generally, the patterns of calls inserted by function builders feel ad-hoc. Reading the proposal alone didn’t make me feel confident that I fully understand which calls will be inserted into a function where; I’ve had to experiment to get a feel for it.
This problem is exacerbated, of course, by the fact that we can’t print ASTs to show the generated calls and declarations. In evaluating this proposal, I found it helpful to write a function builder which records the calls used to produce its result. We might want to consider including something similar in the standard library or as a public package.
But I think this also reflects weaknesses in how the build
methods are named, how the feature is currently being taught, and ultimately in some of the decisions made by the proposal. If the various build
methods were more clearly connected to control flow and differences in how often code is run—rather than to specific syntactic features—I think function builders would be easier to think about and work with.
The playground transform
As a long-term goal, I think we should think about how much we might be able to unify function builders with the playground transform. The playground transform is largely about inserting calls into code to expose the values and control flow without actually changing what it does. There are obvious differences—the playground transform applies to all code in the input file, doesn’t affect return values, and supports more code constructs—but the first two differences seem like flags that could be turned on or off, and the third seems like it could be addressed by extending function builders to support more constructs. In the long run—if not today—we might be able to reduce the size of the compiler (and get rid of some code that’s tied pretty closely to Apple implementation details).