SE-0348: buildPartialBlock for Result Builders

How Apple would achieve and optimize it internally is one question, but due to the fact that tuple views can be nested, you yourself could define buildPartialBlock in terms of nested TupleViews or maybe even Groups.

4 Likes

Two possible answers to @mtsrodrigues's question about ViewBuilder:

Read more

Disclaimer: I don't work on the SwiftUI team and have no idea if, when, or how they might choose to adopt buildPartialBlock. I'm just discussing designs that I believe, based on publicly-available information, they could adopt if this proposal were accepted. Also, everything you see here is untested.

For reference, here's a rough approximation of how SwiftUI is currently implementing buildBlock, reconstructed from its module interface (but with cleaner formatting and without a bunch of keyword/attribute/extension clutter):

@resultBuilder struct ViewBuilder {
    static func buildBlock() -> EmptyView {
        EmptyView()
    }
    static func buildBlock<Content>(_ content: Content) -> Content where Content: View {
        content
    }
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
        return .init((c0, c1))
    }
    static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0: View, C1: View, C2: View {
        return .init((c0, c1, c2))
    }
    static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0: View, C1: View, C2: View, C3: View {
        return .init((c0, c1, c2, c3))
    }
    static func buildBlock<C0, C1, C2, C3, C4>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> TupleView<(C0, C1, C2, C3, C4)> where C0: View, C1: View, C2: View, C3: View, C4: View {
        return .init((c0, c1, c2, c3, c4))
    }
    static func buildBlock<C0, C1, C2, C3, C4, C5>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> TupleView<(C0, C1, C2, C3, C4, C5)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View {
        return .init((c0, c1, c2, c3, c4, c5))
    }
    static func buildBlock<C0, C1, C2, C3, C4, C5, C6>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View {
        return .init((c0, c1, c2, c3, c4, c5, c6))
    }
    static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View {
        return .init((c0, c1, c2, c3, c4, c5, c6, c7))
    }
    static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View, C8: View {
        return .init((c0, c1, c2, c3, c4, c5, c6, c7, c8))
    }
    static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View, C8: View, C9: View {
        return .init((c0, c1, c2, c3, c4, c5, c6, c7, c8, c9))
    }
    // ...other builder methods omitted...
}

I see two ways SwiftUI could adopt buildPartialBlock. First, there's the simple way which gives slightly different results:

@resultBuilder struct ViewBuilder {
    // Still need this to allow empty blocks:
    static func buildBlock() -> EmptyView {
        EmptyView()
    }
    static func buildPartialBlock<Content>(first content: Content) -> Content where Content: View {
        content
    }
    static func buildPartialBlock<C0, C1>(accumulated c0: C0, next c1: C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
        return .init((c0, c1))
    }
    // ...other builder methods omitted...
}

The difference between the original implementation and this simple one is that, given this view:

struct MyView: View {
    var body: some View {
        View1()
        View2()
        View3()
    }
}

The builder implementation with multiple buildBlock overloads will produce a TupleView<(View1, View2, View3)>, whereas the implementation with a single buildPartialBlock will produce a TupleView<(TupleView<(View1, View2)>, View3)> instead. Even though these types are different, though, they would actually both work fine. This is probably what I would have taught in my talk if buildPartialBlock had been available.

(If buildPartialBlock had been available from the beginning, I imagine SwiftUI would have implemented a much simpler PairView<C0: View, C1: View> instead of the TupleView<Tuple> they use now, which I believe leans on some runtime magic to extract the views from the tuple. But this is basically fanfiction about an alternate universe.)

If this difference in types was unacceptable, ViewBuilder could take a second approach instead. It could retain the same overload set it had before, but change most of the buildBlock methods to buildPartialBlock. This would expand functionality while keeping the same types for existing code:

@resultBuilder struct ViewBuilder {
    // Still need this to allow empty blocks:
    static func buildBlock() -> EmptyView {
        EmptyView()
    }
    static func buildPartialBlock<Content>(first content: Content) -> Content where Content: View {
        content
    }
    static func buildPartialBlock<C0, C1>(accumulator: C0, next: C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
        return .init((accumulator, next))
    }
    static func buildPartialBlock<C0, C1, C2>(accumulator: TupleView<(C0, C1)>, next: C2) -> TupleView<(C0, C1, C2)> where C0: View, C1: View, C2: View {
        return .init((accumulator.value.0, accumulator.value.1, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3>(accumulator: TupleView<(C0, C1, C2)>, next: C3) -> TupleView<(C0, C1, C2, C3)> where C0: View, C1: View, C2: View, C3: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3, C4>(accumulator: TupleView<(C0, C1, C2, C3)>, next: C4) -> TupleView<(C0, C1, C2, C3, C4)> where C0: View, C1: View, C2: View, C3: View, C4: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, accumulator.value.3, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3, C4, C5>(accumulator: TupleView<(C0, C1, C2, C3, C4)>, next: C5) -> TupleView<(C0, C1, C2, C3, C4, C5)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, accumulator.value.3, accumulator.value.4, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3, C4, C5, C6>(accumulator: TupleView<(C0, C1, C2, C3, C4, C5)>, next: C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, accumulator.value.3, accumulator.value.4, accumulator.value.5, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3, C4, C5, C6, C7>(accumulator: TupleView<(C0, C1, C2, C3, C4, C5, C6)>, next: C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, accumulator.value.3, accumulator.value.4, accumulator.value.5, accumulator.value.6, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(accumulator: TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)>, next: C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View, C8: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, accumulator.value.3, accumulator.value.4, accumulator.value.5, accumulator.value.6, accumulator.value.7, next))
    }
    static func buildPartialBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(accumulator: TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)>, next: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0: View, C1: View, C2: View, C3: View, C4: View, C5: View, C6: View, C7: View, C8: View, C9: View {
        return .init((accumulator.value.0, accumulator.value.1, accumulator.value.2, accumulator.value.3, accumulator.value.4, accumulator.value.5, accumulator.value.6, accumulator.value.7, accumulator.value.8, next))
    }
    // ...other builder methods omitted...
}

Both the currently shipping implementation and this more complex replacement would produce TupleView<(View1, View2, View3)> for the three-subview view we looked at before. The difference is that, given a view with over ten subviews (since that's the limit of the largest overload):

struct MyView: View {
    var body: some View {
        View1()
        View2()
        View3()
        View4()
        View5()
        View6()
        View7()
        View8()
        View9()
        View10()
        View11()
        View12()
    }
}

With the original builder, the compiler just gives up and emits an error (I believe it complains about the buildBlock call having too many arguments). But the builder with an overloaded buildPartialBlock(accumulator:next:) would neatly group the views into tuples of ten before wrapping them in another nested tuple, producing a result like TupleView<(TupleView<(View1, View2, View3, View4, View5, View6, View7, View8, View9, View10)>, View11, View12)>. (Or at least it could do that--it might take a little tweaking. Like I said, I haven't tested this.)

20 Likes

Thank you for this amazing answer! Reading the proposal I got the impression that we could actually generate the TupleViews with different arities using buildPartialBlock but now I understand it better. It achieves a similar result in a very clever and concise way. +1

4 Likes

+1 - while I'm still struggling to learn how to use ResultBuilders, I've hit the core issue this proposal uses as an inciting action in my own trials, and struggled with a solution. I believe this (independent of variadic generics) would be tremendous benefit to solution patterns for designing result builders.

Yes. Although I think @ksluder's question about what Swift wants to provide in terms of meta-programming is still valid. I don't think that question should preclude this update, which i see as valuable, but it's relevant on a broader scale.

You've already stepped into the deep end of swift by the time you're trying out ResultBuilders, so this is in the "advancedx2" section of the incremental complexity disclosure space, but it matches nicely with swift, and would doubly win with variadic generics in place as well. The two combined would make result builders a whole different game to use from where it is today.

I don't have any other experience with languages that resolved meta-programming in the same fashion here, so I don't have anything to usefully add.

In depth reading, following from pitch to discussion, and previously exploring (and attempting to implement) result builders prior to this proposal, but not using any code from the proposed implementation to try out new ways to tackle result builders.

1 Like

In a world where buildPartialBlock and Variadic Generics exist, for those buildBlock methods that are overloaded for the different arities like in ViewBuilder would the better approach be to rely on variadic generics or buildPartialBlock?

3 Likes

For ViewBuilder, a variadic-generic buildBlock could also be a good fit as it would return a TupleView of a flat tuple, which is consistent with buildBlock overloads’ return types today. That said, this proposal was also motivated by highly generic APIs such as regex builder, which, as mentioned in alternatives considered, cannot be implemented with just variadic generics.

4 Likes

+1 to this proposal.

I believe this will eliminate a pain point I have had with complicated layouts when adopted by SwftUI. That it supports the Regex effort makes it even more important as I really want that capability.

I read the proposal thoroughly.

Comment:

Possible alternate name: buildBlockPart(…

2 Likes

I like it! Although I have to say I think the proposal text sells the features a little short :smiley:

It helps with accepting a large number of children in a DSL, and the regex case is also very convincing. I'd probably be a +1 for those reasons alone. But the feature enables so much more:

In a funny way, a feature motivated in part by Regex is in itself capable of deciding regular languages, with the alphabet of the languages being Swift types.

Defining a few uninhabited marker types as our states we can then pass these along the partial block chain, and since result builders are based on source transformation, we can simply, without any protocol shennanigans, define overloads of allowed combinations of state and input type, and presto, we have ourselves a deterministic finite automaton to determine at compile time whether a sequence of expressions is acceptable to our DSL.

Here is a builder that accepts the language (String Int)+ Bool (with space being concatenation, and (...)+ like in regex). If you try to pass other types or a different order, compilation fails.

Show code
enum State1 {}
enum State2 {}
enum State3 {}

@resultBuilder enum Regular {
    struct S<State> { let a: [Any] }
    static func buildPartialBlock(first: String) -> S<State1> { S(a: [first]) }
    static func buildPartialBlock(accumulated s: S<State1>, next: Int) ->    S<State2> { S(a: s.a + [next])  }
    static func buildPartialBlock(accumulated s: S<State2>, next: String) -> S<State1> { S(a: s.a + [next]) }
    static func buildPartialBlock(accumulated s: S<State2>, next: Bool) ->   S<State3> { S(a: s.a + [next]) }
    static func buildFinalResult(_ s: S<State3>) -> [Any] { s.a }
}

@Regular func word() -> [Any] {
    "lol"
    42
    "123"
    3210
    true
}

But we can take it a bit further. By introducing a recursive stack type that we pass along as well, plus a set of marker types to serve as symbols on said stack, we can implement a deterministic pushdown automaton and decide deterministic context free languages.

This is a builder that accepts String^n Int^n, that is a sequence of n strings, followed by an equal number n of ints. Again, compilation fails if that condition is not met.

Show code
enum SymbolA {}
enum SymbolZ {}

enum Stack<Top, Rest> {}

@resultBuilder enum DeterministicContextFree {
    struct S<State, Stack> { let a: [Any] }

    static func buildPartialBlock(first: String)
        -> S<State1, Stack<SymbolA, Stack<SymbolZ, Never>>> { S(a: [first]) }

    static func buildPartialBlock<Rest>(accumulated s: S<State1, Stack<SymbolA, Rest>>, next: String) ->
        S<State1, Stack<SymbolA, Stack<SymbolA, Rest>>> { S(a: s.a + [next]) }

    static func buildPartialBlock<Rest>(accumulated s: S<State1, Stack<SymbolA, Rest>>, next: Int)
        -> S<State2, Rest> { S(a: s.a + [next]) }

    static func buildPartialBlock<Rest>(accumulated s: S<State2, Stack<SymbolA, Rest>>, next: Int)
        -> S<State2, Rest> { S(a: s.a + [next]) }

    static func buildFinalResult(_ s: S<State2, Stack<SymbolZ, Never>>) -> [Any] { s.a }
}

@DeterministicContextFree func sameNumber() -> [Any] {
    "a"
    "x"
    "y"
    ""
    10
    1
    42
    2
}

We can of course introduce additional generic parameters to the S structs to have different types of accumulated data along different edges to preserve static knowledge about value types, or maybe turn states from marker types into structs and pass data that way.

Right now, we can only accept or reject an annotated function/closure. However if we at some point get complex closure return type inference (hm, it doesn't work with -experimental-multi-statement-closures, but imo it should, might be a bug), we could also classify DSL closures by their structure and have buildFinalResult return different types based on that classification. This would be useful in scenarios where optimizations can be applied when the expressions have a certain structure. By constructing a DFA or DPA we could bring knowledge of that structure into the type system and select different overloads of our DSL based on that knowledge with no runtime overhead.

If you couldn't tell by now, huge +1! The functionality I described here may be rather niche compared to the actually important use-cases like SwiftUI or Regex have, but I find it quite amazing how such a small change can enable such advanced capabilities :smiley:

11 Likes

+1.

I'm very surprised at this proposal, in terms of its strong ability.

When I read previous discussion about variadic generics, I found this comment. It cannot be expressed by simple variadic generics, because it's hard to express the exact type of result. While variadic generics struggle to do that, buildPartialBlock can easily do that.

static func buildPartialBlock<A, B>(first: (A) -> B) -> (A) -> B {
    return first   
}
static func buildPartialBlock<A, B, C>(accumulated: (A) -> B, next: (B) -> C) -> (A) -> C {
    return { (x: A) in next(accumulated(x)) }
}
func f(_: A) -> B
func g(_: B) -> C
func h(_: C) -> D
let hgf: (A) -> D = Chain {
    f
    g
    h
}

Considering this, I feel we should put more thought on how result builders interact with variadic generics. I don't have solid vision of them, but they definitely correlate with each other.

4 Likes

+1

Are there any facilities to back port this feature?

1 Like

buildPartialBlock doesn't have any runtime, so it won't require a minimum deployment target.

4 Likes

Huge +1, I'm so happy about this :tada:

2 Likes

For my part, I am neutral on this proposal. My hope is that a future version of Swift brings a powerful macro-like metaprogramming feature that result builders could be reimplemented in.

3 Likes

Overall this proposal is quite nice; the feature is considerably more useful than just a one-shot-feature just for Regex. Even though that perhaps there could be a similar system implemented with a hygienic macro system, I feel this is a good orthogonal feature usable for other things in addition to that.

I have read through the pitch, tried it out, and found that in conjunction with some of the existing result builder facilities we can make some really fantastically cool things with this feature. It fits well with rounding out the result builder system.

The proposal itself is well written and approaches a complex topic relatively thoroughly.

tl;dr a strong +1

3 Likes

I have a question about the implementation.

I made a quick demo program that looks something like this (implementation details at the end of this post)

@PipelineBuilder func testPipelineBuilder() -> Pipeline<Never, Never> {
  Source<Int>()
  Vector<_, Int> {
    Processor()
    Processor()
  }
  Sink()
}

Which should compile to something like:

func testPipelineExplicit() -> Pipeline<Never, Never> {
  let v1 = PipelineBuilder.buildPartialBlock(first: Source<Int>())

  let v2 = PipelineBuilder.buildPartialBlock(accumulated: v1, next: Vector<_, Int>(explicit: {
    let v1 = Vector<Int, Int>.Builder.buildPartialBlock(first: Processor())
    let v2 = Vector<Int, Int>.Builder.buildPartialBlock(accumulated: v1, next: Processor())
    return v2
  }))

  let v3 = PipelineBuilder.buildPartialBlock(accumulated: v2, next: Sink())
  return v3
}

In swift-DEVELOPMENT-SNAPSHOT-2022-03-22-a the explicit function type checks properly, but the result-built version fails to type check and asks for more explicit types. Is this a fundamental limitation of the proposed approach or a bug to be fixed?


Implementation details:

@resultBuilder
enum PipelineBuilder {
  static func buildPartialBlock<First: PipelineHandler>(first: First) -> First {
    return first
  }
  static func buildPartialBlock<Accumulated: PipelineHandler, Next: PipelineHandler>(
    accumulated: Accumulated,
    next: Next
  ) -> Pipeline<Accumulated.Input, Next.Output>
  where Accumulated.Output == Next.Input {
    return Pipeline()
  }
}

protocol PipelineHandler {
  associatedtype Input
  associatedtype Output
}

struct Source<Output>: PipelineHandler {
  typealias Input = Never
}

struct Sink<Input>: PipelineHandler {
  typealias Output = Never
}

struct Pipeline<Input, Output>: PipelineHandler {

}

struct Processor<Element>: PipelineHandler {
  typealias Input = Element
  typealias Output = Element
}

struct Vector<Input, Output>: PipelineHandler {

  @resultBuilder
  struct Builder {
    static func buildPartialBlock<First: PipelineHandler>(
      first: First
    ) -> Pipeline<Input, Output>
    where First.Input == Input, First.Output == Output {
      return Pipeline()
    }
    static func buildPartialBlock<Accumulated: PipelineHandler, Next: PipelineHandler>(
      accumulated: Accumulated,
      next: Next
    ) -> Pipeline<Input, Output>
    where
      Accumulated.Input == Input,
      Accumulated.Output == Output,
      Next.Input == Input,
      Next.Output == Output
    {
      return Pipeline()
    }
  }

  init(@Builder _ body: () -> Pipeline<Input, Output>) {

  }

  init(explicit: () -> Pipeline<Input, Output>) {

  }
}

A close reading of the proposal suggests this is actually converted to:

func testPipelineExplicit() -> Pipeline<Never, Never> {
  let e1 = Source<Int>()

  let e2 = Vector<_, Int>(explicit: {
    let e1 = Processor()
    let e2 = Processor()

    let v1 = Vector<Int, Int>.Builder.buildPartialBlock(first: e1)
    let v2 = Vector<Int, Int>.Builder.buildPartialBlock(accumulated: v1, next: e2)
    return v2
  })

  let e3 = Sink()

  let v1 = PipelineBuilder.buildPartialBlock(first: e1)
  let v2 = PipelineBuilder.buildPartialBlock(accumulated: v1, next: e2)
  let v3 = PipelineBuilder.buildPartialBlock(accumulated: v2, next: e3)
  return v3
}

Since the eN variables are all assigned in separate statements from the buildPartialBlock calls, it's no surprise that each statement is evaluated without any context from the previous ones. If we wanted to support this kind of builder, we'd need something else—perhaps an extension to buildExpression which passed the type of the previous expression as an argument.

2 Likes

Thanks for the explanation. It seems like the result builder API is currently somewhat Frankensteined from the major use cases (SwiftUI and now Regex). I feel that adding a build expression variant to address this use case would only further fragment and complicate things.

Oh well, maybe with Swift 6 we will be able to move to a single, simpler model that can support all of these use cases (and hopefully more that we haven’t even thought of).

1 Like

I am very excited for this addition to the language. It is well motivated, well scoped, and adds significant power to result builders without adding significant difficulty of understanding.

The problem being addressed is very real (as demonstrated by the fact that it was prompted by an actual need which arose while implementing regex builders).

The proposal fits well with result builders as they now are and very conscientiously chooses names for the new APIs based on existing precedent in the standard library (which I agree also happen to be the most clear in this context).

I don't know of other languages with result builders and therefore can't compare.

I followed this proposal back in the pitch stage and felt similarly about it then, and I've reviewed the proposal text and all the discussion thus far.

7 Likes

Is this concept similar to a Haskell Monad? (Where Swift's buildPartialBlock is like Haskell's >>= operator, and the Swift DSL syntax is like Haskell's "do" syntax.) I wonder if there's anything in Haskell's experience with monads that would help this proposal, or steer towards or away from some semantics. (For example, Haskell eventually decided that monads were a special type of Applicative. Should there be a Swift equivalent of Applicative?)

There's an existing limitation in result builder inference due to its use of one-way constraints.

Towards the end of this thread, @Douglas_Gregor mentions that a theoretical buildFold operation could allow for this limitation to be relaxed. I was hoping buildPartialBlock would unlock some of the more fluent APIs described in the thread, but when I tested the snapshot this doesn't appear to be the case. Could such an enhancement be considered now?

6 Likes