How deeply will the compiler infer the types behind parameterized protocols?

Let me explain the situation where my question was raised step by step.
(Side note: Whole code I'm going to show is there on gist)


Base Protocols

Here are two protocols: P and Q.
Q inherits from P and has an additional associatedtype.

protocol P {}

protocol Q: P {
  associatedtype QA
  var qa: QA { get }
}

Concrete Types that conform to P or Q

Let's implement concrete types:

  • ConcreteP conforms to P.
  • ConcreteQ conforms to Q.
struct ConcreteP: P {}

struct ConcreteQ: Q {
  typealias QA = Int
  var qa: QA { 0 }
}

Container Protocols

Here are two other protocols:

  • PContainer has its content which conforms to P.
  • QContainer inherits from PContainer and has its content which conforms to Q.
protocol PContainer<Content> where Content: P {
  associatedtype Content
  var content: Content { get }
}

protocol QContainer<Content>: PContainer where Content: Q {}

Concrete Type Container

  • It basically conforms to PContainer
  • It conditionally conforms to QContainer when Content conforms to Q
struct Container<Content>: PContainer where Content: P {
  var content: Content
  init(_ content: Content) { self.content = content }
}

extension Container: QContainer where Content: Q {}

// Prepare two containers:
let pContainer = Container<ConcreteP>(ConcreteP())
let qContainer = Container<ConcreteQ>(ConcreteQ())

Test Case #1

We can detect whether or not Container conforms to QContainer at runtime.

extension Container {
  func test1() {
    if self is any QContainer<Content> {
      print("I'm also `QContainer`.")
    } else {
      print("I'm just `PContainer`.")
    }
  }
}

pContainer.test1() // Prints "I'm just `PContainer`."
qContainer.test1() // Prints "I'm also `QContainer`."

Test Case #2

Of course, Content of any QContainer<Content> is always any Q.

extension Container {
  func test2() {
    if case let qSelf as any QContainer<Content> = self {
      print("Content is `Q`: \(qSelf.content is any Q)")
    }
  }
}

qContainer.test2() // Prints "Content is `Q`: true"

Test Case #3

In theory, the compiler can statically know qSelf.content is any Q in the following code.
However, this code fails to be compiled (with -DCOMPILER_CAN_ASSERT_CONTENT_IS_Q option).
(That seems to be because Content constraints never change in if case ... { } scope.)

extension Container {
  func test3() {
    if case let qSelf as any QContainer<Content> = self {
      #if COMPILER_CAN_ASSERT_CONTENT_IS_Q
      print("Value of `qa` is: \(qSelf.content.qa)")
      // ⛔️ error: value of type 'Content' has no member 'qa'
      #endif
    }
  }
}

qContainer.test3() // ⛔️

Workaround #1

One of workarounds for Test Case #3 is to remove <Content> from as any QContainer.

  • Pros: Very simple.
  • Cons: The fact that qSelf.content is Content is lost in static analysis.
extension Container {
  func workaround1() {
    if case let qSelf as any QContainer = self {
      print("Value of `qa` is: \(qSelf.content.qa)")
    }
  }
}

qContainer.workaround1() // Prints "Value of `qa` is: 0"

Workaround #2

Another workaround is explicit coercion for qSelf.content.

  • Pros: Compiler can still assert qSelf.content is Content.
  • Cons: Redundunt for humans.
extension Container {
  func workaround2() {
    if case let qSelf as any QContainer<Content> = self {
      print("Value of `qa` is: \((qSelf.content as! any Q).qa)")
    }
  }
}

qContainer.workaround2() // Prints "Value of `qa` is: 0"

Workaround #3

Third workaround is to extend QContainer protocol.

  • Pros: We can open the existential explicitly(?).
  • Cons: It may be a hassle to write in some cases.
extension QContainer {
  func printQA() {
    print("Value of `qa` is: \(self.content.qa)")
  }
}

extension Container {
  func workaround3() {
    if case let qSelf as any QContainer<Content> = self {
      qSelf.printQA()
    }
  }
}

qContainer.workaround3() // Prints "Value of `qa` is: 0"

Question:
Do you think Test Case #3(func test3) should be compiled successfully in the future?

I would say no, because runtime cast cannot reversely influence static type information.

Inside the extensions of Container, the identifier Content only carry one constraint: Content: P. It would be super confusing for me if the compiler allow Content to have the constraint Content: Q inside the if case clause:

extension Container {
  func test3() {
    if case let qSelf as any QContainer<Content> = self {
      // here Content conforms to Q?
    }
    // here Content does not conform to Q?
}

I don't think there's any precedent in the language that the static information of a type identifier can be altered this way.

Just for completeness (I think you already know this), for some QContainer<Content> it's a different story, any valid some QContainer<Content> already implies the constraint Content: Q. The following comparison may show the difference more clearly.

func helper<T>(_ c: any QContainer<T>) {
    _ = c.content.qa   // ❌, no qa member
}

func helper<T>(_ c: some QContainer<T>) {
    _ = c.content.qa  // ✅
}
1 Like

I agree that changing Content constraints in if-clause would be confusing.
So I'm noticing that this can be reduced to an issue about intersection types.

Current Swift allows intersection types only for protocol-constrained types/protocol composition:

protocol P1 {}
protocol P2 {}
class C1 {}

let _: any P1 & P2 & C1 // ✅ Allowed

func f<T>(_: T) {
  let _: T & any P1 // ⛔️ Not Allowed
  // error: non-protocol, non-class type 'T' cannot be used within a protocol-constrained type
}

If we could allow T & any P1 (at least internally), Test Case #3 might be compiled:

extension Container {
  func testX() {
    if case let qSelf as any QContainer<Content> = self {
      // Then, `qSelf.content` could be inferred as `Content & any Q`.
      print("Value of `qa` is: \(qSelf.content.qa)") // Would come along.
    }
  }
}
1 Like