*** Updated for Swift 4.1 ***
This is Part One of a two-part article. This part demonstrates how making protocols conform to Equatable renders us unable to use the protocols as types and why trying to implement equality functions on protocols, even without Equatable conformance, can lead to erroneous results in some cases and is impossible in others.
Part Two uses type erasure to implement equality comparisons and Equatable conformance at the protocol level. This allows us to program to abstractions using protocol types while being able to safely make equality comparisons and also use functionality provided by the Swift Standard Library which is available only to types that conform to Equatable.
Contents
In a landmark WWDC 2015 talk titled “Protocol-oriented Programming in Swift” a.k.a. “the Crusty talk”, Dave Abrahams declared that Swift is a Protocol-oriented programming language. I would leave the details of how this differs from traditional object-oriented programming for another post. In essence, this entails using protocols to represent abstractions and favouring value types over reference types, unless the entity you are modeling specifically requires reference semantics.
Another piece of advice that came along with this new paradigm is to make all value types Equatable, or to be more exact, conform to the Equatable protocol. This makes sense as the concept of attribute-based equality is one of the defining characteristics of values, as we see for values we commonly use such as integers, decimals, strings, etc.
Fruit, anyone?
Let’s say we are working in a domain dealing with fruit. To keep things simple, let’s say that each piece of fruit has two characteristics, its weight and grade, both integer values. To keep things nice and abstract and to avoid having to deal directly with concrete types, we declare a protocol.
protocol Fruit {
var weight: Int { get }
var grade: Int { get }
}
Next we declare two concrete types representing apples and oranges respectively, both conforming to our protocol.
struct Apple: Fruit, Equatable {
let weight: Int
let grade: Int
}
struct Orange: Fruit, Equatable {
let weight: Int
let grade: Int
}
Note that we have also declared Equatable conformance for both our concrete types. This means that any two apples or oranges having equal weights and grades will be considered equal.
Programming to interfaces, not implementations
This age-old advice originated in the world of object-oriented programming and applies equally in the protocol-oriented realm. This is good programming practice in that it makes code more flexible, extensible and less brittle. The consequence of programming to interfaces is that most of our code would deal in abstract fruits, rather than concrete apples and oranges.
func doSomethingWithFruit(fruit: Fruit) {}
var storeFruit: [Fruit] = []
The actual creation of concrete instances would presumably be handled by factory methods which return the instances typed as Fruit so we can happily program to the abstraction rather than worrying about which concrete type of fruit we are dealing with.
At some point, though, we are going to have to compare two instances for equality. Now, both our concrete types implement Equatable, so had we been dealing with the concrete types, comparing for equality would have been a cinch. However, what we are dealing with are instances typed as Fruit.
No sweat. We will simply implement an equality function for our protocol and modify our protocol definition to add conformance with Equatable.
protocol Fruit: Equatable {
var weight: Int { get }
var grade: Int { get }
}
func ==(lhs: Fruit, rhs: Fruit) -> Bool {
return lhs.weight == rhs.weight && lhs.grade == rhs.grade
}
It is noteworthy that, unlike concrete types, protocols do not automatically synthesize Equatable conformance by simply declaring it in the protocol definition. This should perhaps be the first hint that this is not something we should be doing.
Protocols with Self requirement
Sure enough, the moment we add Equatable conformance to our protocol, things start to go awry. All our functions, arrays, etc., that use the Fruit protocol as a type show the following error:
error: protocol 'Fruit' can only be used as a generic constraint because it has Self or associated type requirements
This error looks rather cryptic at first, but becomes easy to understand once we inspect the requirements of the Equatable protocol at bit more closely.
static func ==(Self, Self)
Self in the above method signature refers to the concrete type of the actual instances being compared and means that the concrete type of both the arguments must be the same. This is okay for structs or classes but cannot be guaranteed for protocols since there could be any number of concrete types which may adopt a given protocol and we cannot guarantee that any two instances being compared will always have the same concrete type. Swift deals with this issue by ensuring that any protocol that conforms to Equatable can no longer be used as a type.
Compromises, compromises
Now we are at an impasse. We either let go of Equatable conformance for our protocol or we will not be able to use our protocol as a type.
Equatable conformance is important to have because a host of functionality provided by the Swift Standard Library relies on Equatable conformance. For instance:
- If we want to check two arrays for equality, their elements need to conform to Equatable. The same is true if we want to check whether an array contains a given value, without having to provide a closure to determine equivalence.
- The Comparable protocol, used to sort elements of a collection, inherits from Equatable.
- The Hashable protocol, to which keys of a Dictionary and members of a Set must conform, inherits from Equatable.
Having said that, the main benefit of working with protocols is that they allow us to create behavior-rich abstractions that can make our code more decoupled and more easily extensible. Therefore, inability to use our protocol as a type is a show-stopper. So we bite the bullet and remove Equatable conformance from our protocol. This gets rid of the errors and we get to keep our equality function. We also add a function to ensure that we can compare any two instances types as Fruit for inequality.
func !=(lhs: Fruit, rhs: Fruit) -> Bool {
return !(lhs == rhs)
}
Note that an inequality function gets synthesized automatically for types that conform to Equatable.
Does this really make sense!
Armed with our equality function, we do some testing.
let apple: Fruit = Apple(weight: 10, grade: 2)
let secondApple: Fruit = Apple(weight: 10, grade: 2)
let thirdApple: Fruit = Apple(weight: 15, grade: 2)
let orange: Fruit = Orange(weight: 10, grade: 2)
print(apple == secondApple) // true
print(apple == thirdApple) // false
print(apple == orange) // true
The first two results are expected but the third literally equates apples and oranges! This is because the equality method of the protocol is simply checking the values of the properties weight and grade regardless of the concrete types of the instances being compared. We could use generics to ensure that only instances of the same type can be compared for equality.
func ==<T: Fruit>(lhs: T, rhs: T) -> Bool {
return lhs.weight == rhs.weight && lhs.grade == rhs.grade
}
But this does not help as it can only be used to compare for equality instances of types that conform to protocol Fruit, not instances typed as Fruit. Instead, we can solve the problem of comparing apples with oranges by modifying the equality function presented earlier to add a guard clause.
func ==(lhs: Fruit, rhs: Fruit) -> Bool {
guard type(of: lhs) == type(of: rhs) else { return false }
return lhs.weight == rhs.weight && lhs.grade == rhs.grade
}
Not very elegant, but at least now apples are no longer equal to oranges.
print(apple == orange) // false
Let’s assume that we now have to cater not just to apples sourced locally but also to apples imported from other countries. So we add a new type to represent imported apples which has a property to identify the country of origin. Imported or not, apples are fruit so our new type also conforms to the Fruit protocol.
struct ImportedApple: Fruit, Equatable {
let weight: Int
let grade: Int
let countryOfOrigin: String
}
Note that the automatically synthesized Equatable conformance for ImportedApple will take into account the country of origin in addition to the weight and grade. This will ensure, for instance, that apples imported from Australia do not end up being equal to those imported from South Africa.
However, when we have imported apples typed as Fruit, which is the likely scenario since we prefer to write code in terms of abstractions, we would have the following result.
let appleFromAustralia: Fruit = ImportedApple(weight: 10, grade: 2, countryOfOrigin: "Australia")
let appleFromSouthAfrica: Fruit = ImportedApple(weight: 10, grade: 2, countryOfOrigin: "South Africa")
print(appleFromAustralia == appleFromSouthAfrica) // true
This illustrates just one one way in which adding a new concrete type which conforms to the same protocol but adds a type-specific property could lead to erroneous results being given by the equality function written in terms of the protocol type. And this is just a trivial example. In a domain of any complexity, it would be difficult to keep track of or even to anticipate all the errors that may be introduced by protocol-level equality comparisons.
Protocols are meant to be about behavior, aren’t they?
Yes, they are. Protocols are the tools for building rich behavioural abstractions, leaving implementation details to the concrete conforming types. Therefore, it is common to find protocols that only declare methods and no properties.
Here is an example of a protocol representing a shape that knows how to draw itself. The protocol does not say anything about what the shape should be or what data conforming types may store to be able to draw themselves, only that types that conform must implement a method to draw themselves.
protocol Shape {
func draw()
}
Any type representing a shape that knows how to draw itself can conform to this protocol. To illustrate, we create two concrete types, one representing a rectangle and the other representing a circle.
struct Rectangle: Shape, Equatable {
func draw() { ... }
let topLeftCorner: CGPoint
let length: Double
let width: Double
}
struct Circle: Shape, Equatable {
func draw() { ... }
let centre: CGPoint
let radius: Double
}
Now then, how do we apply a protocol-level equality comparison when the protocol does not declare any properties? The short answer is that we can’t. Each conforming type can be Equatable (in fact both our concrete types are) but we don’t have a way of checking equality at the level of the protocol since the protocol does not declare any properties.
Conclusion
Having worked through these examples and seen that trying to implement equality for protocols is fraught with possibilities of errors in some cases and just not possible in others, we can appreciate why protocols that are to be used as types cannot conform to Equatable. We could work around this limitation by sacrificing Equatable conformance in favour of a simple equality function, but it would likely produce correct results only under carefully controlled conditions and cannot be trusted to work correctly in real-world domains. Besides, this approach completely falls apart in cases where the protocol does not declare any properties.
This concludes Part One of this article. Do check out Part Two, which uses type erasure to implement equality comparisons and Equatable conformance at the protocol level. This allows us to program to abstractions using protocol types while being able to safely make equality comparisons and also use functionality provided by the Swift Standard Library which is available only to types that conform to Equatable.
Ankur says
Thank you so much for putting it out so beautifully.
Khawer Khaliq says
Thanks Ankur. Glad that you liked the article.
Alexey says
Java people live with Object.equals instance method for years. And that would work in Swift too. Having all this complexity for such a strict type checking in Swift just isn’t required in solving daily tasks. It gives more headaches instead.