Recent improvements to associated type inference

Associated type inference is one of those features that is easy to describe but becomes maddeningly complex once you get into the details. The basic idea is this. We have a protocol with an associated type:

protocol P1 {
  associatedtype A
  func f(_: A)
}

Now if we declare a conforming type, we ought to be able to omit the typealias A = Int below, because the implementation of f() already completely determines A:

struct S1: P1 {  // ok!
  func f(_: Int) {}
}

In some cases, it is certainly better to state type aliases explicitly, even if associated type inference could infer them. On the other hand, many standard library protocols, like collections and numerics, rely on associated type inference for "progressive disclosure". For example, a basic Collection conformance doesn't need to state any type aliases at all; Element and Index can be inferred from the subscript, and Iterator, SubSequence and Indices all have reasonable defaults.

I wanted to highlight some fixes in this area. I've kept the below examples "abstract" so they may look silly to the uninitiated. But if you've hit any of these problems before (and many of you certainly did), you'll immediately see ;-)

Inferring associated types from same-type requirements in protocols

Suppose we add a default implementation of P1.f():

extension P1 {
  func f(_: A) {}
}

Now we might declare a protocol that inherits from P1 and completely fixes A:

protocol P2: P1 where A == Int {}

A conforming type now doesn't need to declare either f() or A at all:

struct S2: P2 {}  // ok!

The above worked in Swift 5.9. The new feature is is that this also applies to abstract same-type requirements. Here is a new protocol:

protocol P3 {
  associatedtype A
  associatedtype B

  func f(_: A)
  func g(_: B)
}

extension P3 {
  func f(_: A) {}
}

protocol P4: P3 where A == B {}

struct S4: P4 {  // error on 5.9; ok on main
  func g(_: Int) {}
}

In the conformance of S4 to P4 above, we must have A == B == Int. The compiler is now smart enough to realize this.

Both the original feature and the enhanced inference were contributed by @anthonylatsis. The latter was behind an experimental flag because it exposed some existing compiler bugs at the time. It is now enabled by default. Thanks Anthony!

Avoiding cycles when inferred associated types referenced from concrete context

Now look at this protocol:

protocol P5 {
  associatedtype A

  func f1(_: A)
  func f2(_: A)
  func f3(_: A)
}

We can declare a conforming type:

struct S5: P5 {  // ok
  func f1(_: Int) {}
  func f2(_: Int) {}
  func f3(_: A) {}
}

Notice how the A in the parameter type of f3 refers to the inferred associated type Int. The above worked in 5.9. Now let's add an innocent little bit of code that gets type checked before the struct declaration:

let _ = S5().f1(3)

struct S5: P5 {
  func f1(_: Int) {}
  func f2(_: Int) {}
  func f3(_: A) {}  // error diagnosed here on 5.9; ok on main
}

We used to get a reference to invalid associated type above, whereas moving the let statement so that it appears after the struct declaration would fix it. Now both declaration orders type check properly.

This limitation was the root cause of various bogus diagnostics and compiler crashes when moving a conformance to another file, or building with WMO vs non-WMO, etc.

Superclass-constrained associated types

Since the dawn of time, you've been able to constrain a generic parameter to a superclass that was itself generic, for example

class MyClass<T> {}

func f<A: MyClass<B>, B: Equatable>(_: A, _: B) {}

The same thing in a protocol superficially appeared to work:

protocol P6 {
  associatedtype A
  associatedtype B: MyClass<A>
  func f(_: B)
}

However, attempting to declare a conforming type would previously hit trouble, and in practice only a non-generic superclass would work in every case. This is now fixed, so we can write this:

struct S6<A>: P6 {  // error on 5.9; ok on main
  func f(_: MyClass<A>) {}
}
55 Likes

I love this - not just these specific fixes, but this exposition on them. I'd love to see more posts like this in future.

19 Likes

@Slava_Pestov Thanks for this. All the cases reported in the post, Associatedtype not inferred?, are addressed, except when the associatedtype is used as the return value of a method.

For the following example code, Xcode 15.3RC is still giving me the two error messages. Changing T in S to Int makes the error messages go away.

protocol P {
  associatedtype T
  func foo(x: T) -> T
}

struct S: P { // ERROR: Type 'S' does not conform to protocol 'P'
  func foo(x: Int) -> T { return x } // ERROR: Reference to invalid associated type 'T' of type 'S'
}
1 Like

Yeah, that's still not supported. The occurrence of T in the type of the witness removes it from consideration entirely. We don't try to separately resolve the non-circular parts.

Three cheers for Slava! Nice fixes.

1 Like

Thank you for blowing the dust off these past efforts, Slava. I never expected a smooth transition to the new implementation — there had to be latent bugs in old code and some unintended behavioral quirks we have come to rely on in Swift code. But at some point I began to realize it would only fix a small class of problems, and this disheartened me on one hand. I was not feeling confident enough to go for the general problem either. So I ultimately left this on the shelf in a semi-complete state and have been getting distracted by the endless amount and variety of other fun work available on the project ever since. That said, conformances are of major interest to me, and I am just as firm about making them my primary focus in the future.

2 Likes

in Swift 6.0, the following compiles fine:

protocol P<A>
{
    associatedtype A
}

protocol Q:P where A == Void
{
}

struct S
{
}
extension S:Q
{
}

however, this doesn’t, even though it compiled fine on 5.10:

protocol P<A>
{
    associatedtype A
}

protocol Q:P where A == Void
{
}

struct S
{
}
extension S:Q
{
}
+ extension S:P
+ {
+ }
error: type 'S' does not conform to protocol 'P'
 1 | protocol P<A>
 2 | {
 3 |     associatedtype A
   |                    `- note: protocol requires nested type 'A'; add nested type 'A' for conformance
 4 | }
 5 | 
   :
14 | {
15 | }
16 | extension S:P
   | `- error: type 'S' does not conform to protocol 'P'
17 | {
18 | }

is this expected? (or should i file a compiler bug)

Did you mean to post two identical snippets, both with redeclarations of S, but the second snippet with diff marks?

1 Like

oops, good catch. the first snippet was supposed to be relying on the inheritance of P from Q.

The second snippet doesn't compile in any version of Swift I can try—all call out the second redeclaration of S as invalid. Did you mean extension perhaps?

Edit: Yes, I can see your point now when I make it an extension. So the reproducer is:

protocol P<A>
{
    associatedtype A
}

protocol Q:P where A == Void
{
}

struct S:Q
{
}

extension S:P
{
}
1 Like

yes, thanks, this just must be one of those days for me :face_with_spiral_eyes:

1 Like

Thank you. Reported in #75504.

since it doesn’t look like this shipped in Swift 6.0, is there a workaround for this problem?

i tried adding a typealias in an extension, but this produces an unsuppressable compiler warning:

extension QueryProtocol
{
    public
    typealias Collation = VolumeCollation
//  warning: typealias overriding associated type 'Collation' from protocol 'QueryBase' is better expressed as same-type constraint on the protocol
}

Yep, looks like there's a simple workaround for this one. Here is the test case from 6.0 conformance regression with abstract type witness · Issue #75504 · swiftlang/swift · GitHub :

protocol P {
  associatedtype A
}

protocol Q: P where A == Void {}

struct S {}
extension S: Q {}
extension S: P {}

It works if you re-declare A inside Q, eg:

protocol Q: P where A == Void {
  associatedtype A
}

This does not emit any warnings.

In the formal model a "re-stated" associated type has no effect, but due to limitations in associated type inference, it's sometimes necessary.

2 Likes

i couldn’t get your solution to work in the actual code base, and reduced the error to this snippet:

public
protocol P
{
    associatedtype A
}

public
protocol Q:P where A == Void
{
    associatedtype A
}

struct S {}
extension S:P {}
extension S:Q {}
test.swift:14:1: error: type 'S' does not conform to protocol 'P'
 2 | protocol P
 3 | {
 4 |     associatedtype A
   |                    `- note: protocol requires nested type 'A'; add nested type 'A' for conformance
 5 | }
 6 | 
   :
12 | 
13 | struct S {}
14 | extension S:P {}
   | `- error: type 'S' does not conform to protocol 'P'
15 | extension S:Q {}
16 | 

test.swift:15:1: error: type 'S' does not conform to protocol 'Q'
 8 | protocol Q:P where A == Void
 9 | {
10 |     associatedtype A
   |                    `- note: protocol requires nested type 'A'; add nested type 'A' for conformance
11 | }
12 | 
13 | struct S {}
14 | extension S:P {}
15 | extension S:Q {}
   | `- error: type 'S' does not conform to protocol 'Q'
16 | 

i compared my snippet to yours, and incredibly, it depends on declaration order within the source file…

- extension S:P {}
extension S:Q {}
+ extension S:P {}

if Q comes before P, i don’t get a compiler error. not sure what happens when the extensions live in separate files.

:exploding_head::exploding_head::exploding_head:

(unfortunately for the library, this means the library is still broken, as the client may have arranged their extension blocks in the “wrong” order)

Yeah, unfortunately if the extension for the less specific conformance is checked first, there is nothing that makes A == Void. (Associated type inference is not “purely functional”, it adds new typealiases which are visible to subsequent lookups. So the second extension immediately finds the new type alias and doesn’t try to infer A again. Fun!)

Is the conformance S: P conditional in the real program? If not, you can just remove that extension because it’s redundant.

1 Like

i haven’t looked at all the clients yet but i suspect not, we don’t use a lot of conditional conformances with this pattern so it’s probably just being used to organize witnesses.

luckily for us there are no third party consumers of this framework we need to support so we can update all the client packages to get them building again. not sure how we would handle this situation if it cropped up in an open source library though.

Thank you, @Slava_Pestov

Would you be able to demonstrate this with a tiny example, by using your favourite notation?

Here is a small example:

echo 'protocol P { associatedtype A = Int }; struct S: P {}' | swiftc -dump-ast -

This command dumps the type-checked AST (you can also pretty-print it as Swift source with -print-ast instead). Associated type inference deduces A := Int and adds an implicit type alias to S to serve as the implementation of the associated type.

2 Likes

Thank you, for helping me understand this.

echo 'protocol P { associatedtype A = Int }; struct S: P {}' | swiftc -print-ast -

Outputs

internal protocol P {
  associatedtype A = Int
}

internal struct S : P {
  internal typealias A = Int
  internal init() {

  }
}