Lifting the "Self or associated type" constraint on existentials

When the first seed of Swift came out many years ago, there were technical reasons for the "self or associated type" constraint on protocol existentials: At that time, protocol witness tables did not carry associated type information, so it was impossible to re-open the dynamic type in order to dispatch methods on an existential when a protocol had associated types. This was fixed a while ago in order to allow for recursive protocol constraints, but we held on to the restriction on existentials thinking it would help avoid confusion or design dead ends with people using protocols as existentials in ways that we couldn't really fully support yet. However, nowadays we also have protocol extensions, so even if a protocol doesn't have any core requirements with contravariant Self or associated type arguments, contravariant protocol methods can be added by extensions, so the type system issues unavoidably exist already. The "self or associated type" constraint also creates artificial resilience problems for protocols; it would otherwise be OK for a protocol to introduce new associated type or contravariant method requirements with default implementations, except that doing so would revoke the protocol's ability to be used as an existential type. Since the restriction is pretty much artificial at this point, I think we should just lift it. What do you all think?

99 Likes

Can you be more specific about what you have in mind? Would this be generalized existentials or would this be a really basic first step where members with self or associated type constraints just are not available? Either way, +1 from me. Now is as good a time as any to start down this road.

8 Likes

Would you happen to have a simple example of how this applies in some code? Your explanation is a tad too abstract for me to follow otherwise :flushed:

5 Likes

This wouldn't be fully generalized existentials because you still can't put constraints on associated types in the existential. We'd follow the existing rule which is that protocol members that use contravariant Self or associated types aren't available on the existential. (You could however write protocol extension methods, which implicitly open the Self type, and use associated types indirectly in a way that's then available through the existential type.)

15 Likes

Sounds like a nice first step forward towards the larger goal of supporting constraints. Hopefully this would alleviate some of the fear people have about "PATs".

2 Likes

Yes please.

5 Likes

Is this about allowing:

protocol P {
  associatedtype A
}

func f(param: P) {
  //..
}

If that is the case, then FINALLY! Huge yes to this.

24 Likes

Huge +1 to this. Would make using protocols in Swift so much easier

If I'm understanding the pitch correctly +1. By far the biggest roadblock I run into when writing modern Swift. I'd interested to hear if there are any known downsides though.

3 Likes

To clarify though, this pitch would not actually allow you to use members of the protocol that involve Self or associated types. So they would still be second class in a sense.

6 Likes

super +1 from me ^^

1 Like

Just to clarify, this pitch won‘t eliminate the need for opaque types? I assume that these kind of existentials won‘t work im generic context like proposed by opaque types.

This seems like a good idea to me Joe: the limitation is fairly artificial, as you say, and in general I don't think the proposed new behaviour will be any more surprising than the current behaviour.

Yes please! This would be exceptional!

Question:

protocol P {
    associatedtype A
    var a: A { get }
}

func f(param: P) {
    let a = param.a
    // What's the type of a? P.A?
    // What can we do with A? Nothing?
}

protocol Q {
    associatedtype B: CustomStringConvertible
    var b: B { get }
}

func g(param: Q) {
    let b = param.b
    // What's the type of b? Q.B? CustomStringConvertible?
    print(b.description) // Is this valid?
}
3 Likes

Given what Slava said above, I'm guessing you wouldn't be able to call param.a or param.b.

You would be able to create arrays of P or Q though, and pass those elements in to functions (must be non-generic due to protocol existentials not self-conforming) or use them directly when their superclass constraints allow it.

A more complete solution would also lift that self-conformance limitation. I wonder if we could...

2 Likes

+1 for
var array: [P]

This will allow [Equatable] and create some more confusion, but hey I‘m still +1 for this :slight_smile:

1 Like

It's awkward, but you can in fact "open" the existential by invoking a protocol extension method, which will give you Self as the dynamic type of the value inside the method, which you can then pass to generic functions:

func foo<T: P>(_: T)

extension P {
  func _forwardToFoo() { foo(self) }
}

func callFooOnEach(_ ps: [P]) {
  for p in ps {
    p._forwardToFoo()
  }
}

Another nice stepping stone on the way to generalized existentials (which I'm not proposing immediately) would be to make it so that existentials automatically open when passed as generic arguments to functions. Oftentimes this is closer to what you want than protocol self-conformance, since you really want to operate on the value inside the existential, not the existential itself.

30 Likes

Wait :scream: you can pass Self to generic context today? :exploding_head: That‘s interesting to know.

This is a pretty awesome trick I have used a few times. Unfortunately, because Swift does not have higher-ranked types we cannot pass a generic function to call. The good news is that we don't have to hard code the function that gets called (foo in the above example). There are many variations on this trick and a very useful one to know is how to use a "receiver" protocol to do double dispatch:

protocol _ReceiveP {
    func _receiveP<T: P>(_: T)
}

extension P {
    func _openP<Receiver: _ReceiveP>(for receiver: Receiver) {
        receiver._receiveP(self) 
    }
}

extension Bar {
    func doSomethingWithPs(_ ps: [P]) {
        for p in ps {
            p._openP(for: self)
        }
    }
  
    func _receiveP<T: P>(_ value: T) {
        // do somehing with value
    }
}

I hope you can imagine several variations on this theme. For example, you can pass dynamic context through the dispatch, you can have a receive method that returns a value which is directly returned by the open method, etc.

15 Likes