[Accepted] SE-0261: Identifiable Protocol

The review for SE-0261 has ended and the proposal has been accepted.

The proposal was positively received as addressing a common need for which a standard protocol is appropriate.

During review, there were some concerns about the use of the property id, with some preferring identifier to avoid clashes with existing properties, while others stated they would have the same problem with id. The core team feels that id, as the term of art, is the better choice. Future language features should provide a path to resolve clashes.

The other concern raised was regarding the default implementation using ObjectIdentifier for classes. Some felt this would lead to issues since object identifiers can be re-used if stored beyond the lifetime of the object. The core team believes this is a specific case of a more general class of problem: that the protocol leaves the duration of the identity unspecified. Identities could be guaranteed always unique (UUIDs), persistently unique but only per environment (database record keys), per-process unique within limits (Combine's identifiers are like this), for the lifetime of the object (object identifiers) or just unique within the current collection. It is up to both the conformer and the receiver of the protocol to document the nature of the identity. There is future potential to refine the protocol further if other guarantees prove useful in future.

While providing a default implementation could lead to bugs from conformances where it isn't appropriate, the documentation will be clear on the default implementation's limitations, and encourage conformers to replace it with a more appropriate ID if one is available. As ever, to conform to a protocol you must read the documentation to ensure you understand the requirements. In this respect Identifiable's default for classes is similar to RandomAccessCollection, which imposes no compile-time requirements over BidirectionalCollection but does require you adhere to other requirements.

Thanks to everyone for participating in the review, and to @anandabits and @kylemacomber for proposing this such that it can make it into the ABI for Swift 5.1.

21 Likes

I am still concerned about the default implementation for classes.

That is one way to look at the problem, but I think it is over-simplifying the issue.

The kind of identity we are talking about in this protocol is record identity - i.e. that you can have two independent instances (of a class, struct or anything), with arbitrarily-different values for their fields, except for the id property. It remains stable when other properties change, allowing you to correlate those instances as referring to the same modelled 'thing'.

For instance, you might have two (or more) Person instances, with different surnames, say, but the same id/SSN because they refer to the same person.

In that context, a class's address is completely meaningless. You will never have two live instances with the same address, so they will never have the same id. The only case where the memory address would actually fit the semantics of record identity is when your model classes are universal singletons (only ever one instance per row, even across threads); and in that very rare case, you don't even need a stable id because all mutations will be visible via all references (because that's how classes work). This has nothing to do with lifetime. Again, I would encourage looking closely at NSManagedObjectID and how that type is used.

Taken together with the severe potential for confusion, I do not think this convenience pulls its weight. If there is some bizarre, rare data model where this actually makes sense, it is trivial to implement it yourself.

I would urge the core team to reconsider this aspect of the proposal.

10 Likes

This is incorrect. The kind of identity is intentionally unspecified. Record identity is often used with this protocol but it is also possible to use the protocol with other notions of identity, object identity being one. As a concrete example, I have already written some SwiftUI code that uses object identity, not record identity.

Well now I am confused. I thought it had been well settled on the basis of the review discussion that this protocol was about record identity. If it is about intentionally unspecified identity, then what kinds of useful generic algorithms are enabled by a protocol that guarantees often-but-not-always-record-identity?

9 Likes

From the proposal:

So yes, in fact, it is absolutely about record identity and nothing else. To use that terminology, if the identity is the same as the memory address, there is no concept of having two "snapshots" with the same id (i.e. same memory address), but with different values. Again, that's not how classes work.

I mentioned that there is one pattern where the two are equivalent: in the case of singleton model instances, where every "logical entity" is represented by one, and only one, object. I'll bet $20 that that's what your "concrete example" is doing.

That's a very specific and rare pattern which does not justify having a default implementation for all classes. But if that is really what you want to do, I don't think it's too onerous to implement the single requirement yourself. Default implementations are a convenience, but this one just isn't.

2 Likes

Maybe the ship has sailed. As the review rationale says:

While providing a default implementation could lead to bugs from conformances where it isn't appropriate, the documentation will be clear on the default implementation's limitations, and encourage conformers to replace it with a more appropriate ID if one is available. As ever, to conform to a protocol you must read the documentation to ensure you understand the requirements.

But I also agree that the default implementation can lead to this nasty scenario:

  1. User defines a record class that should be identified from, say, its uuid property.
  2. User adds Identifiable conformance and does not provide the customized id because the compiler does not require it.
  3. The record class is now identified by instance instead of uuid.
  4. The user injects those records into a tool that uses Identifiable in order to optimize some algorithm. It happens that this algorithm provides the same output despite the "wrong" implementation of Identifiable by our example record class. But the way to build the result is impacted. Consider, say, table view diff. Because of the "wrong" implementation of Identifiable, the diff may be bigger than necessary, leading to various unwanted side effects, from increased memory consumption to missed opportunities to reuse views.
  5. Nothing warns the user that he sub-optimally uses the tool.
1 Like

The identity must define a uniq record only in the context they are used. You may have as many instances representing the same record as long as they are not part of the same set of identifiable objects.

In such context, using the ObjectDescriptor as default value will not be an issue and will almost always be right.

If your record has a special meaning for Identity (SSN, …), there is no reason it uses the default implementation and expect the system to do the right thing. This is true for all protocols like Equatable.

There is a difference between Identifiable and pervasive protocols like Equatable, though, so I'm not sure this reasoning is 100% valid.

That difference is that in many occasions, users will not declare Identifiable for their own consumption (where bugs are easily spotted - by tests or usage), but in order to feed tools that require Identifiable conformance (where bugs are much less easy to spot, as I tried to explain in my previous post).

Identifiable is not a fundamental protocol like Equatable and Hashable, but a convenience support protocol for algorithms that are often written by somebody else. There are consequences.

1 Like

We could avoid having to, you know, fight about this by simply withdrawing the default implementation for now, and letting the protocol stand on its own.

If, in the future, someone can make a case for the appropriateness of this — or another — default implementation, then there will naturally be a further proposal that can be discussed in light of actual experience.

16 Likes

There was plenty of discussion of this in the review thread, but that semantic requirement was not proposed. Accepting a default implementation of ObjectIdentifier is not consistent with that semantics either, so I'm not sure why you would think it was settled in that manner.

No it isn't. The example you quoted is about record identity. But record identity was not proposed as a semantic requirement of the protocol.

Default implementations are not bulletproof. They are a convenience. It is the responsibility of the author of the conforming type to provide a correct conformance.

The default in this case doesn't save a lot of code, but the implementation is somewhat non-obvious. There are many Swift programmers who haven't encountered ObjectIdentifier before.

I haven't seen any new arguments against it today and I don't think it's a good idea to re-litigate core team decisions without at least bringing some new information, examples, or perspective to the table.

Here is an example of SwiftUI code that uses object identity instead of record identity. This is not how I would normally write SwiftUI code but it serves to demonstrate that SwiftUI's use of Identifiable does not depend on record identity. I don't see a compelling reason to prevent this example from being valid code.

final class Counter: Identifiable, ObservableObject {
    @Published var count = 0
    @Published private var child: Counter? = nil

    func presentChild() {
        precondition(child == nil)
        child = Counter()
    }
    var presentedChild: Binding<Counter?> {
        Binding(
            get: { self.child },
            set: {
                precondition(self.child != nil)
                precondition($0 == nil, "Presentation bindings should only ever set their value to nil")
                self.count += self.child!.count
                self.child = nil
            }
        )
    }
}

struct CounterView: View {
    @ObservedObject var counter = Counter()
    var body: some View {
        VStack {
            HStack {
                Text("\(self.counter.count)")
                Button("++", action: { self.counter.count += 1 })
            }
            Button("More", action: { self.counter.presentChild() })
        }
        .sheet(item: counter.presentedChild) {
            CounterView(counter: $0)
        }
    }
}

Because if I didn't follow SE-0261, I'd be sitting there scratching my head going "How is this object Identifiable? Is it by the child?" and then proceed to dig into Identifiable.

With Equatable it's easy to reason about what the potential compiler implementation would be without really knowing the semantics of the protocol.

It's about clarity at the point of use.

In addition, given Swift's ABI stability, I'd very much like to err on the side of being conservative on what we put into the stdlib.

I do agree with this, though. Just thought I'd present a counter to your example.

1 Like

I also had the distinct impression that the overwhelming feedback regarding the default implementation was negative, e.g. SE-0261: Identifiable Protocol - #35 by Lantua

It is unfortunate that this has been completely ignored and the proposal has not even been extended with these objections in the alternatives section.

Now stating that this was discussed but not proposed is a bit of a cyclic argument... it has certainly been proposed by the community.

I also don‘t understand the strong desire to keep this default implementation as weiting it for the rare cases where it is needed is trivial (I don‘t share the view that ObjectIdentifier is obscure knowledge).

4 Likes

The core team was able to consider this feedback and addressed it in the rationale for their decision.

It's not exactly obscure (I didn't say it is), but it is definitely not common knowledge either.

2 Likes

FWIW, when I read this, it feels like an argument against having a default implementation.

The default implementation makes Identifiable conformance mandatory for every class type, regardless of whether the author understands the requirements of the protocol.


On a separate note: One thing I really like about Swift is that the standard library doesn't "pollute" types with a bunch of properties/methods. In Swift, the author of a type is in complete control of what members that type has. I worry that the default Identifiable conformance, which adds an id property to all class types, sets a bad precedent.

You still need to explicitly conform to Identifiable to get the default implementation, like Equatable.

:man_facepalming: Give me a minute to wipe the egg off my face :grimacing:

4 Likes

@Ben_Cohen

If reused identifiers are not an issue for default implementations, can I ask why there is no similar default implementation for Equatable?

extension Equatable where Self: AnyObject  {
    
    static func ==(lhs: Self, rhs: Self) -> Bool {
         ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
    }
}

If objects are identical (object identity), they have the same state and therefore should also be equatable, no?

(Note that I'm not proposing we add this implementation, I'm only playing devil's advocate here)

8 Likes

Maybe because it was not possible to declare such conditional conformance when Equatable was introduced ?

Probably because objects having the same identifier isn't the only way for them to be considered "equal". I mean, I'm aware of the arguments in favor of that approach, and I might even be convinced that in a vacuum it's the correct approach. It'd make Obj-C interop a real pain, though, since class doesn't have that semantic attached to it in Obj-C. If there was a AnySwiftObject constraint, it could be a very interesting conversation.

Doesn't it though? objc1 == objc2 is evaluated using pointer equality, as is [objc1 isEqual: objc2], AFAIK.

Personally, I prefer not having this default for Equatable, as it's almost never useful, and already covered by ===.