Background
Swift's Equatable
is defined like this:
public protocol Equatable {
static func == (lhs: Self, rhs: Self) -> Bool
}
Note that the arguments for ==
are defined as Self
rather than Equatable
. This has some implications and benefits:
- The arguments must be the same type at the call site.
- Because of #1, the function is generic in a way; each type gets its own implementation instead of one shared implementation that would have to unbox arguments.
- Because of #2, synthesized conformance to Equatable is fast. Boxing values carries overhead, and since the argument types are known at the call site, Equatable doesn't have to do any boxing.
However, there are some drawbacks:
- The arguments must be the same type.
- By implication, the arguments must be known to be the same type.
- The use of
Self
as in Equatable's definition restricts howEquatable
can be used. You cannot declare collections or variables as a protocol type, like you can in Objective-C.
For example, #3 above makes any code similar to the following impossible:
let a: Equatable = ...
let b: Equatable = ...
a == b
As another example, by extrapolation, you cannot compare collections of Equatable either, unless they can be deduced to be the same type, such as within a generic function with the following signature:
func checkEqualArrays<T: Equatable>(a: [T], b: [T]) -> Bool
// This compiles
checkEqualArrays(a: [1, 2, 3], b: [1, 2, 5])
// This does not:
// "Protocol type 'Any' cannot conform to 'Equatable'
// because only concrete types can conform to protocols"
checkEqualArrays(a: [1, 2, 3], b: ["x", "y", "z"])
Going further down the rabbit hole, you cannot get around this by just adding an additional generic argument. This would allow the above code to compile, but the implementation of checkEqualArrays
will not:
func checkEqualArrays<T: Equatable, U: Equatable>(a: [T], b: [U]) -> Bool {
// Binary operator '==' cannot be applied
// to operands of type '[T]' and '[U]'
return a == b
}
Manual type-erasure and AnyHashable
If you've ever been to this dark place, as I'm sure many of you have, like me, you found yourself frustrated and annoyed. Without any other options, you turned to some form of type-erasure. Type-erasure is a workaround of sorts, but it's absolutely something that should be avoided in these instances. AnyHashable
is an example of this if you've ever used it: it allows you to compare two Hashable
s for equality without ensuring they have the same concrete type at compile-time:
let a = AnyHashable(5)
let b = AnyHashable("x")
a == b // false
AnyHashable goes through a lot of boilerplate and uses what little reflection Swift currently offers to accomplish this. Unfortunately it doesn't alleviate the biggest problem I have with Equatable (and Hashable, by extension), which is that you can't declare variables or collections to store them directly, like you can in Objective-C.
Example: JSON objects and Any
Many APIs vend Any
, such as JSONSerialization
's .jsonObject(_:_:)
methods. For testing purposes, you may want to compare the output of these methods to one another. You'll quickly find you can't:
let data: Data = ...
let json = (try JSONSerialization.jsonObject(with: data, options: [])) as! [String: Any]
XCTAssertEqual(json, otherJSON) // won't work
You might think you can get around this by making your own special protocol to replace the Any
in that expression:
public protocol JSONValue: Equatable { }
extension Array : JSONValue where Element: JSONValue { }
extension Dictionary : JSONValue where Value: JSONValue { }
extension String : JSONValue { }
extension Date : JSONValue { }
extension Int : JSONValue { }
extension Bool : JSONValue { }
extension Double : JSONValue { }
...
extension NSNull : JSONValue { }
...
let json = (try JSONSerialization.jsonObject(with: data, options: [])) as! [String: JSONValue]
That last line sadly won't compile because of the Self
requirements of Equatable. Type-erasure by hand could help here, maybe, but it's a very hacky solution to something the language itself should support.
Informal Pitch: OpaqueEquatable
(or something similar)
Enter OpaqueEquatable
. It would be a new protocol whose operator would accept boxed parameters instead of Self
restricted parameters (hence the 'Opaque'). At runtime, the parameters would be checked first for type equality, then actual equality. The implementation might resemble this pseudocode:
protocol OpaqueEquatable {
static func == (lhs: OpaqueEquatable, rhs: OpaqueEquatable) -> Bool
}
extension OpaqueEquatable {
static func == (lhs: OpaqueEquatable, rhs: OpaqueEquatable) -> Bool {
if type(of: lhs) == type(of: rhs) {
// Get method reference to what is currently
// the synthesized Equatable implementation
let isEqual: (?, ?) -> Bool = type(of: lhs).==
return isEqual(lhs, rhs)
}
return false
}
}
Quick note: a definition like this isn't actually possible to use, because nothing can conform to it without giving this error:
Member operator '==' must have at least one argument of type 'T'
whereT
is the conforming type. I will ignore this for now though.
Ideally, everything—somehow including Any
—would conform to this protocol, or at least be able to conform to it by opting-in within your own project:
extension Any: OpaqueEquatable { }
It might also make sense for one of OpaqueEquatable and Equatable to conform to the other, but I'm not sure which. I'll leave that up to discussion below, as I'm not sure what the benefits and consequences of OpaqueEquatable: Equatable
vs Equatable: OpaqueEquatable
are, or which one should be the default to make APIs as easy to use while still remaining performant.
With OpaqueEquatable, the following should be possible:
public protocol JSONValue: OpaqueEquatable { }
extension Array : JSONValue where Element: JSONValue { }
extension Dictionary : JSONValue where Value: JSONValue { }
extension String : JSONValue { }
etc ...
...
// OK
let json = (try JSONSerialization.jsonObject(with: data, options: [])) as! [String: JSONValue]
// OK
XCTAssertEqual(json, otherJSON)
Edit: as @anandabits mentioned, this doesn't necessarily have to be a protocol. Anything with the semantics of a top-level function like isEqual(_ lhs: Any, _ rhs: Any) -> Bool
or better would be suitable.
Edge cases
Any
includes everything, even functions and closures. As long as the behavior is documented, I think any of these would be fine since it rarely makes sense to compare closures anyway:
- Throw a runtime error when comparing functions or closures
- Allow functions/methods to be compared, but not closures
- Allow functions/methods and closures to be compared, where the same closure source-declaration is always equal to itself, regardless of captured variables
What are your thoughts and concerns? Would you find this useful or not?