Function builder cannot infer generic parameters even though direct call to `buildBlock` can

It is certainly intended that information only flows "forward" in a function builder closure. Function builders are implemented this way (with the "unidirectional constraints" noted in the implementation progress thread) for two reasons. The first is to mimic the type inference behavior of the syntactic rewrite of function builder closures into set of let declarations (one per expression), as @John_McCall noted earlier in this thread. The second is to eliminate exponential type checker behavior that came from considering all of the expressions simultaneously, such that (e.g.) any one expression could radically change the type-checking behavior of any other expression. The first is malleable (we could describe function builders some other way), but the second is not: the type-checking performance benefits we gained from unidirectional constraints were massive, and we cannot give those back; I also don't think we can achieve them without continuing to enforce unidirectional flow through closures.

Fluent builder APIs are effectively unidirectional by construction. When you have something like this:

a.f().g().h()

You have to resolve the type of a before you can meaningfully look up f; then resolve the type of that call to f before you can meaningfully look up g, and so on. There is some limited back-propagation to (e.g.) fill in generic arguments if they weren't known before, but the fact that we cannot perform member lookup until we have a concrete-ish type provides mostly unidirectional type flow that curbs exponential behavior.

Yes, I believe this is be possible. One could imagine adding some kind of buildFold operation to the function builder that the current result and "folds in" a new buildExpression. This example from earlier in the thread:

Sequential { 
  Conv2D<Float>(...)
  AvgPool2D(...)
  Flatten()
  Dense(...)
  Dense(...)
}

Could be translated into, effectively:

Sequential { 
  let a = LayerBuilder.buildFoldInit(Conv2D<Float>(...))
  let b = LayerBuilder.buildFold(a, AvgPool2D(...))
  let c = LayerBuilder.buildFold(b, Flatten())
  let d = LayerBuilder.buildFold(c, Dense(...))
  let e = LayerBuilder.buildFold(d, Dense(...))
  return LayerBuilder.buildBlock(e)
}

Note that this allows you to take the output of the prior expression and feed it into the next expression, but type information is still flowing mostly in one direction. You get to use the type from the prior fold as part of type-checking each expression as input to the fold, but that should still be limited enough to be efficient.

Doug

EDIT: Dropped inferred <Float>s from examples.

1 Like

This isn't quite what I was getting at. Since (IIRC) builder types can be generic, you can write something like this:

protocol Transformation {
  associatedtype Value
  func transform(values: [Value]) -> [Value]
}

@_functionBuilder
struct TransformationBuilder<Value> {
  static func buildExpression<T: Transformation>(t: T) -> AnyTransformation<Value> where T.Value == Value { ... }
  static func buildBlock(transforms: AnyTransformation<Value>) -> AnyTransformation<Value> { ... }
}

func makeTransform<Value>(@TransformationBuilder<Value> builder: () -> AnyTransformation<Value>) -> AnyTransformation<Value> {
  builder()
}

Uses of makeTransform would allow a certain amount of type propagation into the Value type. I don't know how much of a problem we consider that.

Cool! I think this would enable the use case where I was thinking of trying a function builder.

Would this back-propagation continue to be limited to fluent-style API but not in a buildFold function builder since a fluent chain is a single expression and each call to buildFold is a separate statement?

Yes, the fluent chain will allow more back-propagation than the approach I described. Based on the desugaring to statements:

let d = LayerBuilder.buildFold(c, Dense(...))
let e = LayerBuilder.buildFold(d, Dense(...))

The type of d needs to be fully determined (all the way to a concrete type; no remaining type variables) before the type checker will start working on the initializer expression for e. How e type-checks cannot affect how d type checks. Here's an example of the backward propagation in a fluent interface:

struct X<T> {
  func f() -> Y<T> { .init() }
}

struct Y<T> {
  func g() -> T? { nil }
}

let a: Int? = X().f().g()

Here, Int backward-propagates to the generic argument type of X. If I did that "function-builder-style" as we are discussing, it would fail to type check because

let x = X()

cannot determine the generic argument of X.

Doug

Oh, I understand you now. Yes, this does allow some type propagation, and I don't have a good sense of whether this will cause problems in practice in part because we haven't seen it in practice: SwiftUI gives us the biggest function-builder constraint systems, but doesn't use generic function builders at all. We only have smaller-scale tests for generic function builders.

Doug

IIUC, this would back-propagation from the context to the builder, such as is required in your example, while more sophisticated examples of back-propagation would still only work in fluent-style syntax.

Yes.

Doug