Function builders

Right, I think we are following each other now, so let me go out on a limb and spell out in more detail what my line of thinking was last time:

If one is using a function builder, either to carry some data "over" the expressions and statements, such as a Reader type, or "alongside" as in the case of a Stream type like Combine, so adding complete and error cases, won't one want that Reader struct that holds the state, or that Stream enum, to be what this function builder is overloaded on, and not Either? (Or some function type involving them, anyway. Hmm.)

Anyway, thanks for your reply, maybe mine is "food for thought" for a future proposal.

3 Likes

I can't seem to get buildIf working in Xcode 11. It gives a segmentation fault (11) for me. Is something wrong in the code here?

@_functionBuilder
public struct StringBuilder {
    public static func buildBlock(_ children: String...) -> String {
        return children.joined()
    }

    public static func buildIf(_ child: String?) -> String {
        return child ?? ""
    }
}

@StringBuilder
public func combinedString(spaced: Bool) -> String {
    "test"
    if spaced {
        " "
    }
    "other"
}
1 Like

No, that looks like it should work, and it should definitely not be crashing the compiler. Mind filing a bug over at our open-source tracker?

1 Like

Thanks, opened an issue (SR-10923)

1 Like

Now we have a limit of max 10 items inside View body. The function declaration leaves something to be desired https://developer.apple.com/documentation/swiftui/viewbuilder/3278693-buildblock . Lack of variadic generics added complexity and API workaround to Combine and Function builders. I do believe all of that is going to be changed by the release.

2 Likes

When it was requested to allow extensions for structural types, such as tuples, one of the arguments for not doing it right now was that it would require variadic generics to do properly and Swift doesn't want to implement a half-baked mechanism that will need to be replaced later. Even though in practice, the length of tuples that people are going to use very probably has some reasonable upper bound (who's going to use 50-element tuples?).

Now we're talking about a feature where, without variadic generics, you can only put up to 10 items inside a view body—something that is a very real limitation in practice—but the feature is merged anyway. Yes, maybe variadic generics will happen until the release. It's more likely that they are not though, and then nobody is going to rip the feature back out again.

I find this to be weirdly inconsistent and it's one of the reasons why I wrote in the other thread that it doesn't look as if the Swift Core team cares about the fundamental issues with Swift as much as about flashy new features that can be marketed well.

8 Likes

Let's not get haughty, variadic generic is a big endeavor, which I don't see it getting done soon, even as a priority.

With that said, Swift doesn't seem to shy away from doing this kind of thing (tuple equality, hashable, comparable, etc).

2 Likes

@John_McCall I finally could catch up the proposal and this thread. So far I'm overwhelmed by the complexity of this feature. Don't get me wrong, I like it a lot, but it was and still is hard to wrap your mind around it.

@gregtitus mentioned on Slack that the compiler executes the builder functions in a depth-first-traversal way, which is not mentioned anywhere in the proposal or in this thread, but this tiny information was crucial in my learning process. Knowing this makes this feature less magical and more predictable.

Here a good example:

@_functionBuilder
struct TestBuilder {
  typealias Component = [Int]
  
  static func buildBlock(_ children: Component...) -> Component {
    let result = children.flatMap { $0 }
    print("buildBlock \(children) -> \(result)")
    return result
  }
  
  static func buildIf(_ children: Component?) -> Component {
    let result = children ?? []
    print("buildIf \(children as Any) -> \(result)")
    return result
  }
}

func testBlock(@TestBuilder makeChildren: () -> [Int]) -> [Int] {
  return makeChildren()
}

testBlock {
  if true {
    [1]
    if true {
      [2]
    }
  }
  [3]
  if true {
    [4]
    if false {
      [42]
    }
  }
  [5, 6]
}

This prints:

buildBlock [[2]] -> [2]
buildIf Optional([2]) -> [2]
buildBlock [[1], [2]] -> [1, 2]
buildIf Optional([1, 2]) -> [1, 2]
buildIf nil -> []
buildBlock [[4], []] -> [4]
buildIf Optional([4]) -> [4]
buildBlock [[1, 2], [3], [4], [5, 6]] -> [1, 2, 3, 4, 5, 6]

I'm worried about one particular thing with the current design. What if you release a module with a builder that has not specified every possible builder functions? Theoretically I as the module user can retroactively extend your builder type with custom builder functions and potentially alter the behaviour of the building behavior that can lead to unexpected results.

I hope you tracked this issue and I'd expect the compiler to emit errors on retroactively added builder functions from outside your own module boundary.

4 Likes

This is, to me, the most important point. We can work around all the limitations of the current proposal but this is a major one that will not allow function builders to go beyond the (rather simple) scope of the composite pattern: for example, any kind of context-aware computation could not be modeled with the current implementation, and that would be a gigantic miss, even for a first iteration.

3 Likes

A question, and apologies if this is already answered somewhere:

How would the builder handle Never-returning functions? Would clients need to write _ = fatalError(…) inside a builder? Or would plain old fatalError(…) work?

The latter seems in keeping with the principle that throws works like normal without builder intervention, but seems like it would require a special exception in the proposal.

An overload of buildExpression that accepts Never and does nothing should do the trick.

Ah but SwiftUI is not the language Swift, it's a framework, and it's a framework that completely transforms the way people makes apps, so I don't we should mind that involves some workarounds that can be removed in due time.

Indeed, implementation is trivial; my question is just whether it is / should be a part of the proposal.

Apologies if this has been considered above already (this is a very long thread and I am still working my way through it)

Have we considered just keeping a stack of some common currency type (for a particular builder), and then passing each statement to a build function which transforms it to that common currency type and places it on the stack. If we have each build function take the stack inout, then optimizations should still be possible.

This should have several advantages:

  1. You should be able to use normal control flow (skipped expressions just won't have a chance to alter the stack)
  2. You don't run into the same 10 children are ok, but 11 are not problem (without needing variadic generics)
  3. You should be able to type check each expression individually (based on whether there is a matching build function for that expression)
  4. Build functions can make optimizations as they go by looking at the stack
  5. It still allows arbitrary nesting
1 Like

I should mention this is the approach I took in my own DSL, and it works really well for my needs. It even allows you to do very strong runtime error handling (It is a parser that can parse around the error and return meaningful human readable error messages along with a range pointing out where the issues are)

I also find the coroutine approach interesting. Perhaps instead of using {: }, we could have an @autoyielding annotation (at the declaration site instead of call site) that would cause it to automatically yield in appropriate contexts.

That said, I still prefer the stack based approach, as it has a good power to complexity ratio in my experience. Basically we would be bringing the power of something like Forth or Factor into our DSLs...

There are a lot of comments in this thread that discuss alternatives that are not capable of capturing the detailed type information this proposal is capable of capturing. It’s fine to prefer these approaches. They work very well in many contexts! But I think it’s important to address the elephant in the room. What would have SwiftUI do? How would you modify the design of SwiftUI such that it is compatible with that kind of DSL support?

3 Likes

I would argue that the Controller is now able to be broken up into lots of reusable pieces (i.e. Combine) instead of being a massive cluster that has to be rewritten for each app.

I had written my own version of this a couple of years ago (I called them Chains), and have been happily using that pattern in most of my code. Was working on open sourcing those, but with Combine doing it better, there is no need. lol.

It should still be able to capture the same type information when needed (see post above), however some of the optimizations might be pushed to runtime (until we get compile-time functions). SwiftUI would look the same at the use-site, but have a slightly different approach for builder declarations (defining functions taking individual lines and only a single build-block). The advantage is that you could have things like variable assignment and control-flow statements natively, and you wouldn't have the 10 item limit. It could also be done in a reasonably short timeframe.

Which post? Are you able to provide examples? I would like to see a sketch of how the feature would be used by a library like SwiftUI so I can better understand how it is capable of meeting the needs of a library like that.

If you mean this post I don’t see how a “common currency type” for the builder could meet the needs of SwiftUI. It’s typing requirements are much more sophisticated than anything I can imagine meeting that description. But maybe I’m missing something.