Notions of Equality in Swift
In this article I aim to discuss notions of equality, Swift’s Equatable
protocol, how equality in Swift differs from equality in more conventional object-oriented languages, and how the two can be reconciled.
Notions of equality
The concept of equality is deceptively complex. In mathematics, we state that 2 + 2
is equal to 4
. In other words, when simplifying an equation or expression, any time we see 2 + 2
we can excise it and replace it with 4
. The two expressions are exactly equivalent.
But in the context of a windowing system, if two windows happen to be the same size, at the same position, and contain the same contents, are they interchangeable? Even if both windows’ representations in memory are identical, randomly taking some of the references to one window and making them point to the other is unlikely to result in anything but strange behavior. Only if two references point to the same window instance can they be said to be equivalent.
Swift supports both of these forms of equality. Let’s examine each in turn.
Value equality
Value equality is checked using the ==
operator, which returns whether or not two instances are equivalent to each other.
let a = 10
let b = 10
let c = 11
a == b // true; 10 == 10
a == c // false; 10 != 11
// Swift arrays are value types, and hence the following holds
let d = [1, 2, 3]
let e = [4, 5, 6]
let f = [1, 2, 3]
d == e // false
d == f // true
Exactly what ‘equivalent’ means may vary based on the type of the instances being compared. However, value equality generally adheres to the following requirements:
- It should be reflexive; any object of a type supporting equality is equal to itself:
a == a
. - It should be symmetric;
b == a
iffa == b
. - It should be transitive: if
a == b
andb == c
, thena == c
.
Implementations of ==
that violate these properties invariably result in subtle bugs and confused programmers, and should be avoided if at all possible.
Reference equality
Reference equality is checked using the ===
operator, which returns whether or not two references refer to the same object. Reference equality is only meaningful when reference types are being compared, whereas value equality pertains to both reference and value types.
(If you do not understand the distinction between reference and value types, I recommend reading Mike Ash’s blog post on the subject.)
Two distinct instances of an object can be equal when compared using ==
, but two references, each pointing to one of those instances, will compare as unequal using ===
. See the following example:
// Here is a simple class. Assume we've defined two MyObjects as equal in value
// iff both instance variables are equal across both objects.
class MyObject : Equatable {
let a : Int, b : String
init(a: Int, b: String) { self.a = a; self.b = b }
}
// ...
let a = MyObject(a: 10, b: "foo")
let b = a
let c = MyObject(a: 10, b: "foo")
a == b // true; 'a' and 'b' are equal in value
a === b // true; 'a' and 'b' point to the same instance
a == c // true; 'a' and 'b' are equal in value
a === c // false; 'a' and 'c' are different instances
Note that, according to the reflexive property, if two objects are reference-equal, they are also value-equal.
The Equatable
protocol
We will focus most of our attention on value equality, as reference equality’s semantics are quite well defined and the default implementation of ===
suffices for almost all use cases.
In Swift, types accrete attributes by conforming to protocols. A type can declare to other code that it supports value equality by conforming to the Equatable
protocol:
protocol Equatable {
func ==(lhs: Self, rhs: Self) -> Bool
}
The protocol definition gives us enough information to figure out what conforming to Equatable
means: instance of a conforming type can be compared to another instance of the same type to check whether or not they are equal in value.
Self
in the protocol definition is a placeholder referring to the conforming type. Both arguments to ==
are of type Self
, which means that a type which wishes to become Equatable
must provide an implementation of the ==
operator which compares two instances of that same type.
As such, value equality in Swift is homogeneous: trying to compare two instances of different types causes a type error, rather than returning a guaranteed false
result. When the built-in type Int
conforms to Equatable
, the only guarantee we get is that we are allowed to compare an Int
to another Int
using ==
. When the built-in type String
conforms to Equatable
, the only guarantee we get is that we are allowed to compare a String
to another String
, and so forth.
Using Equatable
We can build a simple type that conforms to Equatable
as follows:
struct Coordinate : Equatable {
let x : Double, y : Double
}
func ==(lhs: Coordinate, rhs: Coordinate) -> Bool {
return lhs.x == rhs.x && lhs.y == rhs.y
}
Then we can use it:
let a = Coordinate(x: 10, y: 20)
let b = Coordinate(x: 20, y: 10)
a == b // false
Now, for something different. It would be great if we could write a function which took in three equatable objects and returned whether or not they were all equal in value:
// Note: does NOT compile!
func threeWayEquals(a: Equatable, b: Equatable, c: Equatable) -> Bool {
return a == b && b == c
}
This doesn’t compile! But what if it did? Remember that Int
and String
are both Equatable
types as well. We could call the function with the following arguments:
let a : Int = 10
let b : String = "foobar"
let c : Coordinate = Coordinate(x: 10, y: 11)
threeWayEquals(a, b, c)
The types check out. Int
, String
, and Coordinate
all conform to Equatable
. But we don’t have any guarantee that an implementation of ==
exists that can compare an Int
to a String
, or a String
to a Coordinate
, and if those implementations don’t actually exist, now the compiler is in trouble.
The actual Swift compiler complains that our protocol can only be used as a constraint on a generic parameter because its definition contains Self
. We can fix our function to allow it to compile:
func threeWayEquals<T : Equatable>(a: T, b: T, c: T) -> Bool {
return a == b && b == c
}
This version of the function requires all its arguments to be Equatable
as well, but also requires them to all be of the same type (a type that, therefore, conforms to Equatable
). In this case the compiler is guaranteed the implementation of ==
that it needs exists.
Object-oriented equality
Why is Swift equality so ‘different’ from equality in, say, Objective-C? Swift complains if we compare a String
and an Int
; Objective-C is fine if we send the message isEqual:
to an instance of NSNumber
with a NSString
as the argument. We can answer this question by looking at how object-oriented languages treat the concept of equality.
Basics of inheritance
First of all, many commonly used object-oriented languages ship with a designated base class from which all other classes should inherit. For example, Java and C# have Object
, while Objective-C has NSObject
(and NSProxy
). Swift is atypical in that it does not provide such a base class; any Swift class can serve as a base class.
In a program written in an object-oriented language, if B
is a subclass of A
, ideally instances of B
can be used anywhere instances of A
can be used without breaking the program. (This is the Liskov substitution principle.) We often say B
‘is-a’ A
. If Cow
inherits from Herbivore
inherits from Animal
, a Cow
is a Cow
, but also a more specific type of Herbivore
, which is in turn a more specific type of Animal
. In the Cocoa world, NSString
is a more specific type of NSObject
; UILabel
is a more specific type of UIView
.
Creating a base class
In object-oriented languages with a designated base class, the base class usually specifies some overarching functionality, such as comparing an object with another object.
In languages like Java and Objective-C, the ==
operator applied to instances of objects checks reference equality like Swift’s ===
operator. In order to check value equality, an equals
method is defined on the base class. This method invariably takes another instance of the base class and returns a boolean.
To illustrate how this works, let’s define a ‘sublanguage’ based off Swift. We’ll create our own class hierarchy, with our own designated base class (AZObject
), and decree that all user-specified types must inherit from AZObject
or one of its subclasses.
// Our base class
class AZObject : Equatable {
init() { }
// Return whether or not an object is 'value-equal' to another object.
func equals(another: AZObject) -> Bool {
return self === another
}
}
func ==(lhs: AZObject, rhs: AZObject) -> Bool {
return lhs.equals(rhs)
}
Our AZObject
base class provides an equals()
method, since it’s advantageous to allow any object to be compared with any other object. We’ll decide that any one of our featureless AZObject
instances can only be equal to itself, which means that value equality and reference equality for AZObject
s are identical.
Some AZObject
subclasses
If we create a subclass of our base class, for example AZData
, that subclass can then override equals()
however it wants. For example, two AZData
s might only be equal if all their constituent bytes are equivalent to each other:
/// An object representing an immutable blob of binary data.
class AZData : AZObject {
private let ptr : UnsafeMutablePointer<UInt8>
private let backingStore : UnsafeMutableBufferPointer<UInt8>
override func equals(another: AZObject) -> Bool {
if let anotherData = another as? AZData {
// Perform a byte-by-byte comparison of the data contained within the two buffers.
let thatBackingStore = anotherData.backingStore
guard backingStore.count == thatBackingStore.count else { return false }
for (index, item) in backingStore.enumerate() {
if thatBackingStore[index] != item { return false }
}
return true
}
// If the other object isn't a data blob, they're obviously not equal.
return false
}
// ...
init(bytes: [UInt8]) {
ptr = UnsafeMutablePointer.alloc(bytes.count)
backingStore = UnsafeMutableBufferPointer(start: ptr, count: bytes.count)
for (index, b) in bytes.enumerate() { backingStore[index] = b }
}
deinit { ptr.destroy() }
}
Two AZNumber
s might compare some normalized representation of their numeric values:
/// An object representing a number.
class AZNumber : AZObject {
private enum NumberType {
case Integer(Int), FlPt(Double), Boolean(Bool)
}
private let backingStore : NumberType
override func equals(another: AZObject) -> Bool {
if let anotherNumber = another as? AZNumber {
// For didactic purposes, use the floating point value as the normalized value
// This allows "AZNumber(100)" to equal "AZNumber(100.0)"
return flPtValue == anotherNumber.flPtValue
}
return false
}
var flPtValue : Double {
switch backingStore {
case let .Integer(v): return Double(v)
case let .FlPt(v): return v
// Please don't actually define your Booleans like this.
case let .Boolean(v): return v ? 1 : 0
}
}
// ...
}
Different types may have very different notions of what value equality means, even though they all descend from the same base class.
All objects are equatable
In the system we’ve devised, comparing two objects that aren’t the same type is a valid operation; it just always returns false
. As such, value equality for our AZObject
-based sublanguage is heterogeneous. In effect, any object can be compared with any other object.
More specifically, this is possible because:
- All subclasses of
AZObject
are alsoAZObject
s (‘is-a’). AnAZData
is also anAZObject
; anAZNumber
is also anAZObject
. - Our contract on
AZObject
specifies that value equality is a meaningful operation defined on twoAZObject
s. - Therefore, equating any two instances of
AZObject
subclasses is a meaningful operation, since it is equivalent to equating twoAZObject
instances.
In practice, this is how both Java and Objective-C implement value equality. All your custom types descend from Object
and NSObject
, so you get some notion of value equality ‘for free’ from the default implementation.
Limitations
What happens if we define a third class, AZJSONNode
, but neglect to override equals()
within our implementation? In that case, we fall back to AZObject
’s definition of equals()
, the one that checks for reference equality. In many cases this is incorrect behavior.
Heterogeneous comparison is fraught with danger. Neither compile-time nor run-time checking will warn us if we forget to override equals()
. Our default implementation of AZObject
, so convenient and inviting at first, ends up breaking the Liskov substitution principle in cases where the default behavior is wrong.
At the very least, programmers who implement their own custom classes under such a system need to be aware of the differences between value equality and reference equality. They need to know that their types’ definitions of value equality default to reference equality unless they explicitly implement their own equals()
methods. If they aren’t aware of all this (and unless they read the documentation closely they’re unlikely to be), they run the risk of writing seemingly-working code containing subtle bugs.
Solutions
If abstract methods existed in Swift, we might choose to make equals()
abstract, forcing each type to provide its own implementation, but this would come at the cost of making it impossible to instantiate AZObject
s. (Depending on our use case, this might not be a bad tradeoff.)
A better, more general solution is described in the Protocol-Oriented Programming video from WWDC 2015 (around the 40:30 mark). We can define a protocol AnyEquatable
and a protocol extension that only applies when the type the protocol is applied to is also Equatable
. AnyEquatable
itself contains no associated types, and unlike Equatable
can be used outside the context of generic constraints:
protocol AnyEquatable { }
extension AnyEquatable where Self : Equatable {
// otherObject could also be 'Any'
func equals(otherObject: AnyEquatable) -> Bool {
if let otherAsSelf = otherObject as? Self {
return otherAsSelf == self
}
return false
}
}
Note that the implementation of equals()
is, structurally, a generalization of the equals()
methods implemented for AZData
and AZNumber
. A downcast is used to ensure both arguments are of the same type, and if so value equality is checked using ==
. Otherwise, the comparison returns false
.
Any value that is both AnyEquatable
and Equatable
can be compared with any other AnyEquatable
value ‘for free’, and with the correct semantics. We need write no additional code except for a few empty extensions declaring conformance:
extension Int : AnyEquatable { }
extension String : AnyEquatable { }
class Foo : AnyEquatable { }
// This compiles, and returns 'true', since Int is Equatable.
15.equals(15)
// These return 'false'.
15.equals(16)
15.equals("hello")
// This also returns 'false', even though Foo isn't Equatable.
15.equals(Foo())
Our AnyEquatable
protocol isn’t a perfect replacement for Equatable
. If we wanted to implement our threeWayEquals()
using AnyEquatable
, we’d still need to constrain at least one of the arguments’ types to be Equatable
so we have access to our equals()
method:
func threeWayEquals<T where T : AnyEquatable, T : Equatable>(a: T,
b: AnyEquatable,
c: AnyEquatable) -> Bool {
return a.equals(b) && a.equals(c)
}
However, if we already know at least one of the concrete types we want to compare, we no longer need to make our function generic at all.
func threeWayEquals(a: Int, b: AnyEquatable, c: AnyEquatable) -> Bool {
return a.equals(b) && a.equals(c)
}
Conclusion
Despite marketing slogans, Swift isn’t “Objective-C without the C”. It approaches many problems in a way very different from common object-oriented languages, often for sound underlying reasons. The differences between value equality and reference equality are subtle yet, to some extent, fundamental. Instead of papering over them, Swift makes the delineation between the two concepts explicit.
People often like to characterize programming languages as more or less ‘strict’. I would argue that ‘strict’ might not be the best analogy. Some languages provide a high level of convenience in exchange for semantics that are hidden away or made implicit. Other languages make the semantics more explicit, requiring a bit of additional work on the programmer’s end.
Even so, at every point along this spectrum your programs are being executed in a specific way, whether or not you are aware of what those specifics are. And I would argue that it’s important to be aware, whether you’re using an object-oriented or functional language, a statically-typed or dynamically-typed one.
Some of the work Swift makes you do is due to limitations of the type system. Some of it might be due to unfamiliarity. But some of it involves clarifying intent, resolving ambiguity, and making you think about exactly what you want your program to do. And in the long run, that’s a good thing.