[Accepted] SE-0261: Identifiable Protocol

Obj-C classes imported into Swift receive a == implementation automatically using pointer equality. Swift classes do not receive such a default.

Ah, I did not know that. Is it just a free == function, or do they automatically conform to Equatable, too?

Equatable (implemented using isEqual) and Hashable (implemented using hash).

Hmm... I'm inclined to say that this is unfortunate, but I obviously wasn't paying attention to those discussions or this wouldn't be the first I'd heard of it.

My understanding is that Swift == for Obj-C class instances is actually implemented using the Obj-C -[isEqual:] method, whose default implementation in NSObject is pointer equality.

For classes that override -[isEqual:], including NSString and collection classes like NSArray, Swift's == will therefore use the override, not pointer equality.

This is likely documented somewhere, but I don't know where.

2 Likes

1 Like

Well yes, that's the point I was trying to make with this argument: this default implementation for Equatable is just as correct or incorrect as the default implementation for Identifiable:

In its very first sentence, the proposal states the goal of the Identifiable protocol is to "correlate snapshots of the state of an entity in order to identify changes". In other words: to check if different instances are snapshots of the same entity. Similarly, the goal of the Equatable protocol is to compare the state of two different instances to see if they hold the same state and can be used interchangeably.

For both protocols, the case where the two instances are identical is trivial. Both default implementations handle only this case. Most users will simply accept this default, assume it's correct and we can't blame them for doing so.

If the Standard Library offers a default implementation, it should be correct in all cases where it's offered. Do we really think users are going to look for and read the protocol's documentation if they can just add conformance to their class and not get any warnings or errors? As someone who teaches programming to young people, I can assure you they will just move on and assume everything is fine.

How does this rhyme with a default implementation that assumes object identity is the default? How does this encourage (reading of) documentation?

How does this rhyme with Swift's focus on safety? I'm under the impression that Swift is willing to sacrifice some convenience for safety. Isn't this why we don't implicitly convert between number types, why we require addtional work to support and unwrap optional values, why we have the override keyword, why we require an explicit self in closures, and so on? Why should we accept anything that "could lead to bugs" just to add some convenience for some use cases?

I feel like my understanding of these topics is still incomplete, but I don't seem to be the only one who still has serious concerns about this protocol. Here are some that remain unanswered:

If this protocol isn't exclusively about record/entity identity, what other forms of identity can it represent? Object identity was mentioned as an example, but does that even make sense? We already have object identity, why should we allow a protocol that can be adopted by structs and enums to represent object identity?

If this protocol is added to the Standard Library, shouldn't there also be an operator for it? Many people (myself included) conform classes to Equatable by comparing only some identifying property. Now that we have an Identifiable protocol, shouldn't we encourage users to implement that instead and stop conforming to Equatable just to get an operator?

Similarly, shouldn't the Standard Library collections treat the different forms of identity on the same level? What if I want to check if a collection contains an instance with the same identity as some given instance? The ergonomics of the Standard Library encourage users to implement Equatable, even when that isn't correct protocol for the job.

For example, here's some code I often write that relies on Equatable but is actually about record identity because it only cares about the (record) identity of the instances, not their object identity (they are different objects) and not equality (their state isn't required to be identical):

if user == activity.host { ... }
if activity.participants.contains(user) { ... }
5 Likes

Can you give me an example where 2 value returning true for pointer equality are not equals ?

Yes: objects that define their equality on some mutable shared state which can mutate during the == comparison.

For example, a "FileContent", or a "CurrentDate", or, for the sake of the argument, a simple pointer.

For such objects, "identity implies equality" could not be guaranteed.

It's the inverse we should worry about: Yes, identical objects are equal, but objects can be equal (interchangeable) without being identical. So the problem is not that the default implementation returns true when it should return false, it's that it can return false where a correct implementation should return true.

For example:

class Product {
    
    let code: String
    var description: String
    var price: Double
    
    init(code: String, description: String, price: Double) {
        self.code = code
        self.description = description
        self.price = price
    }
}

extension Product: Identifiable { }
extension Product: Equatable { } // Assuming my default implementation.

let p1 = Product(code: "CH1", description: "Chair", price: 1)
let p2 = Product(code: "CH1", description: "Chair", price: 1)
let p3 = Product(code: "CH1", description: "Chair", price: 1.1)

In this (fairly common) example, both default implementations are wrong:

  • All three objects represent snapshots of the same persistent entity. Again, the proposal states in its very first line that the purpose of Identifiable is to "correlate snapshots of the state of an entity in order to identify changes". Therefore, these three objects should be considered identical (according to Identifiable), yet they all have different ids. Without a default implementation, users would be forced to make a decision themselves, and would most likely implement id by returning code, which is the correct implementation here.
  • Similarly, my default implementation of Equatable is incorrect because it doesn't consider p1 and p2 equal. Remember that equality is about value, not identity. p1 and p2 represent the same state of the entity, can be used interchangeable and therefore should be considered equal values.
3 Likes

Maybe I missed some posts, but to me it looks like the factor of time didn't receive enough attention:
I think it's extremely common that IDs can be persisted - and when you use ObjectIdentifier, this will break terribly when objects are recreated (when, for example, an app is restarted).

Anyone who ever worked with a plain old relational database knows that one major reason to use IDs is expressing relationships, and with the default implementation, you have to be really careful not to forget you custom id in this scenario...

1 Like

I don't agree with this particular default conformance to Identifiable, but this statement is not correct, or at least doesn't describe the current state of things in Swift. It would be more accurate to say that it should be correct in most cases, otherwise almost no default implementation would be offered, e.g. the current synthesis of Equatable and Hashable conformance would be “wrong”.

I personally think the default implementation for Identifiable is bad, but not because it's often the wrong implementation. I think the default implementation Identifiable for objects is bad because knowing whether it is wrong or not requires reasoning about the lifetime of objects. Most people have little knowledge of how Swift manage the lifetime of objects and will assume "new object == different identity", even though it's not the case because creating new objects will reuse addresses (and thus identities) of deallocated ones.

And to make things worse, address reuse is more or less deterministic, so the failures of this default implementation will be non-deterministic.

2 Likes

Equality of non final class is a complex subject. The definition of correct implementation is highly subjective even for user defined implementation.

class Product {
    
    let code: String
    var description: String
    var price: Double
    
    init(code: String, description: String, price: Double) {
        self.code = code
        self.description = description
        self.price = price
    }
}

class SpecificProduct :Product  {
    internal var note: String?
}

class ObservableProduct: Product  {
   var objectWillChange: Producer
   
   override var price: Double {
      willSet {  objectWillChange.send() }
   }
}

extension Product: Identifiable { }
extension Product: Equatable { } // Assuming my default implementation.

let p1 = Product(code: "CH1", description: "Chair", price: 1)
let p2 = SpecificProduct(code: "CH1", description: "Chair", price: 1)
let p3 = ObservableProduct(code: "CH1", description: "Chair", price: 1)

Defining the correct implementation for Equatable for Product is left as an exercise to the reader.

That said, it's true that the default Identifiable implementation will probably does not fit many cases. Most of type will probably have to provide an ID explicitly, so it does not bring real value. One major drawback I see is that when comforting to a protocol, I like to have the compiler tell me what I have to implements, and in that case, I will have to read the doc as the compiler will not complains about the missing property.

1 Like

I still fail to see that ObjectIdentifier is a common case for class-bound Identifiable.

Perhaps that's what most argument against default implementations is about.

Assuming this is the case. A identifiable consumer will incorrectly detect a 'remove' / 'insert' as an 'update' operation. Is this that bad ?

For instance, a List displaying your objects, will replace a row instead of removing one and inserting a new one.

I can't think about a case where it would cause real trouble, especially if you use the default implementation, as it means your objects don't have a strong meaning for ID.

I guess at worse in SwiftUI it'll result in the wrong animation, which is wrong but only a temporary glitch. Other consumers could persist this however. If you are keeping counts of events in relation to an ID, those stats will be wrong. If you're keeping any association relative to an ID, associations to things that have disappeared could spontaneously get reassociated with other things.

It's not the end of the world and I can live with this, but it's sad that the default implementation is providing us with such an unreliable ID. On the positive side, people will learn a thing or two about address reuse the day they notice things don't always work correctly, and then they'll provide an ID of their own. Let's just hope they notice at an early enough stage.

It's worse than that! You need to first know that it can be wrong! Where you can get that information? By reading the documentation maybe? ObjectIdentifier documentation is actively misleading by telling you that it's unique and saying nothing about the lifetime!

It's bad if the only thing you're using Identifiable for is differentiating between remove/insert and update.

Let's say you want to have a list of people that gave you GDPR consent. You cannot directly save references to objects, because there isn't a Set that holds the contents weakly in swift stdlib, so you make it a Set of person.id. Alice granted you the consent, but then deleted her account. Then comes Bob and makes a new account. He didn't grant you the consent, but the system thinks he did. Oops, now you're breaking the law.

I cannot think of the opposite example - where you use the default implementation, persist the ids but there are no bugs. Maybe the key insight is that we aren't supposed to persist the ids? But in that case why is it a property, and not an operator?

I am locking this thread, as it seems to have become a fuller re-litigation/appeal discussion.

To be clear, this is a fine conversation to have, but it doesn't belong on the acceptance announcement thread. Many people follow the announcements thread to get updates on evolution proposals, but don't want to follow a full evolution discussion, especially not on an already-accepted proposal. We should try and keep the subsequent posts on them focused.

I'm going to create a new thread for this over on the discussion topic.

3 Likes