What kind of magic behavior is this?

I was playing around with some API design where I accidentally discovered this magical behavior. Why is the compiler allowed to use the protocol extension with a where clause as default implementation for the missing protocol requirements?

protocol Test {
  associatedtype Something
  var something: Something { get set }
}

extension Test where Something == Int {
  var something: Something {
    get { return 42 }
    set { }
  }
}

struct S: Test {}

print(S().something) // prints 42
  • Is this a bug, known issue, intended behavior?
  • Should I file it anyway?

I would expect this to trigger an error as S does not provide any information about Something. I also would think that if we at least had typealias Something = Int then the protocol would be satisfied, but this feels like magic that ignores that the extension is conditional.

2 Likes

This is associated type inference. Since you haven't implemented something, the compiler looks for an unambiguous default implementation and finds one that happens to resolve the associated type as well. Making something ambiguous reveals the intent:

protocol Test {
  associatedtype Something
  var something: Something { get }
}

extension Test where Something == Int {
  var something: Something {
    return 42
  }
}
extension Test where Something == Bool {
  var something: Something {
    return true
  }
}

struct S: Test {} 
// error: type 'S' does not conform to protocol 'Test'
// note: ambiguous inference of associated type 'Something': 'Int' vs. 'Bool'

Well I know that it will be ambiguous for the compiler if you had two extension with different types for Something, still I think this should not be allowed as the where clause and the fact that the extension is conditional is completely ignored. I want to provide an extension to conforming types that decide to pick that particular type for its associated type which I'm telling the compiler to do so with the where clause, not that every conforming type should have a default implementation to that type, otherwise I could have written associatedytype Something = Int.

As I said in the original post, I would expect the compiler to emit an error and explicitly tell me that I still have to implement something or provide a type alias for Something because all default implementation that it can find are 'conditional' which it shouldn't pick automatically.

2 Likes

Your type didn't decide on what it wants Something to be - so the compiler did it instead, since it is able to, unambiguously in this case.

Making things worse, this extension overrides the default associated type completely.

protocol Test {
  associatedtype Something = String
  var something: Something { get }
}

extension Test where Something == Int {
  var something: Something {
   return 42
  }
}

struct S: Test {}

print(S().something) // again an Int and not String
2 Likes

The question still remains, why is it allowed to? It's a 'conditional' extension which should not act as default implementation 'until my type explicitly is routed to it'.

My gut feeling tells me that associated type inference does not know what a conditional extension is and simply throws everything into one bucket and picks one on the resolution path so it's happy, but this is not predictable behavior nor logical from my point of view.

2 Likes

For the same reason the compiler tries to resolve witnesses when you don't provide them. A conditional extension itself doesn't require conformers to implement the associated type it constrains, so there is nothing wrong with resolving to an unambiguous default implementation and picking types that satisfy it. Iff you don't provide them yourself, that is.

I agree with Adrian, a conditional extension should apply only if the compiler has independently determined that its conditions have been met.

To a programmer reading the code, it is obvious that S in the example has not specified its associated type for the Test protocol, and it has not implemented any requirement from which that type may be inferred. Therefore the reader concludes that the conformance of S to Test is incomplete, as the associated type is undetermined.

In the second example, with a default associated type, it is equally (or perhaps more!) obvious that S is using the default associated type, because it certainly has not specified one of its own.

• • •

The fact that the compiler can pick an associated type, is entirely irrelevant. A programming language is a language, and it is a language through which a programmer communicates with a compiler. The purpose and goal is for the compiler to understand what the programmer intends.

If the compiler understands something other than what a reasonable programmer would infer from a certain piece of code, then the language is wrong. In that particular aspect, the language is failing in its goal of enabling communication from the programmer to the compiler.

…well either that, or the compiler simply has a bug that needs to be fixed :-)

7 Likes

But it should require from the conforming type to be routed to it in an explicit manner as the extension is 'conditional'. I just don't buy this behavior at all. In this particular case I expect the compiler to fail at the inference resolution and notify me that 'I the dumb developer' forget to provide some protocol requirements to successfully satisfy the conformance to the protocol, just because the default implementation should not automatically be picked by the compiler from a 'conditional' bucket of extensions until 'explicitly' routed to them.

The example above is small but I smell a whole stack of ambiguity issues created by this behavior just because the compiler failed to notify the user to properly implement the conformance to the protocol and shuffled some default implementation by itself that in other corners of the program can cause more harm than good.

Filed a bug report: [SR-10158] Associated type inference resolution ignores conditionality of extensions · Issue #52560 · apple/swift · GitHub

1 Like

A conditional extension on a protocol means that if a conformer satisfies said constraints and doesn't provide an implementation to said functionality, calls will be dispatched to the functionality in the extension. It doesn't dictate how associated types are to be resolved - either through explicit witnesses or type inference. Whether associated type inference is being too smart here is apparently arguable, but the extension is not to be blamed. This is just inference, smart enough to sometimes not be trivial to reason about. Though I wouldn't hurry to deny this kind of inference could happen to be very useful in more complex cases.

Well I don't blame the extension here at all. ;) I blame the inference resolution as I don't see why it should use 'every' protocol extension instead of a sub-set of extensions that intersect with the provided type information on the implementing type. Since we haven't provided anything, it should definitely not take extensions into account that require at least one other constraint that must be met.

Maybe someone from the stdlib team knows of this behavior or can tell if even the stdlib is making use of it, but my fingers are crossed that it's not as this is 'too magical'.

cc @Ben_Cohen

2 Likes

In your case, you provided no type information, which means «anything». That definitely intersects with Something == Int.

I see your way of thinking but to me, this whole realm should have two kinds of extensions. The inference should see them all, that is correct, but during the resolution it should not consider the 'conditional' kind of extensions until the constraints are explicitly met, which means that in the above case resolving Something to Int should be impossible until you write typealias Something = Int in case where you want the default implementation for something, or var something: Int { ... } where you want to access other members of that extension (if there were any).

A real-world example where it’s important associated type inference works this way: Test is Collection, the Something associated type is Indices, and the condition is where Indices: Stridable, Indices == Range<Index>

1 Like

Could you please provide more information about this example?

Which protocol requirements have default implementations in the conditional extension you describe?

What does a minimal conforming Collection type look like, which utilizes this behavior with that extension?

I'm not sure I follow that example, but looking at Indices on Collection I see that it's defaulted to DefaultIndices<Self> and that type has a similar extension extension DefaultIndices where Self.Element == String. By the logic of this magical behavior I can imply that if Self.Element was not provided by the implementor somehow, it can potentially infer Element being String. :exploding_head:

Even worse the compiler will likely to try to infer Element as String because of all these extensions which also are satisfied when Self.Element is String.

extension DefaultIndices where Self.Element : Equatable 
extension DefaultIndices where Self.Element : Sequence
extension DefaultIndices where Self.Element : Comparable
extension DefaultIndices where Self.Element : StringProtocol 
2 Likes
struct A: RandomAccessCollection {
  var startIndex: Int { return 0 }
  var endIndex: Int { return 0 }
  subscript(i: Int) -> Int { return 0 }
// RandomAccess also picks up default for index(after:) where Index == Int
}

print(type(of: A().indices))
// Range<Int>

struct B: Collection {
  var startIndex: String.Index { return "".startIndex }
  var endIndex: String.Index { return "".endIndex }
  func index(after: String.Index) -> String.Index { fatalError() }
  subscript(i: String.Index) -> Int { return 0 }
}

print(type(of: B().indices))
// DefaultIndices<B>
2 Likes

This might be a related post, I remember having a glance at. I will need to re-read it still, but it still might be interesting for the readers of this thread: [RFC] Associated type inference

1 Like