Move SwiftUI's Identifiable protocol and related types into the standard library

Since there appears to be widespread support for moving Identifiable and friends I went ahead and drafted a proposal. It doesn't add too much to what has already been stated in this thread but does nail down the details on exactly what is proposed. You can find the draft here. There is also an implementation availalble in a gist.

13 Likes

If Identifiable had been introduced before ordered collection diffing, it would make sense to implement the latter in terms of the former. It’s very late to re-spec it, but it should be possible to add compatibility in future.

2 Likes

I discussed this topic with @numist a while ago on Twitter. The proposal that was accepted was intended to be a baseline diffing API that would be extended in the future. The algorithm it implements was never intended to be able to distinguish identity from state. Moving Identifiable into the standard library is a good enabler for a follow up proposal that provides a diff algorithm that is designed to do that.

8 Likes

I would really like this as well +1

While we're at it, the Combine.Cancellable protocol is just as general a concept. DispatchWorkItem is essentially a Cancellable, and I have a few in my personal arsenal at well.

2 Likes

Considering that quite a lot of data structures need to now start conforming to Identifiable when used with SwiftUI, I wonder if there's some fast path to get the conformance without too much repetitive work on it. Would for example this be suitable, or is there some reasons why some default like this would be Bad Idea(tm)?

extension Identifiable where Self: Hashable {
  public var id: Int { return hashValue }
}

// then all your basic data struct needs is
struct MyData: Hashable, Identifiable { ... }

// or in stdlib:   
// public protocol Hashable: Equatable, Identifiable { ... }
2 Likes

How does this relate to Swift's Identity Operators? If Identifiable moves to the standard library, does this means === and !== should work with it? Or is this an entirely separate concept?

6 Likes

I have had variations of Cancellable and AnyCancellable in my code for a long time as well. I put these in related thoughts as they are independent of Identifiable and are in Combine rather than SwiftUI so moving them to the standard library would be a separate proposal. If this idea gets a few more +1s indicating general support I will create a separate thread for Cancellable.

7 Likes

This would definitely be a bad idea for many reasons, not the least of which is that hash collisions are possible so a hash value is not necessarily unique and identifiers must be unique (within a given scope).

2 Likes

This is a good question. I’d like to hear what others think. Regardless of the answer, I don’t think it needs to be addressed immediately. Given the tight timeline it might be best to leave this as a future consideration.

With respect to Cancellable, I would not be opposed to putting it in the standard library, but also I'm not sure why the standard library would care about cancellation. The primary motivation for putting it there is just that it's the lowest level library we have. That justification could potentially lead to bloat if we are not careful.

Combine has an Identifiable protocol as well, and we would be happy to use one from the standard library instead. This is currently under-documented, but CombineIdentifier is designed to allow for:

  1. Pointer-based identity for classes
  2. Word-sized identity for structs
  3. Globally unique value for identity on #2 via atomically incrementing integer

UUID is probably too expensive in terms of memory and other costs.

If the concept of identity as expressed by the standard library is just the protocol, then we may end up in a situation where everyone's idea of identity is incompatible with each other. Model objects that need to adopt Identifiable then could potentially only be used with one higher level API.

9 Likes

One more thing while we're on the topic of sinking stuff: Combine's TopLevelEncoder and TopLevelDecoder protocols are 100% intended to be sunk into the standard library with the other Codable protocols.

5 Likes

In the case of Cancellable, Foundation is probably the better fit. It does have Operation and URLSessionTask and others. I seem to remember there was a problem with having Dispatch depend on Foundation, though. Dispatch has DispatchSourceProtocol and DispatchWorkItem.

1 Like

That is an interesting discussion to have! if the stlib's version is "just the protocol", do you think it would be feasible to refine it sufficiently for Combine?

Other questions:
What other notions of identity exist?
Which of these refinements are necessary?

1 Like

The standard library itself doesn’t care about cancellation but it doesn’t (currently) care about Result either. The justification for this change is that symbols that provide “common currency” should go into the standard library. I think this is definitely a high enough bar to avoid bloat.

I have written a lot of code that incorporates the notion of cancellation in very similar ways to Combine. It’s a fundamental notion that is necessary for correct resource management. Having a common currency for this notion enables interoperability for resource management code.

As an example, many reactive libraries incorporate the notion of DisposeBag. In Combine, a rough equivalent is [AnyCancellable]. In both cases, a reactive library is not necessarily the only Cancellable resource owned by a context. It should be possible for code that bounds the lifetime of cancellable resource utilization to be compatible with any cancellable resource, not just those provided by one library.

Thanks for brining this up! I noticed CombineIdentifier but didn’t notice that CustomCombineIdentifierConvertible was roughly equivalent to Identifiable. It looks like Combine’s use of CustomCombineIdentifierConvertible is currently limited to refinement of that protocol by Subscriber and Subscription. At a glance, it seems reasonable for these protocols to refine Identifiable where ID == CombineIdentifier instead (possibly also constraining IdentifiedValue == Self). Am I missing anything here?

One of the reasons I am proposing we adopt SwiftUI’s design is that it places very few restrictions on the identifier type - only those that are absolutely necessary for pervasive use cases of identifiable types. This provides a common currency baseline while still allowing us to apply higher-level constraints where necessary.

Some higher-level libraries such as Combine may have additional requirements for identifiers, but that doesn’t mean types meeting those constraints will only be usable with one higher level API. Subscribers and subscriptions would be compatible with generic code that doesn’t have the identifier constraints that Combine itself does. This could be useful for some publisher types.

My current Identifiable protocol is another example - it requires the id property to be an ID<Scope: IdentifierScope> (where the model determines which scope is used for its identity). This design works well for my needs and is necessary in some contexts but is unnecessarily restrictive for truly generic code that works with identifiable models. I would like to update the design to refine Identifiable so that my models are compatible with SwiftUI while also providing additional structure required by some of my code.

I believe Hashable is the right constraint for a currency protocol. The only alternative I can think of would be to relax the constraint to Equatable. But it’s hard to imagine generic code that would only require Equatable and not Hashable and the latter does not feel like a very onerous constraint to expect of types that are suitable identifiers. If it’s hard for a type to provide a Hashable conformance it is probably not a good candidate for an identifier.

Thanks for mentioning this! That isn’t immediately clear when reading the docs. Can you elaborate on the role these protocols play?

Do you think it makes sense to roll all of these changes into one proposal or to have separate proposals for Identifiable, Cancellable and the encoder / decoder protocols?

4 Likes

Foundation definitely has types that are good candidates for Cancellable conformance. However, I’m not sure every library providing a cancellable resource should have to import Foundation. Also, I don’t believe the SE process can be used to propose changes to Foundation.

2 Likes

Fair enough. The analogy with Result is convincing to me.

3 Likes

I agree that the concept of cancellation is general-purpose. I just want the motivation for inclusion in the standard library to be stronger than just a sense of general utility. Cancellation and Identity may meet that bar. We should find a way to prove it to ourselves.

I would rather not have the concrete type either. I suppose we could do without it, but we need something more than Hashable. Let's say you want to print the identifier in some kind of debugger. There is nothing in Hashable that is sufficient. We may want to either define some new requirements or conformance to other protocols to make the identifier more useful in a generic context.

One other reason for the concrete type so that structs have an easy API for creating an identity for themselves:

var combineIdentifier = CombineIdentifier()

and the protocol for classes to get an automatic identity:

extension CustomCombineIdentifierConvertible where Self: AnyObject {
    public var combineIdentifier: CombineIdentifier {
        return CombineIdentifier(self)
    }
}

This is the scenario I want to avoid:

  • FrameworkA needs more concrete identity, so it says that it requires its model objects use Identifiable where the associated type must be == FrameworkAIdentity.
  • FrameworkB, which has nothing to do with A, does something similar with its own concrete type.
  • Model object M intends to be used with both frameworks but can only conform to the protocol once.

I think that by including more requirements on the protocol would reduce the need of higher level frameworks to require some other concrete type and keep identity more universal. Alternatively, don't make it generic but instead require a concrete identity type.

The decode operator is defined generically in Combine, but Combine is below Foundation. We needed a standard pattern for asking JSONDecoder or PropertyListDecoder to decode, along with what type they expect to decode from.

public protocol TopLevelDecoder {
    associatedtype Input
    func decode<T: Decodable>(_ type: T.Type, from: Input) throws -> T
}

public protocol TopLevelEncoder {
    associatedtype Output
    func encode<T: Encodable>(_ value: T) throws -> Output
}

I'm not sure about this part. We are very short on time if we intend to change anything about the identity used by SwiftUI or Combine.

1 Like

In both cases I think the case to be made is one of currency / interoperability, as with Result. We want libraries relying on identity and cancellation to be compatible with one another. This is stronger than general utility. Do you have a more specific form of proof in mind?

If print / String(describing:) and friends aren’t sufficient additional requirements would make sense as generic constraints and / or a refining protocol.

This looks like something that would also be more broadly useful. Requirements 2 and 3 you listed above are certainly desirable in some use cases beyond Combine. Maybe CombineIdentifier could also be pulled down? And if the list you provided above is more a guideline than a hard requirement maybe Combine doesn’t need anything more than Identifiable. Maybe we just need to make it easy for structs to conform by providing an efficient identifier.

We could provide a default Identifiable conformance for classes:

extension Identifiable where Self: AnyObject {
    var id: ObjectIdentifier { ObjectIdentifier(self) }
}

I see, that would be unfortunate. This issue isn’t limited to Identifiable and libraries that do need to impose concrete type requirements should think carefully before doing so. The alternative is that we don’t have a currency protocol, or have one that is unnecessarily limiting.

What additional requirements do you have in mind? If they are easy for any conceivable identifier type to meet maybe that would be ok. But the incompatibility issue you mentioned is really only relevant for same type constraints, not general constraints.

I think making the requirement a concrete identifier type would be a bad idea. Different use cases require different identifier types. The protocol won’t provide the general currency we need if it specifies the identifier type.

I see. I was thrown off by Input and Output because they will conventionally be Data. But that would be unnecessarily restrictive in a protocol. I agree that these should also move down to the standard library.

Aside from agreeing with the design in general, one reason I adopted SwiftUI’s design directly is to minimize impact on SwiftUI. The impact on Combine we’ve been discussing today would definitely be larger. I’d like to try and push these changes forward if at all possible and am happy to defer to Apple folks working on these frameworks regarding the best way to proceed.

4 Likes

I thought that @anandabits's argument was pretty strong. Being able to mix and match "cancellables" from different libraries makes sense. One way to make it even more generic and useful would be to rename it to Disposable (with the accompanying dispose() method). Then it would be crystal clear that it can also be used for any kind of resource management. Once we have that, we may want to even steal a great idea from C#:

using let resource1 = createResource(), let resource2 = createResource() {
    resource1.foo()
    resource2.bar()
}

would be equivalent to:

let resource1 = createResource()
let resource2 = createResource()
resource1.foo()
resource2.bar()
resource2.dispose()
resource1.dispose()
5 Likes