swiftlang / swift

The Swift Programming Language
https://swift.org
Apache License 2.0
67.66k stars 10.38k forks source link

Type inference fails chaining call on the return value of @resultBuilder #67363

Open KingOfBrian opened 1 year ago

KingOfBrian commented 1 year ago

Description We have a SwiftUI-ish declarative API built on top of UIKit from pre SwiftUI days, and have evolved it to use resultBuilder APIs. We have a result builder that type checks fine, but a few of our API's that are chained in one expression on the return value of the resultBuilder fails.

From reading about the changes I don’t understand why it breaks. We have a Stack builder API that returns a Stack1 -> StackN types, with N different buildBlock functions in the result builder. The expressions are valid to type check independently, and there’s no side propagation like mentioned here. We do use the builder to determine a generic result type, and that seems to work, and be inline with the OneWay constraint explanation. I'm not sure I understand the nuance of "back-propagation" as explained above tho.

Steps to reproduce Code below, problematic call sites up top, stripped down types below.

// This is HopperUI code, not SwiftUI code, an in-house declarative UI built pre SwiftUI.
// These examples are useless UI wise, but minimize the compiler issue
enum KeyPathNotInferred {
    func previousCallSite() -> some ViewRepresentable {
        Stack.vertical() {
            StyledString(content: "Hi")
        }
        .aligning(anchors: [\.bottomAnchor])

        // ERROR: Cannot convert value of type '[Any]' to expected argument type '[(StackViewOne<StackViewOne<UILabel>>) -> Anchor]'
        // ERROR[Stack.vertical line]: Generic parameter 'Anchor' could not be inferred
    }
    func closureIsOk() -> some ViewRepresentable {
        Stack.vertical() {
            StyledString(content: "Hi")
        }
        .aligning(anchors: [{ $0.bottomAnchor }])
    }
    func breakUpIsOk() -> some ViewRepresentable {
        let stack = Stack.vertical() {
            StyledString(content: "Hi")
        }
        return stack.aligning(anchors: [\.bottomAnchor])
    }
}

enum ChainedResultBuilderIgnored {
    func previousCallSite() -> some ViewRepresentable {
        Stack.vertical() {
            StyledString(content: "Hi")
        }
        .withFooter {
            StyledString(content: "Hi")
        }
        // ERROR: Cannot convert value of type 'StyledString' to closure result type '[ListItem]'
    }

    func breakUpIsOk() -> some ViewRepresentable {
        let stack = Stack.vertical() {
            StyledString(content: "Hi")
        }
        stack.withFooter {
            StyledString(content: "Hi")
        }
        return stack
    }
}

import UIKit

// Basic protocol for view declaration.
// Views are constructed via the constructed generic View hierarchy, and then updated as needed.
protocol ViewRepresentable {
    associatedtype View: UIView
    func configure(view: View)
}
struct StyledString: ViewRepresentable {
    let content: String
    func configure(view: UILabel) {}
}

// Basic Stack structure
class StackViewOne<First: UIView>: UIStackView {
    var first = First()
}
struct Stack {
    struct One<First: ViewRepresentable>: ViewRepresentable {
        let first: First
        func configure(view: StackViewOne<First.View>) {
            first.configure(view: view.first)
        }
    }

    @resultBuilder
    enum Builder {
        static func buildBlock<First: ViewRepresentable>(_ first: First) -> Stack.One<First> {
            Stack.One(first: first)
        }
    }
    static func vertical<StackType: ViewRepresentable>(@Builder build builder: () -> StackType) -> StackType {
        builder()
    }
}

protocol AnchorType {}
extension NSLayoutYAxisAnchor: AnchorType {}
struct AnchorAlignment<Content: ViewRepresentable, Anchor>: ViewRepresentable {
    let content: Content
    let anchors: [(View) -> Anchor]

    func configure(view: Content.View) {}
}

extension ViewRepresentable {
    func aligning<Anchor: AnchorType>(anchors: [(View) -> Anchor]) -> AnchorAlignment<Self, Anchor> {
        .init(content: self, anchors: anchors)
    }
}

// A shell version of an erased item that provides identity + a body for driving UITV
struct ListItem {
    let body: any ViewRepresentable
}

// A List Builder to transform ViewRepresentables into ListItem.
// Real version automates a good chunk of identity
@resultBuilder
enum ListBuilder {
    static func buildExpression<View: ViewRepresentable>(_ expression: View?) -> [ListItem?] {
        [expression.map { .init(body: $0) }]
    }

    public static func buildBlock(_ components: [ListItem?]...) -> [ListItem] {
        components.flatMap { $0.compactMap { $0 } }
    }
}

// A component that joins a screen body with a footer representation.
struct WithFooter<T: ViewRepresentable>: ViewRepresentable {
    let body: T
    let footer: () -> [ListItem]

    func configure(view: T.View) {}
}

extension ViewRepresentable {
    func withFooter(@ListBuilder build: @escaping () -> [ListItem]) -> WithFooter<Self> {
        return .init(body: self, footer: build)
    }
}

Expected behavior I would expect the return value from the resultBuilder to be fully type checked and able to chain additional calls.

Environment

hborla commented 1 year ago

cc @xedin

xedin commented 1 year ago

This is happening because the body of vertical is not solved separately when there is no more information for solver to infer which means that it tries to solve aligning(anchors: [\.bottomAnchor]) first and attempts [Any] as a contextual type for the argument.

This is not specific to result builders, all multi-statement closures behave that way:

protocol UI {
  var bottomAnchor: Int { get set }
}

protocol Alignable {
  associatedtype View : UI
}

struct Stack {
  static func vertical<T: Alignable>(_ fn: () -> T) -> T {
     fn()
  }
}

struct AnchorAlignment<Content: Alignable, Anchor>: Alignable {
  typealias View = Content.View
}

extension Alignable {
  func aligning<Anchor>(anchors: [(View) -> Anchor]) -> AnchorAlignment<Self, Anchor> {
    fatalError()
  }
}

struct Label : UI {
  var bottomAnchor: Int = 42
}

struct StyledString: Alignable {
  typealias View = Label
}

enum Test {
  func inferenceFailure() -> some Alignable {
    Stack.vertical() {
      let result = StyledString()
      return result
    }
    .aligning(anchors: [\.bottomAnchor])
  }
}

It might be possible to solve this by prioritizing closure inference over a defaulted binding, I'll give it a shot.

KingOfBrian commented 1 year ago

Ah, interesting. What change do you think caused this? I sort of defaulted to the ResultBuilder changes since there was a notice of possible breaking source changes.

Do you think this explanation applies to both problems? It makes some sense that closures work instead of key paths, since the closure probably defers or alters the resolution a little.

But for WithFooter seems a little different. The fact that it finds the extension, but doesn't apply the list builder is bizarre. I added another multi-line / non-result builder and it works ok:

extension ViewRepresentable {
    func withFooterNoBuilder(build: @escaping () -> [ListItem]) -> WithFooter<Self> {
        return .init(body: self, footer: build)
    }
}

extension ChainedResultBuilderIgnored {
    func noBuilderOk() -> some ViewRepresentable {
        Stack.vertical() {
            StyledString(content: "Hi")
        }
        .withFooterNoBuilder {
            let useMultiLineClosure = ListItem(body: StyledString(content: "Hi"))
            return [useMultiLineClosure]
        }
    }
}
xedin commented 1 year ago

Result builders are type-checked as regular multi-statement closures with some implicitly added expressions inside, switching to that is what inference mode is what this regression, so a fix would work for both.

xedin commented 1 year ago

The fact that it finds the extension, but doesn't apply the list builder is bizarre.

Yes, this is indeed interesting, I'm trying to figure out why wouldn't it apply the builder in this case.

xedin commented 1 year ago

Okay this is the same kind of problem as other examples, withFooter closure is resolved too early (before the result type of StackType is resolved which means that it cannot pick up @ListBuilder from the overload.

xedin commented 1 year ago

I've reverted the fix because it caused other issues so this has to be re-opened.