Identifying objects in Swift
We often encounter situations when we need to find a way to store objects based on some concept of identity. Whether it's in a cache, storing representations of objects on disk, or simply using a dictionary - we often need to find ways to uniquely identify objects that we deal with.
This week, let's take a look at some common concepts of identity that we have at our disposal in Swift, and how we can use them in different ways for values and objects.
Equatable
A core protocol that is often used to compare objects and values is Equatable
. This is a protocol that many of you are probably already familiar with, since anytime you want to enable the ==
operator to be used with a type, you need to conform to it. Here's an example:
struct Book {
let title: String
let author: String
}
extension Book: Equatable {
static func ==(lhs: Book, rhs: Book) -> Bool {
guard lhs.title == rhs.title else {
return false
}
guard lhs.author == rhs.author else {
return false
}
return true
}
}
Note that the above manual implementation of ==
isn’t really required anymore, since the compiler automatically synthesizes conformance to Equatable
for any type that only has equatable properties (like our above Book
type does).
Instance equatable
While Equatable
is perfect for when dealing with values (like structs
or enums
), for objects/classes
it might not be what you're looking for. Sometimes you want to check if two objects are the same instance. To do this, we use ==
's lesser known sibling; ===
, which lets you compare two objects based on their instance, rather than their value.
Let's take a look at an example where we want to reload an InventoryManager
every time it gets assigned a new dataSource
:
// Protocols with the 'AnyObject' constraint can only be conformed to
// by classes, enabling us to assume that an object will be used.
protocol InventoryDataSource: AnyObject {
var numberOfItems: Int { get }
func item(at index: Int) -> Item
}
class InventoryManager {
var dataSource: InventoryDataSource? {
// 'oldValue' is a magic variable that always contains the
// previous value of the property when a new one is set
didSet { dataSourceDidChange(from: oldValue) }
}
private func dataSourceDidChange(from previousDataSource: InventoryDataSource?) {
// We don't want to reload if the same data source is re-assigned
guard previousDataSource !== dataSource else {
return
}
reload()
}
}
As you can see above, using ===
can be pretty neat, since it enables you to perform checks without requiring a type conforming to a certain protocol to also implement Equatable
(which adds boilerplate, and also self-constraints the protocol - more on the latter in an upcoming post).
Hashable
Just like Equatable
, another protocol that is super common to conform to when working with value types is Hashable
. This is a requirement whenever you are using a type in some form of hash-based collection, like a Set
, or as a key in a Dictionary
.
Let's extend our Book
type from before to also conform to Hashable
:
extension Book: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(title)
hasher.combine(author)
}
}
Again, the compiler is able to automatically synthesize the above conformance if all of a type’s properties also conform to Hashable
.
We are now able to, for instance, build a collection of only unique books:
class BookStore {
var inventory = [Book]()
var uniqueBooks = Set<Book>()
func add(_ book: Book) {
// Inventory will contain all books, including duplicates
inventory.append(book)
// By using a set (which we can, now that 'Book' conforms to 'Hashable')
// we can guarantee that 'uniqueBooks' only contains unique values
uniqueBooks.insert(book)
}
}
Protocols and Hashable
While Hashable
is easy to use (although it can become a bit boilerplate-ish to implement) when dealing with concrete types (such as in the BookStore
example above), it can become a bit tricky when dealing with protocols.
One thing to keep in mind when using hash values, is that you can only rely on them when you know that all of your objects or values are of the exact same type. Since this is not the case with protocols, we have to rely on some other method.
Let's say we're building a rendering API, where different objects can request to be rendered the next time the screen draws a frame. To use this API, objects conform to a Renderable
protocol, and uses a Renderer
to enqueue themselves for rendering when needed (similar to how UIView
has a setNeedsLayout
method), like this:
class Circle {
var radius: CGFloat {
didSet { renderer?.enqueue(self) }
}
var strokeColor: UIColor {
didSet { renderer?.enqueue(self) }
}
weak var renderer: Renderer?
}
extension Circle: Renderable {
func render(in context: CGContext) {
context.drawCircle(withRadius: radius, strokeColor: strokeColor)
}
}
As an optimization, we want to make sure that we're only rendering each object once per frame, even though it might request rendering multiple times before a frame is drawn. To make this happen we'll need to keep track of unique instances conforming to Renderable
that have been enqueued, but since those instances are probably going to be of completely different types, we can't just add Equatable
or Hashable
as a requirement.
ObjectIdentifier
One solution to the above problem, is to use Swift's ObjectIdentifier
type to identify instances, and to make sure that our rendering queue won't contain duplicates.
In Swift, every instance of a class has a unique identifier that you can obtain by creating an ObjectIdentifier
for it, like this:
func enqueue(_ renderable: Renderable) {
let identifier = ObjectIdentifier(renderable)
}
ObjectIdentifier
already conforms to both Equatable
and Hashable
, which enables us to write a thin wrapper type around Renderable
that uses each instance's identifier to provide it with identity:
class RenderableWrapper {
fileprivate let renderable: Renderable
init(renderable: Renderable) {
self.renderable = renderable
}
}
extension RenderableWrapper: Renderable {
func render(in context: CGContext) {
// Forward the call to the underlying instance
renderable.render(in: context)
}
}
extension RenderableWrapper: Equatable {
static func ==(lhs: RenderableWrapper, rhs: RenderableWrapper) -> Bool {
// Compare the underlying instances
return lhs.renderable === rhs.renderable
}
}
extension RenderableWrapper: Hashable {
func hash(into hasher: inout Hasher) {
// Use the instance's unique identifier for hashing
hasher.combine(ObjectIdentifier(renderable))
}
}
We can now simply use a Set
to keep track of unique instances that need rendering in our Renderer
:
class Renderer {
private var objectsNeedingRendering = Set<RenderableWrapper>()
func enqueue(_ renderable: Renderable) {
// Since we use a 'Set', duplicates will automatically be
// discarded, we don't have to write any code for it
let wrapper = RenderableWrapper(renderable: renderable)
objectsNeedingRendering.insert(wrapper)
}
func screenDidDraw() {
// Start by emptying the queue
let objects = objectsNeedingRendering
objectsNeedingRendering = []
// Render each object
for object in objects {
let context = makeContext()
object.render(in: context)
}
}
}
Conclusion
When using values, Equatable
and Hashable
is probably the way to go in terms of identity - since you are more comparing a normalized representation of a value, rather than a unique instance. However, when dealing with objects, using some of the techniques from this post can make your APIs easier to use, and in result decrease complexity and increase stability.
Instead of requiring implementors to conform to Equatable
, or exposing some form of unique identifier (like a UUID
), you can use techniques like the ===
operator and the ObjectIdentifier
type to quickly and uniquely identify objects without much extra code.
What do you think? Do you have any other favorite techniques when it comes to dealing with object identity in Swift? Let me know, along with any questions or feedback that you might have - on Twitter @johnsundell.
Thanks for reading 🚀