EP 299 · Back to Basics · Oct 21, 2024 ·Members

Video #299: Back to Basics: Hashable References

smart_display

Loading stream…

Video #299: Back to Basics: Hashable References

Episode: Video #299 Date: Oct 21, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep299-back-to-basics-hashable-references

Episode thumbnail

Description

We’ve studied Equatable and Hashable, their laws, and saw how value types as simple bags of data easily conform via “structural” equality. What about reference types? Reference types are an amalgamation of data and behavior, and that data can be mutated in place at any time, so how can they reasonably conform to these protocols?

Video

Cloudflare Stream video ID: 6de5a181561aa6e04e6e267ddbe3b447 Local file: video_299_back-to-basics-hashable-references.mp4 *(download with --video 299)*

References

Transcript

0:05

So, that is the basics of Hashable for data types in Swift. It is just a means to distill a simple integer from a potentially complex data structure in order to allow for a fast path in certain kinds of algorithms and data structures, such as dictionaries and sets. And thanks to all of the knowledge we have built up previously, we can summarize the Hashable protocol as simply a computed property on an equatable type that is well-defined. That’s all it is. And if you don’t fulfill that promise in your type, it will be very easy to write reasonable looking code that produces very unreasonable results. Stephen

0:40

So, we should all feel pretty comfortable with the Equatable and Hashable protocols now. We have gone deep into their theoretical foundations, and shown time and time again why the properties of “equivalence relations” and “well-definedness” must be upheld in order to write code that is easy to reason about. But there is one major part of the Swift programming language that we have purposely ignored each step of the way, and that is reference types. Brandon

1:04

Everything so far has used value types, and for good reason. Value types are just bags of data, and this is a concept that mathematics does very well with, which is where the root of all the concepts can be found. Stephen

1:16

But reference types throw a wrench in all of the wonderful mathematical properties we have explored. They are an amalgamation of data and behavior, and they can change their data over time all on their own. That seems quite complicated, and so how are we supposed to deal with equatability when it comes to reference types?

1:34

Well, let’s dive into that right now. Reference types

1:37

Let’s start by defining a simple reference type for a user reference: class UserRef { let id: Int var isAdmin = false var name: String }

1:42

Right out of the gate things are different from value types because we are forced to provide an initializer: Class ‘UserRef’ has no initializers

1:48

Swift will not automatically synthesize an initializer for classes due to the complications of how it interacts with subclasses.

1:55

However, even if we make this class final we still don’t get an initializer: final class UserRef { … }

2:01

Such is life with reference types, so let’s provide one explicitly. Luckily Xcode will help us out by writing out the most naive version of the initializer for us: class UserRef { let id: Int var isAdmin = false var name: String init(id: Int, isAdmin: Bool = false, name: String) { self.id = id self.isAdmin = isAdmin self.name = name } }

2:13

But even worse, we don’t get an automatically synthesized conformance of Equatable final class UserRef: Equatable { … } Type ‘UserRef’ does not conform to protocol ’Equatable’

2:21

And unlike the initializer, Xcode will not autocomplete a version of == that just checks each stored property for equality.

2:27

And the same is true of Hashable too: final class UserRef: Hashable { … } Type ‘UserRef’ does not conform to protocol ’Hashable’ And again, no autocomplete for a version of hash(into:) that just combines every stored property.

2:38

So, why is Swift taking such a hard stance against synthesizing any of these implementations for us on reference types?

2:44

Well, the initializer is a little different from Equatable and Hashable . This is something that Swift could provide under very constrained situations, such as final classes that do not inherit from any other class. And the core team has even expressed openness to such a change.

2:58

However, classes that inherit from another class are a lot more complicated. How can the compiler provide an initializer for a class that may have additional fields to initialize in the base class, especially if that base class in a separate module?

3:09

And so what about Equatable and Hashable . It turns out that when automatic synthesis of these protocols was proposed, the core team specifically decided to leave out classes: Note We do not synthesize conformances for class types. The conditions above become more complicated in inheritance hierarchies, and equality requires that static func == be implemented in terms of an overridable instance method for it to be dispatched dynamically. Even for final classes, the conditions are not as clear-cut as they are for value types because we have to take superclass behavior into consideration. Finally, since objects have reference identity, memberwise equality may not necessarily imply that two instances are equal. This spells out a lot of the complications we mentioned for initializers, but the last sentence is key and is what sets Equatable apart from initializers: Note Finally, since objects have reference identity, memberwise equality may not necessarily imply that two instances are equal.

3:45

This is saying something very important, and unfortunately saying it in too few words. It is saying that references are not purely defined by only the data they hold, as is the case for value types, and so memberwise equality checking is most likely not the correct thing to do.

3:58

Let’s explore this more deeply by implementing field-wise equality and hashing in our UserRef and see what goes wrong: static func == (lhs: UserRef, rhs: UserRef) -> Bool { lhs.id == rhs.id && lhs.isAdmin == rhs.isAdmin && lhs.name == rhs.name } func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(isAdmin) hasher.combine(name) }

4:40

As far as we know this should be a perfectly fine implementation of == and hash(into:) . The == implementation is certainly an equivalence relation, in that it is reflexive, symmetric, and transitive. And the hash(into:) implementation is certainly well-defined, in that if two users are equal, then certainly their hashes are equal.

4:58

So, if these implementations are legitimate, let’s try using them in a set to see what happens. We’ll start by creating two users and putting them in a set: do { let blob = UserRef(id: 42, name: "Blob") let blobJr = UserRef(id: 43, name: "Blob Jr") var users = Set([blob, blobJr]) }

5:20

These are distinct users, not only as references, but even as far as equality is concerned since they hold onto different data.

5:28

And so at this point we certainly expect to be able to query the set to ask if it contains these users: users.contains(blob) // true users.contains(blobJr) // true users.count // 2

5:46

This is all reasonable so far.

5:48

Now let’s do something that is only possible with reference types. We are allowed to mutate the actual reference to “Blob Jr” to give them a new name, “Blob II”: blobJr.name = "Blob II"

6:00

We are able to perform this mutation even though blobJr is stored as let , and this because it is a class. We aren’t mutating the actual data held by the variable blobJr . Instead, we are mutating the data inside a reference that is held elsewhere, and can be shared with any part of the app.

6:16

In particular, this means that we have secretly even mutated the user held inside the set, which we can confirm by extracting out all of the names in the set: users.map(\.name) // ["Blob", "Blob II"]

6:31

So, thanks to how reference types work, we were able to change the set without even mutating it directly. We only mutated a reference held in the set.

6:38

But this will wreak havoc on our intuitions of how this set behaves. For example, let’s ask the set if it contains each of our users: users.contains(blob) // true users.contains(blobJr) // false Weirdly the set no longer contains blobJr , even though we never removed the user and we just confirmed that there is a user in the set with the new name.

6:56

And we can even check the count of the set to see that indeed that there are two users: users.count // 2

7:02

So there appears to now be some kind of phantom user in this set that we can’t query for. What happened?

7:07

Well, by mutating the reference we have secretly changed its hash value, but the set does not know that. It’s just a simple, behavior-less value type. And so when we ask the set if it contains the blobJr : users.contains(blobJr) // false …internally the set is going to compute the hash of blobJr , which differs from the hash of the reference when it was inserted. And since it does not find the new hash stored internally, it will short circuit the remaining logic and return false .

7:30

It is worth noting that this behavior is not possible whatsoever with value types. Value types are just bags of data that get copied around. You can never mutate someone else’s copy, such as the copy that is stored in a set.

7:40

For example, let’s paste in the User value type version of the code we just wrote for UserRef : do { let blob = User(id: 42, name: "Blob") let blobJr = User(id: 43, name: "Blob Jr") let users = Set([blob, blobJr]) users.contains(blob) users.contains(blobJr) users.count blobJr.name = "Blob, II" users.map(\.name) users.contains(blob) users.contains(blobJr) users.count }

7:48

First of all, right off the bat we get a compiler error: Cannot assign to property: ‘blobJr’ is a ‘let’ constant

7:53

We are not allowed to mutate value types that are held as let . But even if we upgrade the let to a var : var blobJr = User(id: 43, name: "Blob Jr.")

7:56

Now the code compiles and runs: blobJr.name = "Blob, II" users.map(\.name) // ["Blob", "Blob Jr."] users.contains(blob) // true users.contains(blobJr) // false users.count …but the results are a lot more reasonable. After mutating blobJr we can see that the set did not change. It still holds a copy of users with the name “Blob” and “Blob Jr.”. And so then it isn’t so surprising when the set tells us that it does not contain this new version of blobJr : users.contains(blobJr) // false

8:19

If we want this new version of the blobJr we have to explicitly add it: users.insert(blobJr) // true users.contains(blobJr) // true users.count // 3 And now we have 3 users in our set.

8:42

Let’s try to do this with our references. We can try re-inserting the blobJr : users.insert(blobJr) // true

8:52

And according to the set that did actually insert a new element, and the count corroborates this: users.count // 3

8:57

But again this is quite strange. This set thinks it holds 3 references, but we know that in fact two of those references are equal. And not equal in weaker sense that their data is equal. These references are literally pointing to the exact same piece of data in memory.

9:12

And to prove this, let’s do something that should be a complete no-op, which is to convert the set to an array and then back to a set: Set(Array(users)).count // 2

9:22

Somehow we got down to 2 elements after this transformation. This transformation should produce the exact same set we started with. Converting a set to an array and back to a set should do absolutely nothing.

9:36

But during this process the set was finally able to see the two equal references when it was held in an array, and so then converting it to a set again allowed it to de-duplicate that element. References with behavior

9:45

What we are seeing here is that even with the most conservative implementation of Equatable and Hashable we can write very simple code that produces completely unreasonable results. In this case, sets and dictionaries basically become unusable because we are able to mutate the references inside those structures from any lexical scope and any thread in the entire application, and that completely ruins the ability for the set or dictionary to behave correctly. Brandon

10:08

But there are other ways things can go terribly wrong. It’s even possible for the behavior inside a class to be abruptly affected without knowing. Let’s take a look at this.

10:20

Consider this class that manages a timer on the inside, and when the timer ticks it posts a notification: import Foundation final class TimerNotifier { let name: String var task: Task<Void, Error>? init(name: String) { self.name = name } deinit { task?.cancel() } func startTimer() { task = Task { [name] in while true { try await Task.sleep(for: .seconds(1)) NotificationCenter.default.post( name: Notification.Name("TimerNotifier-\(name)"), object: nil ) } } } func stopTimer() { task?.cancel() task = nil } }

10:48

Nothing too special here. It has a name property so that we can manage multiple of these timers if we want, and it has endpoints for starting and stopping the timer.

11:30

Now suppose we wanted to be able to have a set of these timers: var notifiers = Set([TimerNotifier(name: "main")]) In order to do this we need the TimerNotifier to conform to Hashable : final class TimerNotifier: Hashable { … }

11:36

…but of course the question is: how should we implement this? static func == ( lhs: TimerNotifier, rhs: TimerNotifier ) -> Bool { <#???#> } func hash(into hasher: inout Hasher) { <#???#> }

12:00

This class is very different from the UserRef class we explored a moment ago. The UserRef class just held some data and did not have any behavior. There were no side effects or asynchronous work involved.

12:23

Well, to start we can certainly use the name of the timer in the Hashable conformance: static func == ( lhs: TimerNotifier, rhs: TimerNotifier ) -> Bool { lhs.name == rhs.name } func hash(into hasher: inout Hasher) { hasher.combine(name) }

12:43

That takes care of the one piece of data that the TimerNotifier holds onto, and so should we just stop here?

12:49

Well, the TimerNotifier does have behavior, which is the async task that runs the timer and posts notifications. So, should we try to figure out how to update the Hashable conformance to somehow take into account this behavior so that if you have one TimerNotifier with its timer on and another TimerNotifier with its timer off they are considered different?

13:18

But how do we even do that? The simplest thing could just be to check if the timers are running: static func == ( lhs: TimerNotifier, rhs: TimerNotifier ) -> Bool { lhs.name == rhs.name && (lhs.task != nil) == (lhs.task != nil) } func hash(into hasher: inout Hasher) { hasher.combine(name) hasher.combine(task != nil) }

13:54

That certainly captures the behavior of the timers being on or off. But is that enough? What if you had two TimerNotifier objects such that one’s timer has been running for hours, and the other’s timer has only just begun. Should we consider those objects equal just because both of their timers are on? Or should we somehow incorporate how long the timers have been running?

14:15

Honestly, I do not know the answer to these questions. The fact is that there is no way to distill the essence of this behavior down to some simple, Hashable data. And this is just a timer. More real-world objects can have much more complex behavior, for example network requests, location managers, speech recognizers, and a lot more. Do we really think we can figure out how to extract out some meaningful Hashable data from all of those processes? I don’t think so.

14:44

In fact, I think we really have no choice but to just ignore the behavior from the Hashable implementation on TimerNotifier and only use the object’s name: static func == ( lhs: TimerNotifier, rhs: TimerNotifier ) -> Bool { lhs.name == rhs.name } func hash(into hasher: inout Hasher) { hasher.combine(name) }

14:55

It certainly seems strange, but the alternative is basically impossible.

14:58

So, we now have a Hashable implementation on this object with behavior, but sadly it is very dangerous to use. It makes it all too easy to think two objects are equal when in reality they have very different behavior executing. And so then if you discard one of those objects, thinking that they are equal, you may accidentally discard behavior that you do not mean to.

15:21

To explore this, let’s write a test for the TimerNotifier : import Testing @Test func notifier() async throws { }

15:32

Let’s start by subscribing to the notification that is posted by the notifier so that we can count how many times it fires: var timerTickCount = 0 let cancellable = NotificationCenter.default .publisher(for: Notification.Name("TimerNotifier-main")) .sink { _ in timerTickCount += 1 }

16:16

Next let’s create a set of notifiers that already holds onto one object: var notifiers = Set([TimerNotifier(name: "main")])

16:25

Then, in a new lexical scope: do { } …which we can think of as being some other part of our application, we will create a new notifier, start it, and add it to the set of notifiers: do { let notifier = TimerNotifier(name: "main") notifier.startTimer() notifiers.insert(notifier) }

17:02

Then, we will wait for a little more than a second, and we expect that the timerTickCount variable should have increased to 1: try await Task.sleep(for: .seconds(1.1)) #expect(timerTickCount == 1) _ = cancellable

17:18

But sadly it does not: Expectation failed: (timerTickCount → 0) == 1

17:21

This is happening because when we inserted our new notifier into the set: notifiers.insert(notifier)

17:49

…it was quietly discarded since the set thinks that notifier is a duplicate. Of course, it’s not really a duplicate because this new notifier has a timer that is running. But that doesn’t matter since the Hashable conformance cannot take into account all of the behavior happening on the inside of a reference type. This is pretty disastrous. Object identity

18:24

We have now seen that equality and hashability on references is really, really messy. Even the most conservative version of equality on a class can lead to things like sets and dictionaries being completely unusable. And further, for classes with behavior, it can lead us into a situation where we think we are dealing with equal objects, but secretly there is behavior executing in the background that is not captured in its equality. And so if you discard one of the objects because you think it’s the same, you will also be discarding the behavior too, and that may not be very clear the call site.

18:59

In reality, it is impossible to write well-defined functions that involve classes with mutable state or behavior. Or maybe more to the point: discussing “well-definedness” or “substitutability” no longer even makes sense when discussing reference types. Because they can be mutated from any lexical scope, and even any thread, it’s possible to have to references that are “equal” at one moment, and then on the very next line of code they are suddenly unequal. Stephen

19:23

So maybe we should just throw our hands up and say equality and hashability on references just doesn’t make sense and so we should never do it. However, if that was the way to go, then Swift would probably just outlaw ever conforming a class to those protocols.

19:35

There are times where Equatable and Hashable can make sense for classes, but there is really only one way to do it correctly. And that is to use the object identity of the reference.

19:44

So, let’s take a look at that.

19:47

There is a special type in the Swift standard library that represents the unique identity of references and metatypes: ObjectIdentifier A unique identifier for a class instance or metatype.

19:57

This notion of identity only makes sense for references and metatypes. Because value types, such as structs, enums and tuples can be copied around without a care in the world, and the fact the number of copies made is determined by the compiler, you could never have any notion of unique identity for those types.

20:12

We can construct one of these identifiers for some of our UserRef objects: ObjectIdentifier(blob) // ObjectIdentifier(0x600003a078a0) ObjectIdentifier(blobJr) // ObjectIdentifier(0x600003a077e0)

20:21

And clearly these identifiers are distinct.

20:27

And further, these identifiers remain distinct even after mutating state inside the references: ObjectIdentifier(blobJr) // ObjectIdentifier(0x600003a077e0) blobJr.name = "Blob II" ObjectIdentifier(blobJr) // ObjectIdentifier(0x600003a077e0)

20:48

You may also be aware of a triple-equal reference equality checking operator in Swift: blob === blob // true blob === blobJr // false

21:06

This operator is defined in terms of ObjectIdentifier , which we can see by looking at the source code in the Swift standard library: @inlinable // trivial-implementation public func === (lhs: AnyObject?, rhs: AnyObject?) -> Bool { switch (lhs, rhs) { case let (l?, r?): return ObjectIdentifier(l) == ObjectIdentifier(r) case (nil, nil): return true default: return false } }

21:20

This relation is an equivalence relation: It is reflexive, which means every reference is equal to itself. It is symmetric, which means if reference a is equal to reference b , then b is equal to a . And it is transitive, which means if reference a is equal to reference b , and reference b is equal to reference c , then a is equal to c .

21:46

This form of equality checking is by far the most strict. It is even more strict than structural equality that we discussed previously for structs, where you must compare every single field held in a data type for equality. That form of equality means each equivalence class holds all the values whose internal data is identical. But, at the end of the day, you can still create multiple distinct values that just happen to have the same data: let blob = User(id: 42, name: "Blob") let blob2 = User(id: 42, name: "Blob") blob == blob2 // true We create two distinct copies of this data, but at the end of the day they are equal because structurally they are equal. That is, all of the data held inside is equal.

22:17

But this is not true of referential equality. We can create two UserRef objects with the exact same data, and yet they are still not equal as references: let blob = UserRef(id: 42, name: "Blob") let blob2 = UserRef(id: 42, name: "Blob") blob === blob2 // false

22:28

And so the equivalence class of a reference with triple-equals is truly just the one single reference. Really just a pointer to a memory address somewhere on the device.

22:54

And this form of equality is absolutely legitimate for classes and has none of the problems we have discussed previously. Let’s create a whole new version of UserRef , but this time we will have the Equatable and Hashable conformances delegate down to referential identity instead of structural identity: class UserRefCorrect: Hashable { var id: Int var isAdmin = false var name: String init(id: Int, isAdmin: Bool = false, name: String) { self.id = id self.isAdmin = isAdmin self.name = name } static func == ( lhs: UserRefCorrect, rhs: UserRefCorrect ) -> Bool { lhs === rhs } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } }

23:27

It’s a simple change to the class, and amazingly fixes all of our problems. Let’s see this concretely.

23:35

Let’s create a few users to play around with, and stick them in a set: do { let blob = UserRefCorrect(id: 42, name: "Blob") let blobJr = UserRefCorrect(id: 43, name: "Blob Jr") var userRefs = Set([blob, blobJr]) }

23:52

We can ask the set if it contains those users, and of course it does: userRefs.contains(blob) // true userRefs.contains(blobJr) // true

24:01

We can also mutate one of the user references and confirm that the set does contain that updated user: blobJr.name = "Blob II" userRefs.map(\.name) // ["Blob", "Blob II"]

24:16

But, unlike last time when we defined structural equality on the class, the set still correctly recognizes that it contains both users: userRefs.contains(blob) // true userRefs.contains(blobJr) // true

24:29

This is because we are only using the referential identity for hashing and equality checking, and that has not changed at all. Whereas previously, when using structural equality and hashing, we changed the hash of the user behind the scenes without the set knowing, and so it no longer could determine if it contained that user or not.

24:46

This is of course an extremely strict version of equality, where two users will be equal only if they are truly the exact same reference in memory. But it’s also the only reasonable choice. It’s the only thing that makes working with sets and dictionaries behave the way we expect.

24:59

It also fixes the problems we saw with the TimerNotifier class. Let’s update that class’s conformance to use object identity instead of the data held inside the class: final class TimerNotifier: Hashable { … static func == ( lhs: TimerNotifier, rhs: TimerNotifier ) -> Bool { lhs === rhs } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } }

25:46

With that one small change our test suite is not possible. Because the equality of a notifier is determined only by its object identity, when we insert the notifier into set: notifiers.insert(notifier) …it succeeds in doing so, and that allows that notifier to post a notification, which then makes the timerTickCount variable increment. So for the TimerNotifier this is a far safer implementation of its Hashable conformance.

26:01

So, if using a class’s referential identity is the best way to implement the Equatable and Hashable protocols, why didn’t Swift choose to automatically synthesize that conformance for references? Well, we’re not really sure! As we’ve seen in this series, equality is very tricky and easy to get wrong, even for structs but especially for classes, and so maybe they wanted to avoid the complexity and perhaps even discourage people from trying to conform their classes to the protocols. Swift Data

26:29

But, there is some evidence that lets us know that even Apple and Swift agree that referential equality and hashing is almost always the only reasonable choice. First, if we think back to Objective-C days, nearly every class inherits from NSObject , the magic base class that provides all kinds of fancy runtime functionality, such as dynamic message passing, key-value observing, and more.

26:50

For example, if we create two blank view controllers we will see that they are indeed equatable, so we can compare them: import UIKit UIViewController() == UIViewController() // false

27:03

…but they are not equal. And they will never be equal, regardless of what data they hold on the inside. This is because they are only compared as references, and don’t look at the data inside at all. This equality check is coming from the NSObject base class, which has decided to default to using referential identity for equality checking.

27:19

There’s another piece of evidence that let’s us know referential identity is the only reasonable way to equate or hash two objects: Swift Data. All models defined in Swift Data are Hashable , which we can confirm by defining a very basic model: import SwiftData @Model class Item { var name: String init(name: String) { self.name = name } }

27:54

And if we expand the macro we will see it does a lot of work, but in particular it extends the class to the PersistentModel protocol: +extension Item: SwiftData.PersistentModel { +}

28:03

And if we look at the interface we’ll see that it inherits from the Hashable protocol. protocol PersistentModel: AnyObject, Observable, Hashable, Identifiable { … }

28:08

And while the @Model macro is doing a lot, it is not defining == or hash(into:) directly.

28:21

But our model compiles just fine, and this is because Swift Data is providing a default implementation of these protocols. But what implementation did they choose? Since these models primarily hold onto data, they are in spirit quite similar to value types. In fact, many of us were quite surprised when Swift Data was announced that classes were used at all. I think everyone hoped it would be a value type oriented framework.

28:40

So, if there was ever an argument for using structural equality for a class, we think Swift Models would probably be it!

28:45

But that is not how the protocols are implemented. The default implementation simply uses the referential identity, because as we have seen repeatedly, it’s the only choice that does not lead us to very strange, mystifying results.

28:56

To see this, let’s write some tests to explore how equality is defined on Swift Data models. In order to save and load this model from a database we need a ModelContext : let schema = Schema([ Item.self, ]) let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true ) let modelContainer = try ModelContainer( for: schema, configurations: [modelConfiguration] ) let context = ModelContext(modelContainer)

29:33

With that done we can create an item, insert it into the context, and save: let item = Item(name: "Blob") context.insert(item) try! context.save()

29:45

Then let’s use the same context to fetch the item that was just saved: let fetchedItem = try context .fetch(FetchDescriptor<Item>()) .first!

30:07

And let’s see if it’s equal to the item we just created: #expect(fetchedItem == item)

30:19

It is, so that’s not super surprising. But what may be a little surprising is that the fetched item is actually the exact same reference: #expect(fetchedItem === item)

30:31

So even they we are querying for an item, Swift Data was smart enough to see that the model being fetched already exists as a reference, and so just returned that rather than creating a new reference to represent the item.

30:40

But now let’s create a new lexical scope and create a new context in there: do { let context = ModelContext(item.modelContext!.container) }

30:52

This is a fresh context, and so shouldn’t be able to reuse the item reference that has already been fetched. So, let’s fetch the first item again, but using this new context: let fetchedItem = try context .fetch(FetchDescriptor<Item>()) .first!

31:08

This gives us a new item that for all intents and purposes seems to be the same item as fetched before. It has the same name: #expect(fetchedItem.name == item.name)

31:19

It even has the same persistent ID: #expect(fetchedItem.id == item.id)

31:34

However, it is not equal to the other items fetched: #expect(fetchedItem == item) #expect(fetchedItem === item)

31:38

And this is because the equality of these items is defined by their reference, and the newly fetchedItem is not the same reference as previously fetched. Next time: Advanced hashable

31:47

So this proves that Swift Data has chosen to use referential identity for the Equatable and Hashable conformance of models, and we think this is the clearest sign that referential equality is typically the correct choice for classes. Even Swift Data models do not use structural equality, and they are classes that primarily represent simple data values held in some external store. So, if they can’t use structural equality, what hope do we have for other classes? Brandon

32:09

There’s another situation where referential equality is the only way to make a reference Equatable , but it has to do with actors instead of classes. If you didn’t know, actors are reference types, but its stored properties are all protected by an isolation domain that requires you to await to access and mutate the properties if you are in a different isolation domain.

32:33

Let’s take a look…next time! References Hashable Note A type that can be hashed into a Hasher to produce an integer hash value. Documentation for the Swift protocol. https://developer.apple.com/documentation/swift/equatable Equatable Note A type that can be compared for value equality. Documentation for the Swift protocol. https://developer.apple.com/documentation/swift/equatable Equivalence relation Note In mathematics, an equivalence relation is a binary relation that is reflexive, symmetric and transitive. The Wikipedia page defining an “equivalence relation,” a mathematical concept underpinning Swift’s Equatable protocol. https://en.wikipedia.org/wiki/Equivalence_relation Downloads Sample code 0299-back-to-basics-equatable-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .