Reading 12: Equality
Credits: This reading is a port from Java to C# from 6.005 — Software Construction
on MIT OpenCourseWare . Click here to
see the original reading based on Java.
The goals of this course
Safe from bugs | Easy to understand | Ready for change |
---|---|---|
Correct today and correct in the unknown future. | Communicating clearly with future programmers, including future you. | Designed to accommodate change without rewriting. |
Objectives
- Understand equality defined in terms of the abstraction function, an equivalence relation, and observations.
- Differentiate between reference equality and object equality.
- Differentiate between strict observational and behavioral equality for mutable types.
- Understand the Object contract and be able to implement equality correctly for mutable and immutable types.
Introduction
In the previous readings we’ve developed a rigorous notion of data abstraction by creating types that are characterized by their operations, not by their representation. For an abstract data type, the abstraction function explains how to interpret a concrete representation value as a value of the abstract type, and we saw how the choice of abstraction function determines how to write the code implementing each of the ADT’s operations.
In this reading we turn to how we define the notion of equality of values in a data type: the abstraction function will give us a way to cleanly define the equality operation on an ADT.
In the physical world, every object is distinct – at some level, even two snowflakes are different, even if the distinction is just the position they occupy in space. (This isn’t strictly true of all subatomic particles, but true enough of large objects like snowflakes and baseballs and people.) So two physical objects are never truly “equal” to each other; they only have degrees of similarity.
In the world of human language, however, and in the world of mathematical concepts, you can have multiple names for the same thing. So it’s natural to ask when two expressions represent the same thing: 1+2, √9, and 3 are alternative expressions for the same ideal mathematical value.
Three Ways to Regard Equality
Formally, we can regard equality in several ways.
Using an abstraction function . Recall that an abstraction function f: R → A maps concrete instances of a data type to their corresponding abstract values. To use f as a definition for equality, we would say that a equals b if and only if f(a)=f(b).
Using a relation . An equivalence is a relation E ⊆ T x T that is:
- reflexive: E(t,t) ∀ t ∈ T
- symmetric: E(t,u) ⇒ E(u,t)
- transitive: E(t,u) ∧ E(u,v) ⇒ E(t,v)
To use E as a definition for equality, we would say that a equals b if and only if E(a,b).
These two notions are equivalent. An equivalence relation induces an abstraction function (the relation partitions T, so f maps each element to its partition class). The relation induced by an abstraction function is an equivalence relation (check for yourself that the three properties hold).
A third way we can talk about the equality between abstract values is in terms of what an outsider (a client) can observe about them:
Using observation . We can say that two objects are equal when they cannot be distinguished by observation – every operation we can apply produces the same result for both objects. Consider the set expressions {1,2} and {2,1}. Using the observer operations available for sets, cardinality |…| and membership ∈, these expressions are indistinguishable:
- |{1,2}| = 2 and |{2,1}| = 2
- 1 ∈ {1,2} is true, and 1 ∈ {2,1} is true
- 2 ∈ {1,2} is true, and 2 ∈ {2,1} is true
- 3 ∈ {1,2} is false, and 3 ∈ {2,1} is false
- … and so on
In terms of abstract data types, “observation” means calling operations on the objects. So two objects are equal if and only if they cannot be distinguished by calling any operations of the abstract data type.
Example: Duration
Here’s a simple example of an immutable ADT.
public class Duration
{
private int mins;
private int secs;
// rep invariant:
// mins >= 0, secs >= 0
// abstraction function:
// represents a span of time of mins minutes and secs seconds
// Make a duration lasting for m minutes and s seconds.
public Duration(int m, int s)
{
mins = m; secs = s;
}
// return length of this duration in seconds
public int GetLength()
{
return mins*60 + secs;
}
}
Now which of the following values should be considered equal?
Duration d1 = new Duration (1, 2);
Duration d2 = new Duration (1, 3);
Duration d3 = new Duration (0, 62);
Duration d4 = new Duration (1, 2);
Think in terms of both the abstraction-function definition of equality, and the observational equality definition.
== vs. Equals()
Like many languages, Java and C# has two different operations for testing equality, with different semantics.
-
The
==
operator compares references. More precisely, it tests referential equality. Two references are == if they point to the same storage in memory. In terms of the snapshot diagrams we’ve been drawing, two references are==
if their arrows point to the same object bubble. -
The
Equals()
operation compares object contents – in other words, Object equality, in the sense that we’ve been talking about in this reading. The Equals operation has to be defined appropriately for every abstract data type.
For comparison, here are the equality operators in several languages:
referential
|
Object
|
|
Java |
|
|
Objective C |
|
|
C# |
|
|
Python |
|
|
Javascript |
|
n/a |
Note that
==
unfortunately flips its meaning between Java/C# and Python. Don’t let that confuse you:
==
in Java/C# just tests reference identity, it doesn’t compare object contents.
As programmers in any of these languages, we can’t change the meaning of the referential equality operator. In Java,
==
always means referential equality. But when we define a new data
type, it’s our responsibility to decide what object equality means for
values of the data type, and implement the
Equals()
operation appropriately. C# additionally defines a method
ReferenceEquals()
for all objects which can be used to test for reference equality. This allows programmers to avoid any ambiguity if
==
is overridden.
Equality of Immutable Types
The
Equals()
method is defined by
Object
, and its default implementation looks like this:
// Returns a boolean indicating if the passed in Object obj is
// Equal to this. Equality is defined as Object equality for reference
// types and bitwise equality for value types using a loader trick to
// replace Equals with EqualsValue for value types).
//
public virtual bool Equals(Object obj)
{
return RuntimeHelpers.Equals(this, obj);
}
In other words, the default meaning of
Equals()
is the same as referential equality. For immutable data types, this is almost always wrong. So you have to
override
the
Equals()
method, replacing it with your own implementation.
Here’s our first try for
Duration
:
public class Duration {
...
// Problematic definition of Equals()
public bool Equals(Duration that) {
return this.GetLength() == that.GetLength();
}
}
There’s a subtle problem here. Why doesn’t this work? Let’s try this code:
Duration d1 = new Duration (1, 2);
Duration d2 = new Duration (1, 2);
Object o2 = d2;
d1.Equals(d2) → true
d1.Equals(o2) → false
You can
see this code in action for Java
. You’ll see that even though
d2
and
o2
end up referring to the very same object in memory, you still get different results for them from
Equals()
.
What’s going on? It turns out that
Duration
has
overloaded
the
Equals()
method, because the method signature was not identical to
Object
’s. We actually have two
Equals()
methods in
Duration
: an implicit
Equals(Object)
inherited from
Object
, and the new
Equals(Duration)
.
// explicit method that we declared:
public bool Equals(Duration duration)
{
return GetLength() == duration.GetLength();
}
// implicit method inherited from Object:
public bool Equals(Object duration)
{
return this == duration;
}
We’ve seen overloading since the very beginning of the course.
Recall
that the compiler selects between overloaded operations using the
compile-time type of the parameters. For example, when you use the
/
operator, the compiler chooses either integer division or float
division based on whether the arguments are ints or floats. The same
compile-time selection happens here. If we pass an
Object
reference, as in
d1.Equals(o2)
, we end up calling the
Equals(Object)
implementation. If we pass a
Duration
reference, as in
d1.Equals(d2)
, we end up calling the
Equals(Duration)
version. This happens even though
o2
and
d2
both point to the same Object at runtime! Equality has become inconsistent.
It’s easy to make a mistake in the method signature, and overload
a method when you meant to override it. This is such a common error that the C#
compiler warns us when we override a method without explicitly using the override
keyword.
Duration2a.cs(30,17): warning CS0114: 'Duration.Equals(Object)' hides inherited member 'Object.Equals(Object)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword.
So here’s the right way to implement
Duration
’s
Equals()
method:
public override bool Equals(Object obj)
{
// Check for null and compare run-time types dynamically
if ((obj == null) || ! this.GetType().Equals(obj.GetType()))
{
return false;
}
else
{
Duration p = (Duration) obj; // cast from base class to child
return GetLength() == p.GetLength();
}
}
This fixes the problem:
Duration d1 = new Duration(1, 2);
Duration d2 = new Duration(1, 2);
Object o2 = d2;
d1.Equals(d2) → true
d1.Equals(o2) → true
You can see this code in action for Java in the Online Python Tutor.
GetType()
The
GetType()
method
allows us to see the type of an object. It is an example of a reflection language feature, which allows us to examine an object's state at runtime.
Using
GetType()
is dynamic type checking, not the static type checking we vastly prefer.
In general, using
GetType()
and performing casts in object-oriented programming is a bad smell.
In 6.005 — and this is another of our rules that holds true in most good object-oriented programming —
GetType()
and casting is disallowed anywhere except for implementing
Equals
and while using GameObject.Instantiate() in Unity.
.
This prohibition also includes other ways of inspecting objects’ runtime types.
For example,
typeof
is also disallowed.
The Object Contract
The specification of the
Object
class is so important that it is often referred to as
the
Object
Contract
. The contract can be found in the method specifications for the
Object
class. Here we will focus on the contract for
Equals
. When you override the
Equals
method, you must adhere to its general contract. It states that:
-
Equals
must define an equivalence relation – that is, a relation that is reflexive, symmetric, and transitive; -
Equals
must be consistent: repeated calls to the method must yield the same result provided no information used inEquals
comparisons on the object is modified; -
for a non-null reference
x
,x.Equals(null)
should return false; -
GetHashCode
must produce the same result for two objects that are deemed equal by theEquals
method.
Breaking the Equivalence Relation
Let’s start with the equivalence relation. We have to make sure that the definition of equality implemented by
Equals()
is actually an equivalence relation as defined earlier:
reflexive, symmetric, and transitive. If it isn’t, then operations that
depend on equality (like sets, searching) will behave erratically and
unpredictably. You don’t want to program with a data type in which
sometimes
a
Equals
b
, but
b
doesn’t equal
a
. Subtle and painful bugs will result.
Here’s an example of how an innocent attempt to make equality
more flexible can go wrong. Suppose we wanted to allow for a tolerance
in comparing
Duration
objects, because different computers may have slightly unsynchronized clocks:
private static int CLOCK_SKEW = 5;
public override bool Equals(Object obj)
{
//Check for null and compare run-time types dynamically
if ((obj == null) || ! this.GetType().Equals(obj.GetType()))
{
return false;
}
else
{
Duration p = (Duration) obj; // cast from base class to child
return Math.Abs(GetLength() - p.GetLength()) < CLOCK_SKEW;
}
}
Which property of the equivalence relation is violated?
Breaking Hash Tables
To understand the part of the contract relating to the
GetHashCode
method, you’ll need to have some idea of how hash tables work. Two very common collection implementations,
HashSet
and
HashMap
, use a hash table data structure, and depend on the
GetHashCode
method to be implemented correctly for the objects stored in the set and used as keys in the map.
A hash table is a representation for a mapping: an abstract data
type that maps keys to values. Hash tables offer constant time lookup,
so they tend to perform better than trees or lists. Keys don’t have to
be ordered, or have any particular property, except for offering
Equals
and
GetHashCode
.
Here’s how a hash table works. It contains an array that is initialized to a size corresponding to the number of elements that we expect to be inserted. When a key and a value are presented for insertion, we compute the hashcode of the key, and convert it into an index in the array’s range (e.g., by a modulo division). The value is then inserted at that index.
The rep invariant of a hash table includes the fundamental constraint that keys are in the slots determined by their hash codes.
Hashcodes are designed so that the keys will be spread evenly over the indices. But occasionally a conflict occurs, and two keys are placed at the same index. So rather than holding a single value at an index, a hash table actually holds a list of key/value pairs, usually called a hash bucket . A key/value pair is implemented in Java simply as an object with two fields. On insertion, you add a pair to the list in the array slot determined by the hash code. For lookup, you hash the key, find the right slot, and then examine each of the pairs until one is found whose key equals the query key.
Now it should be clear why the
Object
contract requires equal objects to have the same hashcode. If
two equal objects had distinct hashcodes, they might be placed in
different slots. So if you attempt to lookup a value using a key equal
to the one with which it was inserted, the lookup may fail.
Object
’s default
GetHashCode()
implementation is consistent with its default
Equals()
:
public class Object {
...
public boolean Equals(Object that) { return this == that; }
public int GetHashCode() { return /* the memory address of this */; }
}
For references
a
and
b
, if
a == b
, then the address of a
==
the address of b. So the
Object
contract is satisfied.
But immutable objects need a different implementation of
GetHashCode()
. For
Duration
, since we haven’t overridden the default
GetHashCode()
yet, we’re currently breaking the
Object
contract:
Duration d1 = new Duration(1, 2);
Duration d2 = new Duration(1, 2);
d1.Equals(d2) → true
d1.GetHashCode() → 2392
d2.GetHashCode() → 4823
d1
and
d2
are
equal()
, but they have different hash codes. So we need to fix that.
A simple and drastic way to ensure that the contract is met is for
GetHashCode
to always return some constant value, so every object’s hash code is the same. This satisfies the
Object
contract, but it would have a disastrous performance effect,
since every key will be stored in the same slot, and every lookup will
degenerate to a linear search along a long list.
The standard way to construct a more reasonable hash code that
still satisfies the contract is to compute a hash code for each
component of the object that is used in the determination of equality
(usually by calling the
GetHashCode
method of each component), and then combining these, throwing in a few arithmetic operations. For
Duration
, this is easy, because the abstract value of the class is already an integer value:
public override int GetHashCode()
{
return GetLength();
}
Josh Bloch’s fantastic book, Effective Java , explains this issue in more detail, and gives some strategies for writing decent hash code functions. The advice is summarized in a good StackOverflow post . Another good reference is the C# documentation for GetHashCode.
Note, however, that as long as you satisfy the requirement that equal objects have the same hash code value, then the particular hashing technique you use doesn’t make a difference to the correctness of your code. It may affect its performance, by creating unnecessary collisions between different objects, but even a poorly-performing hash function is better than one that breaks the contract.
Most crucially, note that if you don’t override
GetHashCode
at all, you’ll get the one from
Object
, which is based on the address of the object. If you have overridden
Equals
, this will mean that you will have almost certainly violated the contract. So as a general rule:
Always override
GetHashCode
when you overrideEquals
. The C# compiler helps you satisfy this requirement. It will output the following warning if you override Equals() but not GetHashCode().Duration3.cs(3,14): warning CS0659: 'Duration' overrides Object.Equals(Object o) but does not override Object.GetHashCode()
Many years ago in (a precursor to 6.005 confusingly numbered)
6.170, a student spent hours tracking down a bug in a project that
amounted to nothing more than misspelling
GetHashCode
as
hashcode
. This created a new method that didn’t override the
GetHashCode
method of
Object
at all, and strange things happened.
Equality of Mutable Types
We’ve been focusing on equality of immutable objects so far in this reading. What about mutable objects?
Recall our definition: two objects are equal when they cannot be distinguished by observation. With mutable objects, there are two ways to interpret this:
- when they cannot be distinguished by observation that doesn’t change the state of the objects , i.e., by calling only observer, producer, and creator methods. This is often strictly called observational equality , since it tests whether the two objects “look” the same, in the current state of the program.
- when they cannot be distinguished by any observation, even state changes. This interpretation allows calling any methods on the two objects, including mutators. This is often called behavioral equality , since it tests whether the two objects will “behave” the same, in this and all future states.
For immutable objects, observational and behavioral equality are identical, because there aren’t any mutator methods.
For mutable objects, it’s tempting to implement strict
observational equality. Java uses observational equality for most of
its mutable data types, in fact. If two distinct
List
objects contain the same sequence of elements, then
Equals()
reports that they are equal.
But using observational equality leads to subtle bugs, and in
fact allows us to easily break the rep invariants of other collection
data structures. Recall our Date class from the reading on rep invariance.
In the Tweet example, Date
was a mutable class. Suppose we implement Equals() to use observational equality:
public override bool Equals(Object o)
{
if (o == null || o.GetType() != GetType())
{
return false;
}
Date date = (Date) o;
return (_day == date._day) &&
(_month == date._month) &&
(_year == date._year);
}
public override int GetHashCode()
{
return _day + _month + _year;
}
Now suppose we make a
Date
, and then drop it into a
HashSet
:
Date date = new Date(11,28,1019);
HashSet<Date> set = new HashSet<Date>();
set.Add(date);
We can check that the set contains the list we put in it, and it does:
set.Contains(date) → true
But now we mutate the date:
date.Month = 12;
And it no longer appears in the set!
set.Contains(date) → false!
It’s worse than that, in fact: when we iterate over the members of the set, we still find the list in there, but
Contains()
says it’s not there!
foreach (Date d in set)
{
Console.WriteLine("Date: "+d);
}
If the set’s own iterator and its own
Contains()
method disagree about whether an element is in the set, then the set clearly is broken. You can
see this code in action in Java (for a mutable list object).
on Online Python Tutor. Note that if you try the example using List<string> in C#, it works as expected.
What’s going on?
Date
is a mutable object. In the implementation of
Date
, mutations affect the result of
Equals()
and
GetHashCode()
. When the date is first put into the
HashSet
, it is stored in the hash bucket corresponding to its
GetHashCode()
result at that time. When the date is subsequently mutated, its
GetHashCode()
changes, but
HashSet
doesn’t realize it should be moved to a different bucket. So it can never be found again.
When
Equals()
and
GetHashCode()
can be affected by mutation, we can break the rep invariant of a hash table that uses that object as a key.
Here’s a telling quote from the specification of
java.util.Set
:
Note: Great care must be exercised if mutable objects are used as set elements. The behavior of a set is not specified if the value of an object is changed in a manner that affects equals comparisons while the object is an element in the set.
The Java library is unfortunately inconsistent about its interpretation of
Equals()
for mutable classes. Collections use observational equality, but other mutable classes (like
StringBuilder
) use behavioral equality.
The lesson we should draw from this example is that
Equals()
should implement behavioral equality
. In general, that means that two references should be
Equals()
if and only if they are aliases for the same object. So mutable objects should just inherit
Equals()
and
GetHashCode()
from
Object
. For clients that need a notion of observational equality
(whether two mutable objects “look” the same in the current state), it’s
better to define a new method, e.g.,
similar()
.
The Final Rule for Equals() and GetHashCode()
For immutable types :
-
Equals()
should compare abstract values. This is the same as sayingEquals()
should provide behavioral equality. -
GetHashCode()
should map the abstract value to an integer.
So immutable types must override both
Equals()
and
GetHashCode()
.
For mutable types :
-
Equals()
should compare references, just like==
. Again, this is the same as sayingEquals()
should provide behavioral equality. -
GetHashCode()
should map the reference into an integer.
So mutable types should not override
Equals()
and
GetHashCode()
at all, and should simply use the default implementations provided by
Object
. Java doesn’t follow this rule for its collections, unfortunately, leading to the pitfalls that we saw above.
Summary
- Equality should be an equivalence relation (reflexive, symmetric, transitive).
-
Equality and hash code must be consistent with each other, so that data structures that use hash tables (like
HashSet
andHashMap
) work properly. - The abstraction function is the basis for equality in immutable data types.
- Reference equality is the basis for equality in mutable data types; this is the only way to ensure consistency over time and avoid breaking rep invariants of hash tables.
Equality is one part of implementing an abstract data type, and we’ve already seen how important ADTs are to achieving our three primary objectives. Let’s look at equality in particular:
-
Safe from bugs . Correct implementation of equality and hash codes is necessary for use with collection data types like sets and maps. It’s also highly desirable for writing tests. Since every object in Java inherits the
Object
implementations, immutable types must override them. -
Easy to understand . Clients and other programmers who read our specs will expect our types to implement an appropriate equality operation, and will be surprised and confused if we do not.
-
Ready for change . Correctly-implemented equality for immutable types separates equality of reference from equality of abstract value, hiding from clients our decisions about whether values are shared. Choosing behavioral rather than observational equality for mutable types helps avoid unexpected aliasing bugs.