Hashable not auto-implementing when a member has a protocol type whose conformers must be Hashable

I'm trying to implement a protocol that is restricted to UIViewControllers. I have a struct that has an array of this protocol, and it isn't able to infer Hashable even though it is able to infer Hashable if the array type is just UIViewController. Here's an example...

protocol CardController where Self: UIViewController {
    func update()
}

struct CardGroup: Hashable { // DOES NOT WORK (NOT HASHABLE)
    let controllers: [CardController]
}
struct CardGroup: Hashable { // DOES WORK
    let controllers: [UIViewController]
}

I have been unable to determine why my CardGroup cannot auto synthesize Hashable for CardController if CardController is a UIViewController.

I have a solution that works in my case that works and actually makes more sense for me. However, just from a learning angle, I don't see why this doesn't work. Is this a TODO with the Swift compiler? A feature that may not have been implemented yet?

2 Likes

I think the answer is that you don't account for CardControllers that are not UIViewControllers. Although you only want CardControllers that are derived from UIViewControllers, the compiler can't make that assumption. In general, CardControllers are not Hashable, and when you make an array of existential types of CardControllers, the struct is not Hashable because the compiler can't guarantee that all elements of the array are Hashable. Again, the compiler can't guarantee that all CardControllers in the universe of discourse are UIViewControllers.

Why not just derive CardController from UIViewController and not use the protocol? Protocols are great, but they don't fix in every circumstance. You said your intent was to have all CardControllers be UIViewControllers. It would seem to me to make CardControllers derive from UIViewControllers to explicitly enforce that relationship.

Hey @jonprescott!

Thanks for the response, I appreciate you jumping in to help me solve this problem!

For your first point, I'm not sure that is true. The where Self: UIViewController clause restricts the protocol from being used anywhere except on UIViewControllers.

Take the following example...

protocol CardController where Self: UIViewController {}

class MyVC: UIViewController, CardController {}

func test() {
    let presentingVC: CardController = MyVC()
    let modal = UIViewController()
    presentingVC.present(modal, animated: true, completion: nil)
}

Here we have assigned a MyVC to to presentingVC which is a CardController and we still have access to present a function on UIViewController. So even though preventingVC is a CardController, it is still a UIViewController too, per the Self restriction in the protocol.

If the Self restriction was on an extension, I think your point would be valid, but because the Self restriction is on the protocol itself, all CardControllers must be UIViewControllers.

This restriction can be further shown with this example.

protocol CardController where Self: UIViewController {}
class MyObj: CardController {}

:point_up: This will return an error saying that 'CardController' requires that 'MyObj' inherit from 'UIViewController'

The ability to restrict protocols to specific objects/classes exists, so we know that ALL CardControllers are UIViewControllers so which can't swift synthesize Hashable

Again, I have a better solution in my current use case, however I am trying to understand why this doesn't work from a conceptual point of view. Is this a Swift implementation detail that has not been implemented yet, or am I missing something more?

Looks like a bug. There were some fixes recently around subclass constraints, because the compiler didn’t correctly infer that all conformers must be classes. This looks like a similar issue.

You should try a recent Snapshot from swift.org, and if that doesn’t fix it, file a bug report.

A Hashable conformance can only be derived if all stored properties of the type conform to Hashable. Unfortunately, the type Array<CardController> used here is not Hashable.

Array<T> conditionally conforms to Hashable if and only if T conforms to Hashable. In your example, the element type is CardController, which is a protocol type. Protocol types do not conform to protocols, regardless of what constraints are placed on Self, or if the protocol refines other protocols, etc.

2 Likes

We thought that too, @Slava_Pestov, so we tried the following:

protocol CardController: Hashable where Self: UIViewController {}

In this case, the compiler complains of a redundant conformance constraint, that Hashable is specified twice. That suggests to us that requiring conforming types to be a type that is Hashable is intended to make the protocol Hashable, or else it wouldn't be considered redundant. That seems in line with the behavior that requiring conforming types to be UIViewControllers gives us access to all of UIViewController's members through a variable like let foo: CardController, including Hashable's methods. In fact, in this case, a CardController is considered to be a UIViewController:

protocol CardController where Self: UIViewController {}
extension UIViewController: CardController {}
let foo: CardController = UIViewController()
let bar: UIViewController = foo

It sounds like you're saying something broader, though, that a protocol type is never treated as a subtype of another protocol type, despite that it can be treated as a subtype of a concrete type. Can you say more about why that's the case, or show us where we're making some false assumption?

It's more subtle than that. Subtyping and conformance are two different things. A protocol type can be a subtype of another protocol type if it refines that protocol type. A protocol type can also be a subtype of a class if it imposes a class constraint on Self.

However, a protocol type never conforms to another protocol. Only concrete types can conform to protocols.

In this case, we're trying to derive a Hashable implementation, and the requirement is that the stored property types of your struct conform to Hashable, not just that they are subtypes of Hashable.

2 Likes

Thank you. Then, a difference between conforming to a protocol and being a subtype of a protocol is that conformance is specifically a thing that only concrete types do? Is the reason for that practical, like because it focuses the spec of what the language has to accomplish in executable code, or is it something theoretical I'm not appreciating?

In this example, is it the case that the concrete type expression is assignable to foo because of conformance, whereas subtyping is not sufficient to pass foo to the hasher in the last line? My assumption had been that subtyping was the requirement for any kind of variable to be assigned, but now I would guess it's conformance for protocol types and subtyping for concrete types?

protocol CardController where Self: UIViewController {}
extension UIViewController: CardController {}
let foo: CardController = UIViewController() // Succeeds, all UIViewControllers are CardControllers
let bar: UIViewController = foo // Succeeds, all CardControllers are UIViewControllers
var hasher = Hasher()
hasher.combine(bar) // Succeeds, UIViewController conforms to Hashable
hasher.combine(foo) // Fails, CardController cannot conform to Hashable even though it is a subtype

It's a bit of both. Self requirements and associated types make it tricky for protocol types to conform to other protocols. Eg, imagine if I have this function:

func allEqual<T : Equatable>(_ elts: [T]) -> Bool {
  let first = elts[0]
  for elt in elts {
    if first != elt { return false }
  }
  return true
}

Imagine if [Equatable] conformed to Equatable. What would happen if you called allEqual([1, "Hi"])? The Equatable protocol's == requirement takes two parameters of type Self. In this case, the two parameters have different concrete types, Int and String. This is a contradiction.

On the implementation side, there are reasons for why self-conformance doesn't work as well. A value of protocol type ("existential") can be thought of as a container that stores something conforming to the protocol; the container itself cannot be stored inside another such container, so it doesn't conform. The reason is a little esoteric and has to do with how protocols are implemented, by passing a separate out-of-line "witness table" describing the implementation of the protocol's requirements on that concrete type.

Almost! I think you meant to phrase this the other way around. Assigning a value of type T to a variable of type U only requires that T be a subtype of U. Passing a value of type T as a generic parameter that is constrained to conform to P by a where clause requires that T conforms to P.

2 Likes

Assigning a value of type T to a variable of type U only requires that T be a subtype of U .

Yes, OK… So if a protocol subtypes another as in protocol A: B, you can do let foo: B = (expression of A) because subtyping meets the requirement. And that would also work in the case of protocol A where Self: C given C: B, because again A is a subtype of B.

But conformance is tighter than subtyping because as you showed, it is useful to make the distinction.

Thanks very much for all the details!

1 Like

I think it is because your [CardController] array is heterogenous. If you restrict it to only a concrete implementation of it, it works again:

struct CardGroup<C: CardController>: Hashable { // also works!
    let controllers: [C]
}

I'm not sure I have the vocabulary to express exactly why though.

Hey @sveinhal and @kevinc!

I appreciate you both jumping in on this, I've gained a lot of knowledge from this post! The nuance between subtyping and conformance escaped me and while some sort of me understood that they are different beasts, I don't think I gained an appreciation for it until now.

@sveinhal The suggestion you had regarding the [CardController] array being heterogenous makes sense on the surface level, however it wouldn't have worked in this instance for me as there were supposed to be multiple objects conforming to CardController in that CardGroup. That is besides the point though, as I have found a better solution for this specific use case, but I do appreciate the suggestion and the knowledge gained, as my curiosity as to why I couldn't do it was the reason I made the post, not that I was stuck with my specific problem.

Thanks again!

To be honest, these nuances sometimes escapes me as well, and I'm not sure I entirely understand all of it. Sometimes I recognise that there is probably a difference, and just hammer out alternatives swapping between protocol-constrained-generics and using-protocol-as-an-existential (or whatever it's called) until it works. ¯\_(ツ)_/¯

1 Like