ResultBuilder asynchronous control flow

An idea I've had for improving ResultBuilders. I was curious if there are folks who have had the same pain point with ResultBuilders and if this would solve problems they might have had.

Motivation

It can be difficult to implement systems that operate on the output of ResultBuilders, because those systems need to re-run the body of the ResultBuilder whenever the inputs to the control flow change. This is because builderEither() only gives the branch that is executed on the run of the ResultBuilder, instead of yielding both. Thus, implementors of such systems need to implement either diffing algorithms (potentially inefficient) or combine fixed return types with reflection on the return value of the body/associated type of a View in SwiftUI's case (potentially inefficient, beyond the means of most non-Apple engineers, requires lots of boilerplate in the builder itself to simulate variadic generics).

Summary

This change would allow users to implement ResultBuilders that allow the following:


var fixedWidth: SomePublisher<Float>

@ConstraintBuilder 
func makeConstraints() -> some Constraints {
     EqualToSuperview {
           Leading()
           Trailing()
     }
     if let fixedWidth {
         Width(equalTo: fixedWidth)
     } else {
         WrapContent()
     }
}

var isFixedWidth: SomePublisher<Bool>

@ConstraintBuilder 
func makeConstraints() -> some Constraints {
          EqualToSuperview {
           Leading()
           Trailing()
     }
     if isFixedWidth {
         Width(equalTo: 100)
     } else {
         WrapContent()
     }
}

To support this, the following functions would be added to the ResultBuilder templating system:

// if-else
buildEither(_ stream: Stream, first: () -> First, second: () -> Second) -> MyResultType
// if-let
buildEither(_ stream: Stream, first: (Element) -> First, second: () -> Second) -> MyResultType

// when variadic generics are available, we could support multiple if-lets and switches, or they could be supported with the current approach used by SwiftUI

In summary, these ResultBuilder functions receive all branches of the control flow, not just the one that would be executed if the if statement had been evaluated. The if-statement/if-let binding doesn't operate on a boolean anymore, it instead operates on an arbitrary type, which in typical usages would be something resembling a Combine Publisher, AsyncSequence, or RxSwift Observable.

An implementation of the if-else function might look like the following, for our prior example:

buildEither<First, Second>(_ stream: SomePublisher<Bool>, first: () -> First, second: () -> Second) -> ConditionalConstraint<First, Second> {
    ConditionalConstraint(stream, first(), next())
}

struct ConditionConstraint<First, Second> {

      let first: First
      let second: Second
      let stream: Stream

      func apply(in parent: UIView, manager: ConstraintManager) {
            manager.addSubscription(stream.recieve {
                    if $0 {
                         second.deactivate()
                         first.activate()
                    } else {
                         first.deactivate()
                         second.activate()
                    }
            })
      }
}

This design has one major advantage over the current ResultBuilder implementation: if the only mutable inputs to your system are streams, then you never need to re-run the body of your ResultBuilders, because everything about how they change is known at compile time.

Potential usability issues

The major disadvantage is that if you want inputs to functions you call inside the ResultBuilder to change, these are completely beyond the scope of the system to detect: for example:

var fixedWidth: SomePublisher<Float>

@ConstraintBuilder 
func makeConstraints() -> some Constraints {
     Width(equalTo: fixedWidth)
}

This wouldn't work, unless Width() had an overload that took some Publisher<Float> and subscribed to it internally. For a library like SwiftUI, I think this would be very painful to work with.

Alternative syntax

One other objection to this is that the overloading of control flow in this manner is confusing. For example, one potential abuse of this system would be to consider Bool? a "stream" to avoid writing out ?? false in a result builder:

buildEither(_ stream: Bool?, first: () -> First, second: () -> Second) -> MyResultType {
        if stream ?? false {
              return first()
        } else {
              return second()
        }
}

A potential solution would be change the design of this system to require that the Stream was an AsyncSequence and then the syntax could be

if await x {
    ...
}
if await let x {
   ...
}

Thanks for reading. I personally am not sure if this design is a good idea, and I'm hoping that feedback from folks here will give the answer to that.

I think there’s merit in the idea of having more information about control flow statements after the builder transformation. This is also true for for-in which cannot be lazily and is thus unusable for many APIs. I think a better name for the proposed feature would be lazy control flow, if I understand the pitch correctly. So maybe the new methods could become buildLazyEither, and if more control-flow statements are added, buildLazyArray, etc.

Regarding your example, I’m not sure I understand how the API author avoids diffing. It would help if you showed an example without the pitched feature where diffing is unavoidable. Also, I don’t really understand where reflection comes in, although I agree it’s quite slow in most cases. Perhaps if the motivation was clearer, I could better understand the need for having Stream and not Bool in the buildEither call, as it seems quite odd.

would be lazy control flow, if I understand the pitch correctly

Yeah that's more accurate to what it does in practice, whereas the "streaming" name I originally picked is more geared toward the intended/expected use.

Regarding your example, I’m not sure I understand how the API author avoids diffing

I'll think about this. But to explain: if you don't diff, you need to recreate all the constraints (and deactivate the old ones) every time this function is run. I don't believe that UIKit's constraint solver does any diffing of the old and new ConstraintSets under the hood, so this is likely extremely inefficient.

I could better understand the need for having Stream and not Bool in the buildEither call, as it seems quite odd.

This is a requirement of implementing this in a streaming library agnostic way. To clarify, the goal is that the DSL code is run just once. So the goal is to substitute something bool-like (in most use cases, something that streams bools) instead of using an actual boolean. Stream isn't a real type, I was copying the way that ResultBuilder code is presented in the original SE, where, if memory serves, they use Component to stand in for the actual type you'd write out in an implementation such as SwiftUI View.