*** Updated for Swift 4.1 ***
This is Part Two of a two-part article. Part One 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.
This part 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.
It would be useful to go through Part One before reading further to see what issues can result from trying to implement Equatable conformance or even just equality comparisons for protocols. Even if you are familiar with these issues, it would be useful to glance through the sample code in Part One since the code in this post is based on the protocols and concrete types introduced there.
Contents
Now that we have demonstrated (in Part One of this article) that it is not advisable and in some cases not even possible to implement equality for protocol types, let’s see if there is a way to address the issue. After all, we want to continue programming to abstractions rather than implementations, but we also want to be able to compare our abstract types for equality.
Self or associated type requirements
Let’s start by looking at the error we encountered when we tried to make our Fruit protocol conform to Equatable.
// error: protocol 'Fruit' can only be used as a generic constraint because it has Self or associated type requirements
We know all about the issues with having Self requirements from our experience with trying to conform to Equatable. But what are associated type requirements? The short answer is that associated types are used to make Swift protocols generic. This is a major topic and deserves its own post but the important point in this context is that protocols with associated types face the same issues that we encountered with Self requirements.
The good news is that in the case of protocols with associated types, the issue has largely been addressed through type erasure by creating a new concrete type that conforms to the protocol so can be used wherever a protocol type is expected. At the same time, it retains the generic character of the protocol.
What is type erasure
Type erasure, as the name suggests is a process by which a new type is created to encapsulate an instance of another type, to overcome some limitations of the original type and / or to enable client code to use the relevant features without having to deal with the specifics of the original type. The ‘relevant’ features would generally be determined by a protocol to which the original type conforms. The type eraser would also conform to the same protocol, implementing the requirements of the protocol and simply forwarding the operations to the encapsulated type. It is noteworthy that type erasure is not the same as casting, where the original type can be reclaimed simply by reversing the cast operation. In this case, the original type is completely encapsulated.
Type erasure is not a new concept. Swift has long had type erasers in the Standard Library. In fact, there is already a de facto naming convention that the Standard Library uses for type erasers, i.e., prefixing the name of the relevant protocol with ‘Any’. Examples from the Standard Library include AnySequence, AnyCollection, AnyHashable, etc. The purpose of these type erasers is to enable clients to use the desired functionality without having to deal with the specifics of the type being used. AnySequence, for instance, allows clients to use any type that conforms to the Sequence protocol without having to deal with the specifics of the underlying instance, which could be an array, a set, or a dictionary, among others.
What we are interested in is the other use case for type erasure, where it has been used to overcome the limitations of protocols with associated type requirements. This was demonstrated by Rob Napier in his talk at dotSwift 2016 and also in a post in his Cocoaphony blog.
Seems like a promising approach, so we forge ahead.
Type erasure in action
For our purposes, all we need is a simple wrapper type, a struct with a single initializer that takes one argument of the protocol type where conformance to Equatable is desired. The wrapper type would encapsulate the instance passed in through the constructor and all subsequent access to the methods and properties defined by the protocol would be through the methods and properties of the wrapper type, which would also conform to the protocol. We will simply save the instance passed in as a private property of the wrapper and use it to provide the required functionality to the wrapper type.
Methods called on the wrapper type would call the corresponding methods on the saved instance, passing any arguments and returning any values, as applicable. Computed properties would be similarly dealt with. For each stored property declared by the protocol, the wrapper type would have a computed property with a required get and an optional set block. For stored properties that are let constants, only the get block would be required, which would retrieve the value of the corresponding stored property of the stored instance. For var properties, a set block would also be required to set the value of the corresponding stored property of the wrapped instance.
Since the purpose of the exercise is to achieve Equatable conformance, it stands to reason that our wrapper types should only accept instances of concrete types that not only conform to the protocol in question but also conform to Equatable. Accordingly, and in keeping with the aforementioned naming convention for type erasers, we will use the prefix ‘Any’ and add the word ‘Equatable’. The general form for the names of such wrapper types would then be AnyEquatable<protocol name>.
Using the above approach, we create type erasing wrapper types for the two protocol types introduced in Part One of this post.
struct AnyEquatableFruit: Fruit {
init(_ fruit: Fruit) {
self.fruit = fruit
}
var weight: Int {
return fruit.weight
}
var grade: Int {
return fruit.grade
}
private let fruit: Fruit
}
struct AnyEquatableShape: Shape {
init(_ shape: Shape) {
self.shape = shape
}
func draw() {
shape.draw()
}
private let shape: Shape
}
As per our naming convention, we have called these types AnyEquatableFruit (for the Fruit protocol) and AnyEquatableShape (for the Shape protocol).
Laying the groundwork for Equatable conformance
Before we proceed to add Equatable conformance to our newly minted wrapper types, we need to make a small addition to the protocols themselves because we need a way to use the protocols to access the equality functions implemented for the conforming types. To achieve this, we declare a new isEqualTo() method in the protocol and provide a default implementation which only applies to conforming types that are Equatable. This will ensure that our wrapper types will only accept instances of concrete types that conform to Equatable.
protocol Fruit {
func isEqualTo(_ other: Fruit) -> Bool
var weight: Int { get }
var grade: Int { get }
}
extension Fruit where Self: Equatable {
func isEqualTo(_ other: Fruit) -> Bool {
guard let otherFruit = other as? Self else { return false }
return self == otherFruit
}
}
protocol Shape {
func draw()
func isEqualTo(_ other: Shape) -> Bool
}
extension Shape where Self: Equatable {
func isEqualTo(_ other: Shape) -> Bool {
guard let otherShape = other as? Self else { return false }
return self == otherShape
}
}
The isEqualTo(_:) method we have added does the following:
- The guard clause attempts to cast the instance passed in as the argument to the type represented by Self, which is the concrete type of the instance on which the method is called. If the cast fails, i.e., the instance being compared has a different concrete type, false is returned. This will ensure, for instance, that apples and oranges do not end up being equal.
- Once we are sure that the instance passed in as the argument has been successfully cast to the type of the instance on which the method has been called, the instance uses self (with a lowercase ‘s’) to compare itself for equality with the instance passed in as the argument. Since this extension only applies to concrete types that are also Equatable, the presence of an equality method is guaranteed.
The elusive Equatable conformance
Now for the piece de resistance — implementing Equatable conformance on our wrapper types. Since our wrapper types are structs and not protocols, there is no drama from the compiler and we are able to implement an equality function and Equatable conformance on both the wrapper types.
extension AnyEquatableFruit: Equatable {
static func ==(lhs: AnyEquatableFruit, rhs: AnyEquatableFruit) -> Bool {
return lhs.fruit.isEqualTo(rhs.fruit)
}
}
extension AnyEquatableShape: Equatable {
static func ==(lhs: AnyEquatableShape, rhs: AnyEquatableShape) -> Bool {
return lhs.shape.isEqualTo(rhs.shape)
}
}
The main point to note is that in each case the equality comparison is delegated to the underlying concrete type using the isEqualTo(_:) method already implemented on the protocol. This, as already explained, ensures that only instances of the same type can be considered equal and the equality function implemented on the underlying type is called, which can take into account all stored properties of the concrete type, not just those declared in the protocol (if any).
The test drive
First, we create some instances of our wrapper types which wrap instances of the corresponding concrete types.
let apple = AnyEquatableFruit(Apple(weight: 10, grade: 2))
let secondApple = AnyEquatableFruit(Apple(weight: 10, grade: 2))
let thirdApple = AnyEquatableFruit(Apple(weight: 15, grade: 2))
let orange = AnyEquatableFruit(Orange(weight: 10, grade: 2))
let appleFromAustralia = AnyEquatableFruit(ImportedApple(weight: 10, grade: 2, countryOfOrigin: "Australia"))
let appleFromSouthAfrica = AnyEquatableFruit(ImportedApple(weight: 10, grade: 2, countryOfOrigin: "South Africa"))
let rectangle = AnyEquatableShape(Rectangle(topLeftCorner: CGPoint(x: 5.0, y: 4.0), length: 10.0, width: 7.0))
let secondRectangle = AnyEquatableShape(Rectangle(topLeftCorner: CGPoint(x: 5.0, y: 4.0), length: 10.0, width: 7.0))
let thirdRectangle = AnyEquatableShape(Rectangle(topLeftCorner: CGPoint(x: 6.0, y: 4.0), length: 10.0, width: 7.0))
let circle = AnyEquatableShape(Circle(centre: CGPoint(x: 6.0, y: 8.0), radius: 10.0))
Note: If you feel the syntax above is a bit verbose, hold on to that thought. A more concise way to create wrapper types is presented in the Usage section just coming up.
Next we run equality tests and validate the results.
print(apple == secondApple) // true
print(apple == thirdApple) // false
print(apple == orange) // false
print(appleFromAustralia == appleFromSouthAfrica) // false
print(rectangle == secondRectangle) // true
print(rectangle == thirdRectangle) // false
print(rectangle == circle) // false
All the equality comparisons return the expected results. It is pertinent to note that our new approach has fixed the issues we had encountered when trying to implement equality functions directly on the protocols.
To make sure that we can use our wrapper types where the corresponding types are expected, we assign a couple of wrapper types to variables of the protocol types.
let someFruit: Fruit = orange
let someShape: Shape = circle
We also need to make sure that our wrapper type will work in situations where Equatable conformance is expected.
func equatableExpecter<T: Equatable>(_ first: T, _ second: T) -> Bool {
return first == second
}
print(equatableExpecter(apple, secondApple)) // true
print(equatableExpecter(rectangle, circle)) // false
We have created a function which takes two types that conform to Equatable. We then passed instances of our wrapper types to this function and checked the results to make sure things work as expected.
Usage
Since our wrapper types conform to the corresponding protocol, they can be used wherever the corresponding protocol type is expected. However, in actual usage, we would expect to use protocol types as we would normally and create wrapper types only for situations where Equatable conformance is required. To make this easier, we can add a convenience asEquatable() method to the protocol to enable wrapping without having to call the initializer of the wrapper type.
This is demonstrated below for the Fruit protocol.
protocol Fruit {
func asEquatable() -> AnyEquatableFruit
func isEqualTo(_ other: Fruit) -> Bool
var weight: Int { get }
var grade: Int { get }
}
extension Fruit where Self: Equatable {
func asEquatable() -> AnyEquatableFruit {
return AnyEquatableFruit(self)
}
func isEqualTo(_ other: Fruit) -> Bool {
guard let otherFruit = other as? Self else { return false }
return self == otherFruit
}
}
Let’s say we have some arrays of type Fruit.
let first: [Fruit] = [apple, orange, appleFromAustralia]
let second: [Fruit] = [apple, orange, secondApple]
let third: [Fruit] = [apple, orange, appleFromAustralia]
We can compare these arrays for equality by mapping them to arrays of the corresponding wrapper types and then invoking the equality function.
print(first.map({ $0.asEquatable() }) == second.map({ $0.asEquatable() })) // false
print(first.map({ $0.asEquatable() }) == third.map({ $0.asEquatable() })) // true
In the example above, we have created instances of the wrapper types only for the purpose of one equality comparison. We could also save the wrapper type instances if we foresee the need for repeated use. Since each wrapper type conforms to the corresponding protocol, instances of the wrapper type can freely be used wherever the corresponding protocol type is expected.
Variation
There are important semantic differences between value and reference types that determine when and how they should be used in application design. The approach presented above works for structs, which is a reasonable assumption since structs are value types characterized by attribute-based equality and they should always conform to Equatable. Classes, on the other hand, are reference types and should be used to model domain entities that have identity and a life cycle. Applying attribute-based equality to class instances may lead to erroneous results. This is why implementing Equatable conformance for classes may not be straightforward and is generally not advisable.
If, however, you do find yourself working with protocols and conforming classes that also conform to Equatable, you may have to use a modified version of the isEqualTo(_:) method presented above. This will be required when the two types being compared are a class and its subclass. For the Fruit protocol example, this would be the case, for instance, when Apple is a class and ImportedApple is its subclass. In such a scenario, the standard isEqualTo(_:) method, which simply checks whether one type can be cast to another, will produce incorrect results, as shown below.
class Apple: Fruit, Equatable { ... }
class ImportedApple: Apple { ... }
let apple: Fruit = Apple(weight: 10, grade: 5)
let importedApple: Fruit = ImportedApple(weight: 10, grade: 5, countryOfOrigin: "Australia")
print(apple.isEqualTo(importedApple)) // true
print(importedApple.isEqualTo(apple)) // true
This is because of the subtle change to Self when dealing with a class and its subclass(es). When a method is called on an instance typed as a protocol, Self represents the concrete type that conforms to the protocol. Normally, this would also be the concrete type of the instance on which the protocol method is called, i.e., the type of self. However, when we call a protocol method on an instance of a subclass whose superclass conforms to the protocol, the type of self is the subclass type but Self represents the superclass type.
This means that the otherFruit = other as? Self conditional cast in the isEqualTo(_:) method would become otherFruit = other as? Apple, regardless of whether the method is called on an instance of Apple or ImportedApple. The isEqualTo(_:) method will, therefore, return true in both cases as long as both the instances have equal values for the weight and grade properties, ignoring the countryOfOrigin property.
This can be remedied by modifying the guard clause in isEqualTo(_:) to include an explicit check that the concrete type of other is the same as the type of the instance on which the method has been called, proceeding to the cast operation only if the concrete types are the same. The modified isEqualTo(_:) method will have to be implemented in each class and subclass.
The isEqualTo(_:) method in Apple would be as follows.
func isEqualTo(_ other: Fruit) -> Bool {
guard type(of: other) == Apple.self, let otherFruit = other as? Apple else { return false }
return self == otherFruit
}
In ImportedApple, it would be as below.
override func isEqualTo(_ other: Fruit) -> Bool {
guard type(of: other) == ImportedApple.self, let otherFruit = other as? ImportedApple else { return false }
return self == otherFruit
}
With the modified isEqualTo(_:) methods implemented in the classes, we get the correct result and normal service resumes.
print(apple.isEqualTo(importedApple)) // false
print(importedApple.isEqualTo(apple)) // false
It is noteworthy that the test for type equality is required in the isEqualTo(_:) method in Apple. Without it, a comparison between an instance of Apple and an instance of ImportedApple would break symmetry. This is because a conditional cast to Apple would succeed when other is an instance of ImportedApple but the reverse would not be true. This would be the case for an instance of any class when other is an instance of its subclass. A test for type equality is not actually required in the isEqualTo(_:) method in ImportedApple since it does not have any subclasses. However, it is prudent to implement the method with the type equality test in all classes, in case we subclass any of them at a later stage.
Conclusion
In Part One of this article, we demonstrated 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.
In this part, we have walked though an alternate approach to addressing the issue, using type erasure by creating wrapper types that conform to the corresponding protocol and also conform to Equatable. The equality function we implemented on our wrapper types returns false if the underlying concrete types of the instances being compared are not the same and, given two instances of the same concrete type, it delegates the equality comparison to the concrete type. This allows us to program to abstractions using protocol types while being able to safely make equality comparisons at the protocol level and also use functionality provided by the Swift Standard Library which is available only to types that conform to Equatable.
Acknowledgements
Thanks to:
- Rob Napier for the original work on using type erasure for protocols with associated types.
- Stephane Philipakis for suggesting the name asEquatable() for the convenience methods that enable wrapping without having to call the initializer of the wrapper type.
- Heath Borders for pointing out the potential problem with the isEqualTo(_:) method when dealing with class instances. This led to addition of the Variation section in this article.
Jimmy says
This is great, I’ve wished for an elegant way to deal with equatable conformance and abstractions, and erasure seems like an effective way to sidestep the whole issue.
Werner Freytag says
Thanks for this very useful article. It is even possible to make this solution completely generic to handle any object that complies to Equatable (or provides an “isEquatable” method). See my gist at https://gist.github.com/werner-freytag/e4f1fdcd201db1cdc726f6d81d55aa18
Ferran Pujol Camins says
You can an add an overloaded init to the type wrapper to avoid unnecessary nested wrappers:
init(_ anyFruit: AnyEquatableFruit) {
self.fruit = anyFruit.fruit
}
Rizul Sharma says
Hi, can anyone supply an example for classes please, Thanks
Khawer Khaliq says
Hi Rizul, thanks for your comment. Although Equatable conformance should normally not be added to classes because classes are reference types and comparing them using attribute-based equality can lead to erroneous results, the Variation section in the article does talk about cases where you may be dealing with classes that conform to Equatable.