SE-0289: Function Builders

I would be happy to see this proposal accepted because of the kinds of libraries we could start to see. I'd love to see more declarative programming in the model layer (could you imagine what the Point Free gents would do with this?), XCTest and so on.

I have sympathy for this view. SwiftUI and all the experimental examples should be enough to have some insight. But I don't think it's within the scope of this proposal. Since Swift is an open source initiative, I do not think it should only be the authors responsibility to provide it. The proposal mentioned @anreitersimon's ArrayBuilder<E>. Perhaps this person might be willing to pitch this addition to the standard library?

  • What is your evaluation of the proposal?

+1

At first, I was against this feature because of how different the semantics are to normal code. But then one of our teammates implemented a Texture (AsyncDisplayKit) DSL using function builders and our views setup code dramatically shrunk and now it's very hard to write new ones without it.

Although, I believe it would still improve drastically to have some way to signal the point where the function builder world starts, at the call site. Something like a keyword for the enclosing closure:

var body: some View { 
    return Button(
       action: { in
           // signals standard execution closure
       },
       label: { with // instead of "in"
           // signals function builder context
       }
    )
}

This can be optional like in, but when it's there it would be significantly easier to differentiate the context switching going around.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes, but I understand that not many are affected by this "problem" right now and may not see its use yet. But we are seeing more and more use of declarative UI across the industry, and I think it's very very good that Swift has this tool very early on. As an example, our team was used to the usual UIKit view setup style that it was insignificant for those cases, but we saw how bad our old code were in places where we used Texture (i.e. declarative UI) and how game-changing function builders were for them.

I saw a lot of arguments against function builders that is based mostly on how they dislike the syntax. While consensus should be met regarding that before accepting this proposal, that should not be enough to reject the proposal altogether because the current alternatives are really not ideal.

  • Does this proposal fit well with the feel and direction of Swift?

Yes. While I think there is room for improvement (as I mentioned above), the current implementation is a very good step if Swift is to be used with modern code and modern paradigms (ex: Declarative/reactive UI).

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I've seen a lot of DSL libraries in Swift, but putting syntax improvements aside, I think they would all benefit from the extra functionality of function builders like if-else handling, optional components in lists, static typing, etc.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I've used the feature both in real-world SwiftUI and in my own @_functionBuilders. I had actively been reading past function builder posts in the Swift forums, read the proposal, read earlier posts in this thread.

3 Likes

Given the current state of the proposal, -0.5.

During pitching I brought up two main concerns that were acknowledged but never addressed.

:one: I believe functionBuilder is the wrong name. This feature is not about "building functions" nor defining a "builder of functions". At its heart its about defining a domain-specific language, but could also be viewed as a "source code transformation".

There's nothing in the pitch that describes why the name "function builder" is appropriate, and even the initial definition of "function builder" goes out of its way to clarify what it actually means:

At the very least the pitch is missing the acknowledgement of alternative names and rationale for why @functionBuilder is the best name. And at most, the feature is mis-named.

:two: There is no help provided to a DSL author to know which methods they actually need to implement.

In every other scenario of "an author wanting to supplement behavior", the compiler provides information about what's going wrong on that type that prevents successful compilation.

For protocols, we get errors about not having implemented the properties and methods defined by the protocol. For attributes like @main, failing to provide the static func main() requirement results in a compiler error that: 'MyEntryPoint' is annotated with @main and must provide a main static function of type () -> Void or () throws -> Void.

What analogous help is there here? The proposal says I can decorate a type with @functionBuilder to "make it a function builder", but then an author is immediately abandoned and left wondering "... now what?". The likely answer to this is "read the documentation", yet an ongoing thread here is all about acknowledging the flaws in our documentation. Perhaps the 1.0 version of this will ship with great documentation. But what about 1.1 versions and beyond?

Providing type-based hints from the compiler about "what do I do next" is a raison d'Γͺtre for Swift, and this feature is completely lacking them.


I like the feature and want to use it, but I don't think this proposed change is good enough.

Mixed. This proposal highlights a massive missing area in Swift: a proper macro feature. I've had several situations where I've wanted to do source-level transformations and generation of code as part of compilation, and it pains me to see, yet again, a one-off solution being baked into the compiler.

N/A

I've been avidly following along and experimenting with function builders on my own.

34 Likes

As of current implementation, it seems like the build* methods that you need to implement depends on the features you want to support in your function builder type. So, I think the compiler cannot warn or error about missing methods by default 1. It can only do that if you try to use a feature without implementing a corresponding build* method.

So it seems to me that the information needs to surface in another way. One option might be that the compiler exposes the build* methods via code completion and where selecting one of the options inserts a placeholder for the user to complete. As a special case, the options can be prioritised over all other options available in that context iff the function builder type has no existing build* methods (basically, no matter what you type, the build* methods would always be on the top. I am not totally sure if this would be a good idea, though).


  1. I think builder types would need at least the buildBlock method so maybe it could be an error if the user hasn't defined that.
3 Likes

+1. This has a lot of potential applications in Swift on the server including view building, query building, route building, etc. Looking forward to this becoming official so I can start using it. Thanks John and Doug.

3 Likes

Maybe that is the point.
Everyone might not have participated since the beginning, and it does not seem just that participating be reserved to those that were.
While the proposal is clearly inspired by the Binding Pattern, it is also quite different as it is not a theoretical inception here, but a real implementation of a concept in a programming language/context.
In a general manner, possibly too theoretical (Maybe I start contradiction myself here :-) ) , it seems to be a good practice to describe the goal, before you describe the road to get there.

But I'm getting carried away.
My comment is not a critic of the proposal.
My intuition tells me that it will be hugely beneficial to users and to the language.
And that benefit will be multiplied with a clear and comprehensive explanation of what a functional Builder is.
So the newcomer can pick it up and quickly feel as at home with the concept as the old-timer.

I want to look into the transformation of optionally-executed blocks (esp. if-else and switch) a bit more. I looked into the examples in the Awesome GitHub, and categorized them into three flavours †:

  1. Heterogeneous: where each block produces different types, or the information about which branch produces the result is preserved,
  2. Homogeneous: where each block produces the same type, and the branch information is discarded,
  3. Incongruent: where the optionally-executed block is disallowed.

For type-erasing builders, such as ArrayBuilder, the homogeneous transformation is the most common, followed by the incongruent case. For type-preserving builders (mostly SwiftUI-inspired and/or UI-related), the common case is heterogeneous transformation.

The incongruent case is achieved simply by not defining buildOptional, and heterogeneous transformation by defining both buildOptional and buildEither. The homogeneous transformation, however, is very ceremonious, requiring the builder to define three functions:

func buildOptional(...) -> Component { ... }
func buildEither(first: Component) -> Component { first }
func buildEither(second: Component) -> Component { second }

At the same time, when only buildOptional is defined, the partial results (for the lack of a better naming) transformation is used ‑. The problem is that the partial result case is not being used by any of the exemplars.

I propose that for the case with only buildOptional, we use a slightly different transformations, which I call homogeneous injection. The homogeneous injection is akin to when you define buildEither that simply passes the values. More precisely, the homogeneous transformation is as follows:

  1. Declare vMerged at the beginning of the branching blocks.
  2. For each result producing block, assign buildOptional(block) to vMerged.
  3. For non-result producing block, assign buildOptional(nil) to vMerged.
  4. Type-check all vMerged assignment together.

So the code

if condition1 { a }
else if condition2 { b }

is transformed into

let vMerged
if condition1 {
  let block = ...
  vMerged = Builder.buildOptional(block)
} else if condition2 {
  let block = ...
  vMerged = Builder.buildOptional(block)
} else {
  vMerged = Builder.buildOptional(nil)
}

And of course, we can also skip buildOptional if there are only result-producing cases, like in switch. So now the API authors can:

  • Use injection tree for heterogenous transformation by defining buildOptional and buildEither,
  • Use homogeneous injection for homogeneous transformations by defining only buildOptional, and
  • Disable optionally-executed blocks by not defining buildOptional.

It also seems to simplify the explanation, since this case can be seen as skipping buildEither, similar to how buildExpression and buildFinalResult are skipped when not defined.

Footnotes:

† From the Awesome GitHub:

The rest are incongruent cases. I also saw some builders define: buildIf(_: Component?) -> Component or even buildIf(_: Components?...) -> Components. From what I can grok, they implement it as a homogeneous transformation.

‑ In my testing with Xcode 12.0 Beta 2, if you define only buildOptional, you can only use if without else or else if. The proposal seems to suggest that you can still use else and else if, which will produce more variables. Could someone confirm which of the two behaviours is the intended one? It doesn't affect my suggestion here, but it's always good to clarify cases like this.

Yeah, I think it would be good to emit an error/warning if none of the function builder methods are provided. Then this can be handled the same way as StringInterpolationProtocol's ad hoc requirements: There's a custom diagnostic (error: type conforming to 'StringInterpolationProtocol' does not implement a valid 'appendInterpolation' method) and a linked note explaining the special requirements (https://github.com/apple/swift/blob/master/userdocs/diagnostics/string-interpolation-conformance.md).

4 Likes
  1. What is your evaluation of the proposal?
    +1 β€” We've been following this development and it's evolution since the first versions last year have been positive and well informed.

  2. Does this proposal fit well with the feel and direction of Swift?
    Yes β€” I think it helps tremendously to empower developers for a broad range of use cases.

  3. If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
    n/a

  4. How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
    Read the original pitch, have been using the feature actively since Dec' 19 to develop a compile time type safe eDSL to replace a C++ library.

Read the official SE-0289 from top to bottom and most of the feedback in this thread. Hoping for...in and throws to make it into the 5.3 GM (one can hope right :smile: )

It would be nice if we could ignore Void expressions, assignments, and @discardableResults. Though given the transformation model, it's understandably tricky, if not impossible, to pull off.

Still, we should at least ignore assignments. It sounds weird if assignment is ignore in one circumstance (when assigning to an uninitialized instance), but not another.

Isn't this possible by adding a buildExpression overload for Void?

static func buildExpression(_ input: Void) -> []

@discardableResult would still need an underscore assignment though.

It's trickier than that in heterogeneous builders, and I wouldn't expect anyone to think that assert(x) becomes EmptyView.

It should still be reasonable to ignore buildExpression returning Never and/or Void. No cake for @discardableResult though as it's not visible to the type system.

Also, both solutions require the builder to include buildExpression, which is currently optional.

You could create buildExpression overloads for all your input types and then an additional one for Any that would send all other input types to some empty value like the overload for Void.

We probably don't want Any, that sounds like a footgun. And as said, this requires you to include buildExpression, which was optional before.

That's why I think post-buildExpression Void expressions could be ignored, it could work with or without buildExpression, and I don't expect any builders to lose its functionality. If anything, they can opt-in to include Void by adding:

func buildExpression(_: ()) -> Empty

Which is ceremonious, but seems to be a much rarer case.

In discussion with @woolsweater, I realized that we may want to tweak the behavior of return statements slightly.

The proposal says:

return statements are ill-formed when they appear within a transformed function. However, note that the transformation is suppressed in closures that contain a return statement, so this rule is only applicable in func s and getters that explicitly provide the attribute.

And elsewhere, it also notes:

[Inference of a view builder attribute from a protocol requirement] occurs unless:
[snip]

  • The body of the function or property getter contains an explicit return statement.

In other words, in places where we might infer a function builder attribute, return statements are legal and opt out of the inference; in places where the function builder attribute is explicitly applied to the declaration, return statements are illegal, but you can opt out by removing the attribute instead.

Having an opt-out is good because it provides an escape hatch when some specific function is more easily implemented by computing a return value instead of using the builder. However, I think the behavior difference when a function builder attribute is explicitly used is undesirable.

It might be better to allow the return statement, but warn that the attribute will be ignored. For instance, given this input:

struct MyView: View {
  @ViewBuilder var body: some View {
    return computeView()
  }
  ...

The compiler might say:

example.swift:2:3: warning: function builder attribute 'ViewBuilder' will be ignored because 'body' explicitly returns a value
  @ViewBuilder var body: some View {
   ^
example.swift:2:3: note: remove '@ViewBuilder' to silence this warning
  @ViewBuilder var body: some View {
  ^~~~~~~~~~~~~
example.swift:3:4: note: remove 'return' keyword to collect expression results with 'ViewBuilder'
    return computeView()
    ^~~~~~~
10 Likes

5.3 branched a while ago. Neither for...in nor throws will be back-ported to 5.3.

Doug

As I see it, when we hit this case you have explicitly stated two things that are in conflict: applying a function builder, and having a return statement. That seems like reasonable cause for an error.

Doug

3 Likes

Yes, this is a great point. @Jumhyn has a pull request up to introduce a check specifically for this, and I'll prepare a revision to the proposal making this a requirement.

Doug

4 Likes

I feel like the homogeneous case is best handled via something like ArrayBuilder, which eliminates all of the ceremony in one shot. Making the ad hoc protocol around buildEither/buildOptional more complicated to help the homogeneous case doesn't feel like an overall win.

My intent was for buildOptional to enable if without an else, and buildEither to allow if with an else; I'll clarify the proposal.

Doug

2 Likes

@discardableResult suppresses the warning when the result of a function call isn't used and the call is at the root of an expression statement. Function builders gather the results from expression statements, so the warning won't fire in the first place, making @discardableResult effectively irrelevant.

Yes, I'll fix it in the proposal. Thank you!

Yes, I can work on clarifying this.

Doug

2 Likes