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

In the use case I have, I ended up abandoning function builders and using a chained builder-style API instead. This approach inferred well in Swift 5.1 although I’m having some trouble with it in Swift 5.2 (still trying to get to sort this out).

If inference is able to work with the chained syntax then omitting it from function builders doesn’t prevent a library design from expressing something equivalent. It just removes function builder syntax as an option for the library. IMO, this is an unfortunate tradeoff to run into.

3 Likes

What Holly says is exactly right. Arbitrary type information not back-propagating and side-propagating between statements is an intentional aspect of the function builder design. One of the reasons we didn't put function builders through evolution last fall was specifically that the implementation could have propagated types that way, which we didn't consider acceptable or sustainable for the feature to commit to.

Part of our thinking here is that we've been conscious of the possibility of type-checking all closures in their surrounding context the same way that function-builder closures are. That would eliminate the problem where multi-statement closures passed to generic functions often need to declare their signature. But to consider that, we need to (1) make sure that the type-checking model isn't vastly different from what it's been before and (2) stave off as many sources of exponential complexity as we can. That means restricting how types propagate between statements to be as consistent as possible with a statement-by-statement model.

4 Likes

Good idea! A fluent builder-style API seems like a viable alternative that should support type inference in the original example.

I agree with this sentiment. I'd like to use function builders with the desired type inference, but it sounds like function builders are intentionally designed to not support it (for well-considered reasons).

I see - I've heard of this direction to generalize one-way constraints to all closures, thanks for all the clarification.

Regarding multi-statement closures, the tradeoff seems to be:

  1. Explicit closure type annotation, but maximum type inference within the body. This is the status quo.
  2. Explicit type annotation for variable bindings within the body, but inferred closure type. This would be enabled by one-way constraints.

It's hard for me personally to judge whether (1) or (2) is better.

(1) fits the principle of "no inference for function signatures (where annotation matters, because we want users to be explicit to prevent unexpected behavior) but maximum inference inside the body (where annotation matters much less)".

But (2) seems to enable type inference to work for some code that is currently diagnosed with the following, which I run into somewhat frequently:

unable to infer closure return type; add explicit type to disambiguate

Understood. What isn't clear to me though is why this is necessary in function builders if an analogous fluent builder API doesn't run into the same issues. Is there a non-obvious difference in type checking consequences between the two approaches to syntax? Or is the intent to discourage libraries that would rely on this kind of inference altogether?

1 Like

I think these are good nuanced questions.

Also: I think the "fluent builder API" is just explicit calls to the function builder's buildBlock. For my use case, the function builder is syntactic sugar for a fluent builder API.

1 Like

You wouldn't have to add explicit type annotations within the body; that would clearly not be consistent with current type-checker behavior.

A one-way constraint is like the status quo for type-checking a variable binding:

let x = 15
return x + UInt(27)

This doesn't type-check because type information only flows "one way" through the variable binding: we decide that x: Int without considering that x is required to be convertible to UInt on the next line.

Another way of understanding the use of one-way constraints in function builders is that they're as if the closure was transformed to assign all the statement results into locals. So given this closure:

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

Instead of thinking of the transform like this:

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

Think of it more like this:

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

(moving bugs.swift.org discussion here, upon request)

Thanks for the technical explanations John! The "lack of type inference" is expected behavior given how function builder syntax is expanded into variable bindings.

I'm still curious about answers to @anandabits's use-case-motivated questions from above:

Should libraries give up on ever using function builders for this style of type inference? Are libraries encouraged to use explicit generic parameters or a different pattern (e.g. fluent method-chaining builders) instead?

// Will this `<Float>` generic parameter type inference never be possible?
let model = Sequential {
    Conv2D<Float>(...) // first layer, feeding output to:
    AvgPool2D(...) // AvgPool2D<Float>, feeding output to...
    Flatten() // Flatten<Float>
    Dense(...) // Dense<Float>
    Dense(...) // Dense<Float>
}

You might be able to get this kind of propagation if the builder type itself is parameterized and you use buildExpression, which intentionally does fire “before the binding”. I believe we’d consider that intended behavior.

That doesn’t introduce a way to flow type information from one builder expression to the next, does it? That is the capability that is present in fluent-style syntax.

My original question was whether the fluent-style API design somehow reduces work for the type checker in a way that function builders would not, or whether the same issues with inference and type checking performance would exist with fluent-style syntax. I’m still curious to know the answer, especially if there are potential pitfalls we should be aware of when designing fluent-style API which do flow type information forward.

It would allow type information to flow between the builder type and the element expressions, which effectively means between all element expressions simultaneously. It would not be sufficient for writing e.g. a functional/monadic chain where the output type of one element interacts with the input type of the next.

1 Like

By allowing that kind of typing interaction, however, it might significantly impact the performance of type-checking such a builder. I’d want @Douglas_Gregor’s opinion about whether we consider it intended.

Gotcha, I was wondering about that.

Ahh, gotcha. I'm not thinking of a monad specifically here but the inference behavior is similar - i.e. connecting input to output (specifically, the use case where I ran into this is a profunctor).

Would it be more feasible to implement a new ad-hoc build function that is able to connect two adjacent expressions in this way? It would allow some interesting use cases to move from fluent-style API to builder-style API. The specific example I have is a "transform" type, i.e. an operator chain modeled as a value independently of the values the chain may be applied to.

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