Posted 7/1/2018.
The Hashable
protocol in the Swift Standard Library allows us to use our own custom types as a key in a dictionary or as a member of a set. Conforming to Hashable
where appropriate can make our code safer and improve performance. However, it's important to understand how Hashable
and Equatable
work together in dictionary lookups and when determining set membership in order to avoid unintended behavior.
First, let's look at an implementation of Hashable
with a value type, for example a struct that holds an RGB color value:
struct RGBColor: Hashable {
let red: Double
let green: Double
let blue: Double
let alpha: Double
static func ==(lhs: RGBColor, rhs: RGBColor) -> Bool {
return lhs.red == rhs.red && lhs.green == rhs.green && lhs.blue == rhs.blue
}
var hashValue: Int {
return red.hashValue ^ green.hashValue ^ blue.hashValue ^ alpha.hashValue
}
}
This is pretty standard. The Equatable
conformance checks that each component value is identical. To compute the hash value, we just XOR each component value. In fact, as of Swift 4.1 default implenetations of Hashable
and Equatable
are automatically synthesized for value types (structs and enums), so we no longer need to write the conformances ourselves in most cases. Taking advantage of automatic synthesis is recommended, since Swift uses a better hashing function than the common XOR approach used here.
Let's see how this works with Swift sets:
let gray = RGBColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0)
let identicalGray = RGBColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0)
let colors: Set<RGBColor> = [gray]
print(colors.contains(identicalGray))
This code should print true
. So why does Hashable
conform to Equatable
? The answer is to prevent collisions. Let's take two different colors, gray with 50% alpha and white:
let gray = RGBColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 0.5)
let white = RGBColor(red: 1, green: 1, blue: 1, alpha: 1)
The equality check should print false
:
print(gray == white)
However, the following will print true
:
print(gray.hashValue == white.hashValue)
Our simple XOR approach results in hash values of 0 for both colors. If Hashable
simply relied on the computed hash value property to determine set membership, this collision would result in unintended behavior. This is also why I recommended using Swift's new autosynthesis of Hashable
conformance if you're working with value types since the default hashing function is more reliable.
We saw above the potential pitfalls of Hashable
, but in practice you'll rarely run into issues when working with basic values. With values like RGB colors, geometric points, geographic coordinates, etc. we usually want the default implementation, where all component values are checked for equality and incorporated into the hashing function. However, what about the common case where you have a client-server application and are fetching updates to data you have locally?
For example, consider a Comment
class:
class Comment: Hashable {
let id: String
let text: String
let likeCount: Int
init(id: String, text: String, likeCount: Int) {
self.id = id
self.text = text
self.likeCount = likeCount
}
var hashValue: Int {
return id.hashValue
}
static func == (lhs: Comment, rhs: Comment) -> Bool {
return lhs.id == rhs.id && lhs.text == rhs.text && lhs.likeCount == rhs.likeCount
}
}
Here we are representing a comment with three component values: a unique identifier, the text of the comment, and its like count (in billions). This is a standard implementation for an object with identity. Since Comment
has a unique identifier, it makes sense to use that to compute the hash value. At the same time, we may reasonably decide to only treat two Comment
instances as equal if all component values are equal.
So where does this go wrong? Let's say you have a comment locally and fetch an update from the server:
let id = UUID().uuidString
let text = "Swift is the greatest programming language in the world."
let comment = Comment(id: id, text: text, likeCount: 1)
let updatedComment = Comment(id: id, text: text, likeCount: 2)
You also have a set containing the now outdated comment and check if it contains the updated version:
let comments: Set<Comment> = [comment]
print(comments.contains(updatedComment))
This will print false
. This may be what you want. However, if we had assumed that the hash value was all that mattered and expected this code to print true
, we would see unexpected behavior in our app.
When done correctly, conforming our custom types to Hashable
can make our code safer and more efficient. However, it's important to understand the relationship between Hashable
and Equatable
and to think carefully about what equality means for each type. With basic values we can almost always take advantage of autosynthesis, as Swift's default implementations will generally do what we want. When dealing with reference types, we need to be more careful. The concept of equality for objects is more complicated, and we need to be deliberate about the behavior we want.