A duck story

Moving this into a separate topic to not spam the original.

This is a manifestation of the ever surprising "protocol can't conform to itself" issue.

Distilling this issue got me down to this little story:


Duck Story

Setting

protocol Duck {
    func quack()
}

struct Donald: Duck {
    func quack() { print("quack") }
}

struct Adopt<Duckling> where Duckling: Duck {
    var little: Duckling
}

func teach(_ : Duck) {}
func walk(_ : any Duck) {}
func feed<T: Duck>(_ : T) {}

Cast of characters:

var donald: Donald = Donald()
var anyDuck: any Duck = Donald()

Main action

Can it quack like a duck?

anyDuck.quack()         // ✅

Is it a duck?

print(anyDuck is Duck)  // ✅ true, and 🟠 warning: 'is' test is always true
let whatever: Any = anyDuck
print(whatever is Duck) // ✅ true

Pass it as an existential?

teach(anyDuck)          // ✅
walk(anyDuck)           // ✅

Pass it as a generic?

feed(donald)            // ✅ obviously
feed(anyDuck)           // 🤔 even this?!

What else could we do with it?

Adopt(little: donald)   // ✅

But not this!

Adopt(little: anyDuck)  // 🛑  Type 'any Duck' cannot conform to 'Duck'
6 Likes

Adopt isn't a type, Adopt<T> is. what is T?

6 Likes

In Duck v2, Duck gains a new capability:

protocol Duck {
  func quack()
  func quack(at: Self)
}
6 Likes

This is very like the example I concocted for the relevant educational note, which was recently discussed in another thread. The note needs to be updated for the implicitly opened existentials feature (or rather, a different version needs to be written for the new diagnostics), and that should be tracked by a GitHub issue—contributions welcome!

Same as in:

feed(anyDuck)

It is Donald in this case.

The difference between Adopt(anyDuck) and feed(anyDuck) is that Adopt<T>.init has type T -> Adopt<T>, while feed has type any T -> Void. If feed had type any T -> T, it would be similarly forbidden, because T can only be known at runtime, but once it appears in return position it becomes visible in the calling context’s static type environment.

2 Likes

feed(_:) doesn't know it is Donald, it just knows it is some Duck. it never needs to know if it is Donald because it can just use the type variable T to refer to it.

this is different from calling the initializer on (any Duck).Type, it escapes the the type variable Self to the caller's scope, and Self is not a valid type name there.

1 Like

In this case feed prints Donald:

func feed<T: Duck>(_ v: T) {
    print(T.self)
}
...
var anyDuck: any Duck = Donald()
feed(anyDuck)

I believe there's a separate compiled function for every type I'm calling it with, in this case "feed_Donald" (if it had a name), so the above print prints "Donald".

Are you talking about "walk", not "feed"? This compiles just fine:

func stuff<T: Duck>(_ v: T) -> T { v }
stuff(anyDuck) // ✅

the printing uses reflection, it doesn't count as "knowing Donald".

(any Duck) -> Void is a compiler generated shim that calls (some Duck) -> Void, which is what feed(_:) actually is.

1 Like

OK, this is confusing:

  1> let x: any BinaryInteger = 2
x: Int = 2
  2> func f<T: BinaryInteger>(_ arg: T) -> T { return arg }
  3> f(x)
error: repl.swift:3:1: error: inferred result type 'any BinaryInteger' requires explicit coercion due to loss of generic requirements
f(x)
^
    as any BinaryInteger

yet:

  3> protocol Duck { }
  4> struct Donald: Duck { }
  5> let anyDuck: any Duck = Donald()
anyDuck: Donald = {}
  6> func stuff<T: Duck>(_ duck: T) -> T { return duck }
  7> stuff(anyDuck)
$R0: Donald = {}

And for what it’s worth, the type(of: stuff(duck)) is any Duck, not Donald.

3 Likes

This is true of C++ templates and Rust generics, but not Swift. Swift passes the runtime representation of the type as a hidden parameter and has a fully polymorphic implementation ready to go. (Though it may get optimized to an implementation that just works for Donald.)

10 Likes

When you call a generic function, you provide a replacement type for the generic parameter 'T'. When you open an existential, the replacement type is a local type representing the payload of that exact existential value. This local type cannot "leak out" of the expression. If my function also returns 'T', then the actual return type of the call is this local type, but since that cannot be the type of an expression, we wrap it in an existential.

So there's a symmetry here: calling a function <T: P> (T) -> T with an any P value opens the existential, calls the function with the payload, and wraps the result inside of a new any P. (Statically, you've lost the fact that both any Ps now have the same concrete type inside)

The reason that this doesn't work with an initializer is that the type of the initializer is <T: P> (T) -> Foo<T>. The substituted return type is Foo<> applied to this local type, but there is no existential type to erase it to; Foo<any P> is not correct.

We could generalize existentials further, and allow you to write any <T: P> Foo<T> for an erased Foo<> with an unknown generic parameter. Then the return type of our hypothetical init applied to an existential value could be expressed. With this new spelling, the existing any P type is just a shorthand for any <Self: P> Self.

6 Likes

My recollection was that the leak was prevented by banning calls to generic functions where an existential must be implicitly opened to fill a type parameter that also appears in return position.

That seems to be happening for any BinaryInteger but not for any Duck for reasons I do not understand.

I misunderstood the part regarding BinaryInteger. That looks like a bug. In any case it should be safe to return T, or T?, or () -> T or any other type that contains T in covariant position, because type erasure allows us to plug the leak, so to speak.

2 Likes

Is there a special rule that allows an implicitly opened existential to substitute for T in T -> T? ? Because T? is shorthand for Optional<T>, which is not covariant in T.

Optional is special-cased, there is a subtype relationship Optional<T> < Optional<U> whenever T < U.

4 Likes

After some distillery work, this is what makes BinaryInteger special:

protocol N {
    associatedtype M: N
}

protocol UglyDuck : N where M : UglyDuck, M == M.M {}

let x: (any UglyDuck)? = nil
func stuff<T: UglyDuck>(_ duck: T) -> T { duck }
stuff(x!) // 🛑 Inferred result type 'any UglyDuck' requires explicit coercion due to loss of generic requirements

Also found another unrelated issue:

protocol Duck {}
var duckOpt: (any Duck)?
var duckIUO: (any Duck)!
func roast<T: Duck>(_ duck: T) -> T { duck }
roast(duckOpt!) // ✅
roast(duckIUO)  // 🛑 Type 'any Duck' cannot conform to 'Duck'
roast(duckIUO!) // 🛑 Type 'any Duck' cannot conform to 'Duck'
1 Like

That's a nice test case, thanks .It looks like the check that emits the "losing requirements" diagnostic digs through generic requirements and tries to figure something out. While it should be using generic signature operations instead of performing a syntactic analysis, the more fundamental issue is that the question it's trying to answer is probably unanswerable.

The original justification in https://github.com/apple/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md#losing-constraints-when-type-erasing-resulting-values was to avoid a source break when more accurate erasure involving constrained existentials was implemented.

I think the written intent in the proposal is that the compiler must figure out, given a generic signature <T0, T1... where ...> and some type parameter Tn.A.B that's known to conform to a protocol P, whether the type parameter is "completely described" by its conformance to P, in the sense that anything we can derive about a member type of Tn.A.B or Tn.A.B itself, is a consequence of this conformance requirement only, and not any other requirement.

This is equivalent to asking if a certain function between two monoids is injective. This may or may not be decidable.

So maybe we can just remove this diagnostic, since it no longer seems to serve a clear purpose beyond introducing a theoretical puzzle.

4 Likes