Implementing Equatable on Enumerations with non-equatable associated types
Summary
To implement Equatable
on an enumeration with non-Equatable
associated values, you can leverage String(reflecting:)
as the basis for checking equality. This can be applied via a convenience ReflectiveEquatable
protocol (see below).
Details:
While as mentioned in some of the other answers, Swift can apply Equatable
to enums with cases having associated values provided those values are themselves Equatable
.
For instance, in the below example, since String
already conforms to Equatable
, the compiler can synthesize equality for the Response
enum by simply decorating it with the Equatable
protocol...
enum Response: Equatable {
case success
case failed(String)
}
However, this one will not compile because the associated type Error
is not itself Equatable
, thus the compiler can't synthesize equality for us...
enum Response: Equatable {
case success
case failed(Error)
}
More frustratingly, you can't manually conform Error
to Equatable
as Error
is a protocol, not a type, and you can only add conformance to actual types, not protocols. Without knowing the actual types Error
is applied to, there's no way for you to check for equality.
Or is there?! ;)
Implement Equality via String(reflecting:)
The solution I propose is to leverage String(reflecting:)
to implement equality. Reflection works recursively through all nested and associated types, resulting in a unique string that we ultimately use for the equality check.
This capability can be implemented in a reusable fashion via a custom ReflectiveEquatable
protocol, defined as such...
// Conform this protocol to Equatable
protocol ReflectiveEquatable: Equatable {}
extension ReflectiveEquatable {
var reflectedValue: String { String(reflecting: self) }
// Explicitly implement the required `==` function
// (The compiler will synthesize `!=` for us implicitly)
static func ==(lhs: Self, rhs: Self) -> Bool {
return lhs.reflectedValue == rhs.reflectedValue
}
}
With the above in place, you can now conform the Response
enum to ReflectiveEquatable
, thus giving it Equatable
implicitly, and it now compiles without issue:
// Make enum with non-`Equatable` associated values `Equatable`
enum Response: ReflectiveEquatable {
case success
case failed(Error)
}
You can demonstrate it's working as expected with the following test code:
// Define custom errors (also with associated types)
enum MyError: Error {
case primary
case secondary
case other(String)
}
enum MyOtherError: Error {
case primary
}
// Direct check
print(Response.success == Response.success) // prints 'true'
print(Response.success != Response.success) // prints 'false'
// Same enum value, 'primary', but on different error types
// If we had instead used `String(describing:)` in the implementation,
// this would have matched giving us a false-positive.
print(Response.failed(MyError.primary) == Response.failed(MyError.primary)) // prints 'true'
print(Response.failed(MyError.primary) == Response.failed(MyOtherError.primary)) // prints 'false'
// Associated values of an enum which themselves also have associated values
print(Response.failed(MyError.other("A")) == Response.failed(MyError.other("A"))) // prints 'true'
print(Response.failed(MyError.other("A")) == Response.failed(MyError.other("B"))) // prints 'false'
Note: Adhering to ReflectiveEquatable
does not make the associated types Equatable
!
(...but they also don't have to be for this to work!)
In the above example, it's important to note you are only applying Equatable
to the specific type you apply the ReflectiveEquatable
protocol to. The associated types used within it do not pick it up as well.
This means in this example, Error
still doesn't conform to Equatable
, thus the code below will still fail to compile...
print(MyError.primary == MyError.primary) // Doesn't support equality so won't compile!
The reason this implementation still works is because as mentioned above, we're not relying on the associated values' conformance to Equatable
. We're instead relying on how the specific enum's case appears under reflection, and since that results in a string which takes all associated values into consideration (by recursively reflecting on them as well), we get a pretty unique string, and that's what's ultimately checked.
For instance, this prints the reflected value of the enum case used in the last test above:
print(Response.failed(MyError.other("A")).reflectedValue)
And here's the resulting output:
main.Response.failed(main.MyError.other("A"))
Note: 'main' here is the module name containing this code.
Bonus: ReflectiveHashable
Using the same technique, you can implement Hashable
based on reflection with the following ReflectiveHashable
protocol...
// Conform this to both `Hashable` and `ReflectiveEquatable`
// (implicitly also conforming it to `Equatable`, a requirement of `Hashable`)
protocol ReflectiveHashable: Hashable, ReflectiveEquatable {}
// Implement the `hash` function.
extension ReflectiveHashable {
func hash(into hasher: inout Hasher) {
hasher.combine(reflectedValue)
}
}
With that in place, if you now conform your enum to ReflectiveHashable
, you get both Hashable
and Equatable
(via ReflectiveEquatable
) simply and easily...
// Make enum `Hashable` (and implicitly `Equatable`)
enum Response: ReflectiveHashable {
case success
case failed(Error)
}
Final Thoughts - Reflection? Really?!
While admittedly, reflection is not the most performant method compared to standard equality checks (and by several orders of magnitude at that), the question most people incorrectly ask is 'Shouldn't we avoid reflecting because it's so much slower?' The real question one should ask is 'Does it actually need to be faster?'
Consider the area where this may solve a problem for you. Is it while processing millions and millions of checks a second and where performance is critical, or is it more likely in response to a user-action? In other words, do you even notice that it's slower, or are you only looking at it from an academic standpoint?
The takeaway here being make sure you're not prematurely discounting the use of reflection if it solves a problem for you like the above. Don't optimize for something that doesn't actually move the needle. The best solution often isn't the fastest to run, but the fastest to be finished.