My frustration with Generics and Protocols

I don’t know what Swift engineer had in mind when they decided to use this existential concept.

Why this doesn’t work:

protocol MyDelegate : AnyObject {
}

final class WeakDelegate<T: AnyObject> {
    weak var value: T?
    init(_ value: T) {
        self.value = value
    }
}

var delegates = [WeakDelegate<MyDelegate>]() //'WeakDelegate' requires that 'any MyDelegate' be a class type

While this work:

protocol MyDelegate : AnyObject {
}

final class WeakDelegate {
    weak var value: MyDelegate?
    init(_ value: MyDelegate) {
        self.value = value
    }
}

var delegates = [WeakDelegate]()

At compile time, they should be the same thing. At least it’s how other languages work, and it’s intuitive to think that way. Just create a new class WeakDelegate'MyDelegate'1 at compile time by replacing all the T with the concrete implementation.

What am I not seeing? Is there a benefit to the Swift way?
This is super annoying and I see myself fighting the language itself more than I would like.

1 Like

T: AnyObject means “one reference type“ (which you have not supplied—MyDelegate is a constraint you can put on a reference type, but it’s not a reference type.)


: MyDelegate means “any type that conforms to MyDelegate“. You should spell it like this, for clarity:

weak var value: (any MyDelegate)?
init(_ value: any MyDelegate) { self.value = value }

Those are different. Which do you want?

1 Like

I can see why this can be frustrating, and unfortunately I don’t have a nice solution here, but I’ll try to explain why this doesn’t work. This is just my understanding and I can be wrong or there can actually be a nice way to do it.

Your WeakDelegate class definition requires that the generic parameter T conforms to AnyObject.

The problem is, the existential any MyDelegate doesn’t conform to AnyObject (and in fact, it doesn’t even conform to MyDelegate), a concrete type conforming to MyDelegate does.

That’s why you get the error message: “'WeakDelegate' requires that 'any MyDelegate' be a class type”.

This is a limitation of today’s Swift though and shouldn’t be a limitation of the “existential” system altogether. This issue could be fixed by allowing extensions on existential types, for example by writing this:

// conforming any MyDelegate to AnyObject, this should be trivial conformance though
extension any MyDelegate: AnyObject { }

// or conforming any MyDelegate to MyDelegate
extension any MyDelegate: MyDelegate { ... }

or by allowing implicit trivial self-conformance when possible. This feature isn’t available in Swift today though.

For the time being, using manual type erasure (e.g. by introducing an AnyMyDelegate class) can be useful but it would add a lot of boilerplate if you have many such delegate types.

4 Likes

I think the specific difficulty you’re running into is that there is no way to encode a weak collection that can generically hold elements that are either concrete class types or class-bound existentials, because there’s no way to spell this exotic kind of requirement on T.

You can trade a little bit of type safety here without losing generics entirely though, perhaps if WeakDelegate left T unconstrained, and had a _value stored property of type AnyObject?; then you could define a value computed property that force casts to and from T. It would crash at runtime if T was not class-bound, of course.

Swift generics are not implemented by syntactic substitution at compile time (if they were, you wouldn’t have existential types). It’s also best not to think of protocols or existential types as being like superclasses in an OO language.

The reason existential types are a little bit funny is that generics and protocols are the primitive concepts, while existentials are assembled on top. A “upcast” to an existential type involves a representation change.

That’s why “protocol as a type” is now spelled “any P”, whereas a conformance requirement just refers to “: P”. The latter is the building block for the former.

9 Likes

You could further constrain the init to where T: AnyObject to ensure that the cast is always safe by construction without lifting the requirement to the type level!

2 Likes

I’m not sure I see how though, because WeakDelegate<T>.init ultimately will take a value of this same exact T, which would be any MyDelegate in this case.

2 Likes

Ha, you’re right—the definitions work out alright but would fail when you actually try to pass a value of a type that conforms to MyDelegate. :slight_smile:

1 Like

Here is my definition (updated to properly handle optionality):

struct Weak<T> {
  private weak var _value: AnyObject? = nil

  init(value: T?) {
    self.value = value
  }

  var value: T? {
    get { return _value as! T? }
    set { _value = (newValue as AnyObject?)}
  }
}

I realized that the cast in the setter doesn’t have to be forced, because otherwise we warn forced cast from 'T' to 'AnyObject' always succeeds; did you mean to use 'as'.

3 Likes

This wouldn’t really be possible even if extension any P: Q was supported, because AnyObject specifically means the value is a single machine word in size, whereas any any P for a class-constrained P is a pointer together with a witness table. Also AnyObject is not a protocol, and “retroactive conformance” to it is also not possible today.

7 Likes

Yeah that makes sense, I was mainly thinking of it as a hint to the compiler that we should be able to use this where we have an AnyObject constraint. I feel like this should work out of the box though since any P (where P: AnyObject) should always contain a reference type.

But isn’t that an implementation detail or is that something intrinsic to AnyObject regardless of the implementation?

#expect(Weak(value: 1).value == 1) // ✅
#expect(1 is AnyObject) // 'is' test is always true

:package::boxing_glove: (I think the first part is AnyHashable at work?)

Yeah, I forgot we do the whole silly thing from swift-evolution/proposals/0116-id-as-any.md at main · swiftlang/swift-evolution · GitHub here. I don’t think it works on non-Darwin platforms though.

It’s an implementation detail but it cannot be papered over in the language. An Array<T> where T: AnyObject for example must always have the same in-memory layout as Array<AnyObject>, while Array<any MyDelegate> is an array of pairs of words, so it’s not substitutable for an Array<AnyObject>.

3 Likes

I think what many people want (including myself) is a new “Weakable” constraint. This would allow existential boxes to conform without the strict requirements of AnyObject.

Maybe it could even be designed such that structs with object-like behaviour could also conform.

As far as generics surgery goes, this change wouldn’t be too difficult, but it probably wouldn’t gain much traction unless it was part of a more general rethink of “layout constraints”, which are this idea that exists in a half-implemented form of being able to constrain T to being POD, or some fixed size and alignment, and so on. A Weakable constraint (but really you want this to apply to unowned and unowned(unsafe) as well, so AnyReference or something silly like that instead maybe) could be satisfied by a type of any size as long as the first word was a reference and the rest of the type was POD. You could then form a weak T or unowned T dynamically.

This would be a heavier lift, because it would introduce a new kind of dynamic dispatch when manipulating values of type T abstractly. It’s probably not worth it considering the narrow applicability of this. Although perhaps one day the weak and unowned keywords could desugar to special stdlib types using a mechanism like this.

2 Likes

The thing that gets me here is that for any specific class-bound protocol, you can create a weak wrapper for the existential:

protocol P: AnyObject {}

struct WeakP {
    weak var wrapped: (any P)? // ✅
}

And for any unconstrained class you can create a weak wrapper:

struct Weak<T: AnyObject> {
    weak var wrapped: T? // ✅
}

But if you try to combine the two, it doesn't work, and if there is a reason, it's not obvious to me:

protocol P: AnyObject {}
final class C: P {}

struct Weak<T: AnyObject> {
    weak var wrapped: T?
}

// 🛑 'Weak' requires that 'any P' be a class type
let x = Weak<any P>(wrapped: C()) 

There are some carve-outs for this kind of thing in the type system already; any Error: Error and any P: P if P is an @objc protocol. Why can't any P: P where P: AnyObject and lacks self-constraints?

For the reason I described further up in the thread, T: AnyObject is a statement about the in-memory layout of T, not just an abstract semantic property. A class existential does not satisfy this condition, unless P is an @objc protocol, in which case there is no witness table, so the existential is really just a single pointer.

9 Likes

I think this explanation is what I was looking for. The question that remains now is: why not? It works so well for other languages.

Thanks for helping out — it gave me direction on where to research further.

Three reasons that come to mind, there are probably others:

  • Doing it at compile time requires the full implementation of every library function to be available at compile time, which makes having a stable ABI pretty much impossible
  • Creating a huge number of specialized versions of functions blows up generated code size
  • Doing it at runtime allows for things like as? casts to change from failing to succeeding after dynamically loading a binary containing a conformance
12 Likes