Function builders

My theory is that the way the feature is designed has relevance for updates:
The system might always know that (example) component 0 and 2 in a constructed view contains text, and component 1 displays an image — and that the data generated during an update has the right types (String, CGImage(?)) with constant offsets.
I don't think you can achieve such a mapping with a more flexible approach; but I've neither seen the source to check that this is true at all, nor have I seen benchmarks that prove that the wins are significant enough to justify the downsides.
So from my perspective, the whole discussion is blindfolded :-(.

4 Likes

I guess my main worry is the lack of flow control within the DSL. Even if we can simulate if and forEach, this is something that we will keep running into rough edges on (e.g. if let) because we are simulating flow control instead of using it. This will be a nightmare to teach, because of all the special cases.

I think having native flow control just work should be a primary design goal, and I agree with others that this may be a case of premature optimization.

Is there a reason that these representations couldn't be part of the stack implementation? For example, I could easily have a TupleNode3<A,B,C> that gets built from a TupleNode2<A,B> and a value. It seems like there should be some way to finesse the needed performance. To the end user it all just looks like .push(value) even if the internals are fairly complicated.

This is the tricky bit, because the resulting type could be very different based on flow control.

I'm wondering how detrimental it would be to flip it, where you have optimizations for cases where things are fixed/known, but still allow cases with natural flow control. It would basically behave like there was automatic promotion to AnyView when faced with a type mismatch. That seems like what you would want from a UX perspective (and from an avoiding StackOverflow perspective), but I don't know if it would make things unworkable performance wise.

It is really hard to provide constructive feedback when we don't understand what the underlying tradeoffs are...

1 Like

True, the same issue exists with property wrappers where you could theoretically extend a wrapper type wrapperValue and if not guarded by the compiler it will break existing code. However this issue was mentioned during the last pitch and @Douglas_Gregor said it‘d be best if the compiler would emit a warning.

In case of builder functions I‘m not sure if we should allow some extensions and ignore others. If I understood the functionality correctly then some builder functions will fallback to others if not present. This is a potential area of abuse through retroactive extensions.

I see. This is quite an interesting approach.

How does it behave if it goes through different path?
You said it'd work nicely with control flow, which I still don't see.
If they do different push/pop in different branch, or even unbalanced push/pop in iterative branch, we'd end up with different type signature. So we somehow need to merge them at closure boundary.

Would you also kindly add the transformation of some simple DSL structure with this design? Say,

block {
  node1
  node2
  if condition {
    node3
    if condition1 {
      node3_1
    }
  } else {
    node4
  }
  node 5
}

How would the transformed closure look like? Just the function invocation would be fine (might as well also demonstrate looping while you're at it).

How did you get the impression that we're simulating flow control? The current implementation doesn't support if let / if case for internal implementation reasons that we should easily be able to fix (we need to type-check the pattern binding within an existing constraint system instead of separately), not because it's somehow a deep limitation of the approach.

I don't think a design that automatically promoted conditionally-evaluated views to AnyView would be desirable, either for SwiftUI (lots of structure would become dynamic) or for the feature in general (how many DSLs would have an AnyView equivalent?).

This is a feature not a bug in my book.

depends on what you consider abuse. In my view this could be a way for me to hook into providing my own implementation for other platforms not supported by SwiftUI for example.

Well this needs to be battle tested to avoid potentially breakable behavior and the rules needs to be very strict. By that I mean if you take a simple wrapper type that has no wrapperValue for example. If your module relies on $property which has the type of your property wrapper, but I then retroactively add wrapperValue and assume that the compiler would not ignore it. All of a sudden $property will be a different type, your code theoretically cannot function correctly as it‘s in a state that otherwise would be a compile time error.

However there are overloads that would make sense if a certain builder type does not already support them, for example func buildExpression(_: Never) -> Component {}.

This is the problem. My approach can handle this case, but, as John points out, it (or any dynamic approach) can't use that type as the return value (since it will end up a different type on different paths). My approach falls back to the common currency type when there is a discrepancy between types.

Here is an example of a hybrid approach to your code question, which tries to make the same optimizations around if statements as the proposed approach, but still allows full compatibility with flow control by type erasing when there is a mismatch. Your example is a good one to show the difference, because I believe the proposed approach wouldn't compile if all the nodes are different types here (without wrapping nodes in an AnyView equivalent).

This fallback is either a feature or a bug depending on your point of view...

block {
    let s0 = BuildStack()  //Type: EmptyStack
    let s1 = BuildExpr(node1, stack: s0)  //Type: Stack<Node1> (member of BuildStack protocol)
    let s2 = BuildExpr(node2, stack: s1)  //Type: Stack <(Node1,Node2)>
    //We have to type erase here because the different paths have different types
    var s3:BuildStack = s2 //Type: BuildStack
    if condition {
        let s4 = BuildExpr(node3, stack: s2)  //Type: Stack <(Node1,Node2,Node3)>
        //We don't have to type erase here because we use the optional trick of the proposal
        var s5 = s4.pushNil(of: Node3_1.self) //Type: Stack <(Node1,Node2,Node3,Node3_1?)>
        if condition1 {
            s5 = BuildExpr(node3_1, stack: s4).optionalTop //Type: Stack <(Node1,Node2,Node3,Node3_1?)>
        }
        s3 = s5 //Type: BuildStack
    } else {
        s3 = BuildExpr(node4, stack: s2) //Type: BuildStack
    }
    let s6 = BuildExpr(node5, stack: s3) //Type: Stack<(BuildStack, Node5)>
    buildBlock(s6)
}

Also note that with this hybrid approach, we can still limit the types accepted by the build blocks, we are just also able to accept type erased parts if desired...

I suppose that with sufficient compiler smarts we could end up passing Stack<(Node1,Node2,BuildStack,Node5)> to the Build block instead of Stack<(BuildStack,Node5)>, but I am not sure how much work that would take...

I think it would probably be better to just acknowledge pairwise formation as a workaround for the lack of variadic generics, so that you could e.g. provide a buildPartialBlockPair and then closures with more than 1 partial result would get folded by a sequence of such calls, so that buildBlock would only ever be called with a single element. Otherwise result formation wouldn't need to change.

4 Likes

@John_McCall can you please provide a diff of changes in your proposal after you update it, similarly to how it was done with the property wrappers proposal. The proposal text is quite long and hard to follow, it makes it no better if one would need to re-read everything multiple times after new changes to the design are introduced. Thank you in advance. :slight_smile:

I might have forget some details of the proposal and if this was previously mentioned in this thread, but could we make the compiler ignore constants and variables until they are used in an expression or explicitly written out and left unused, which by the standard rules would emit a warning but the in case of this feature would be picked up by the builder? This would allow re-usability right from the building closure.

@_functionBuilder
struct TestBuilder {
  typealias Component = [Int]
  
  static func buildBlock(_ children: Component...) -> Component {
    return children.flatMap { $0 }
  }
  
  static func buildExpression(_ component: Component) -> Component {
    return component
  }
  
  static func buildIf(_ children: Component?) -> Component {
    return children ?? []
  }
}

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

testBlock {
  // error: Closure containing a declaration cannot be used with function builder 'TestBuilder'
  var value = [1, 2]
  value
  value.map { $0 * 4 }
}

I would expect that the result from makeChildren() to equal [1, 2, 4, 8].


So in general the builder will only pick up unused elements, which allows more flexibility inside the builder block itself.


This idea originates from a SwiftUI tutorial where I'm really visually annoyed by the indentation of the code.

var body: some View {
  VStack {
    ...
    VStack {
       ...
    }
    .padding()
  }
}

Instead I'd write this:

var body: some View {
  VStack {
    let stack = VStack {
       ...
    }
    ...
    stack.padding()
  }
}

or like this:

var body: some View {
  VStack {
    let stack = VStack {
       ...
    }
    ...
    stack
      .padding()
  }
}

And as a bonus I can reuse the view locally instead of creating a totally new type for re-usability.

3 Likes

As with all pyramids of doom, also in SwiftUI it's good idea to isolate and componentize frequently, it's explicitly even stated in WWDC session videos.

However, if you don't want to create entirely new struct for your sub-component, you can also in some cases just create new sibling properties to body in the struct itself, and use them inside body, which pretty much is what your examples seem to want:

struct Test: View {

  let stack = VStack {
    Text("Foo")
    //...
  }

  var body: some View {
    VStack {
      //...
      stack
        .padding()
    }
  }
}
2 Likes

Ok. If you think we can get full control flow (including guard and switch) in a reasonable time frame with the current proposal, then I trust you.

I have run into quite a few problems in playing with SwiftUI due to it's reliance on _internalTypes (especially _modified) and protocols with associated types. For example, I can't clip to a shape which has been sized. I expect these rough edges will work themselves out by the time it comes out of beta. I'm ok with static being the default as long as dynamic is still possible at launch. Right now, I have had to use ugly hacks to get dynamic behavior (e.g. allowing the user of a custom view to set either a color or gradient fill)... but I could be doing it wrong since I am still learning how SwiftUI works.

1 Like

In the WWDC session on creating custom views in SwiftUI they had a compelling usage of this type information. The example was creating a dynamic pie chart, where segments (which were represented by a custom view type) were mapped to live data. Once enough data points were added, things started to get laggy, as the overhead of so many views started to show. However, the segment custom view type was a particular type of view that basically meant it was a graphics primitive. In the demo, they changed the type of the pie chart view to be a ShapeView which enabled SwiftUI to generate a custom Metal view for the pie chart. All of the declared data mappings and animations were preserved, but all of the lag simply disappeared. To me this was a clear demonstration of the power of this approach to handling declarative APIs, and also made me think that someone on the SwiftUI team has read Leland Wilkinson's "The Grammar of Graphics". I'm very excited about this feature.

4 Likes

Is there any reason we don’t treat guard like a bigggg if-else?

Thank you, but I didn‘t asked for alternative solutions as they are trivial, and that‘s not what I want. First I mentioned that I don’t want to create a new type for every reusability case (that’s what wwdc suggests "to type creep"), nor do I want to create the reusable parts as type members which does not make them lazy/computed. Furthermore the example was extremely simplified, but it may want to capture other local state that should also be reused. Last but not least, such alternative solutions do not improve or generalize the design of the feature discussed in this thread.

1 Like

Can we get some parity between Function Builders and Property Wrappers and how the basis of these can be exposed as custom attributes?

There is an umbrella of functionality being introduced to support SwiftUI that would be really great if it was also holistically applied to user defined attributes or at least consider how these features could be bubbled up.

1 Like

Do you actually get this indentation? This is what I would like, but for me the end brace is indented an extra step.

1 Like

From my perspective, the "umbrella of functionality" that's being introduced is exactly this concept of user-defined attributes via marked types that we've been developing across several different pitches, starting with the static-attributes pitch you yourself linked. As a result, I'm not really sure what you feel isn't being "holistically applied" or where you feel there isn't "parity". Are you asking for a general proposal for attributes, divorced from any specific language feature that would use them, just to lay out the basic rules? Are you just asking for someone to work on a specific application of the concept, perhaps the static attributes pitch or a dynamically-available variant of it? Or are you concerned that somehow the concept will be less applicable to things like static attributes than it is to, say, property wrappers?

1 Like

I was thinking specifically about the idea of function decorators (like python) and how that would compose with something like function builders.

I was thinking the different static functions that are required for Function Builders and how those requirements (that are not necessarily attached to a protocol) will be defined in used defined static-attributes

No?. You are right in saying that static attributes is sort of the umbrella here. Maybe that would necessitate different static-attributes per var, func, etc. So that instead of having one way of declaring static attributes, one would need to decide what sort of things that attributes supports (var, let, func)

yeap that is it. In my mind, in a perfect world static-attributes should provide the means to specify a way to compose but after thinking a little longer I don't think that is the case.

1 Like