Skip to main content

CSE 331: Equality

lecture

Course motto: Your job is to deliver code that you know works.

The objects of an abstract data type stand for values, and a client reasons about the values, never the fields. Java gives every object an equals method and leaves each class to say what it means. So this lecture asks one question: when are two objects of an abstract data type equal? The answer is a definition, the same abstract value, and two methods written from that value, equals and hashCode. Along the way are a trap in Java, an overload that passes for an override; a case where no answer exists, a subclass that adds to the value; and a cost, a set that loses track of an element whose value changes.

The same queue twice

Recall the queue for office hours, HelpQueue: the students waiting for help, in the order they joined, with each student in the queue at most once. We have implemented it several times, and today we need two of the implementations. LinkedQueue keeps the students as a chain of nodes, one node per student. FasterQueue keeps them in two lists and a set. Here is a program that makes one of each. (Every program in this lecture, the copies included, is in the course's public repo, to download and run.) Three students join q1, the FasterQueue, and next serves one. Two join q2, the LinkedQueue. Then main prints four lines about the pair:

Queues.java
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Queues {
    public static void main(String[] args) {
        // A FasterQueue: ada, bob, and eve join, and ada is served.
        HelpQueue q1 = new FasterQueue();
        q1.join("ada");
        q1.join("bob");
        q1.join("eve");
        q1.next();

        // A LinkedQueue: bob and eve join.
        HelpQueue q2 = new LinkedQueue();
        q2.join("bob");
        q2.join("eve");

        // Who is waiting in each?
        System.out.println("q1.waiting(): " + q1.waiting());
        System.out.println("q2.waiting(): " + q2.waiting());

        // Are the two queues the same?
        System.out.println("q1 == q2: " + (q1 == q2));
        System.out.println("q1.equals(q2): " + q1.equals(q2));
    }
}

Before you read on, write down the four lines you expect it to print. Here is the run:

$ javac Queues.java

$ java Queues
q1.waiting(): [bob, eve]
q2.waiting(): [bob, eve]
q1 == q2: false
q1.equals(q2): false

The first two lines are the same, and here is why, one queue at a time:

  • q1. ada, bob, and eve joined, in that order, so ada was at the front. Then next served ada. So bob and eve are waiting, front first.
  • q2. bob and eve joined, in that order. So the same two students are waiting, front first again.

A client that calls HelpQueue's methods on q1 and on q2 gets the same answers from both. As far as a client can tell, the two hold the same queue. But the last two lines print false.

== is the easy one. q1 == q2 is true only when the two names refer to the same object. q1 and q2 came from two new expressions, so they are two different objects, and == says so. That is not the question we meant. We asked whether the two are the same queue, not whether they are the same object.

You have called equals on two strings, and it compared their characters. So what did it compare here? HelpQueue specifies no equals, and neither class declares one. So each class inherits equals from Object, the class that every Java class extends, whether or not it says so. Here is Object's equals, from the JDK's own source:

Object.java
public boolean equals(Object obj) {
    return (this == obj);
}

q1.equals(q2) ran this method, computed q1 == q2, and printed the same false. Nobody has said yet what equals should mean for a help queue, so Java fell back on the one check Object can make.

Equal means the same abstract value

What should equals have said? Think about what each object stands for. q1's fields are an empty inbox, an outbox holding eve, bob, with the front of the queue last, and a members set holding both. FasterQueue's abstraction function says that those fields stand for the queue bob, eve. q2's fields are a chain of two nodes, bob's and then eve's, and that chain stands for the queue bob, eve. The fields differ, but the two objects stand for the same queue:

q1's fields, an empty inbox, the outbox eve, bob, and the
members bob, eve, and q2's fields, a chain of bob's node and then
eve's, both map to the one queue bob, eve: two objects, one
value.

The fields of q1 and of q2, and the queue that both stand for.

== compared the right side of the picture and found two objects. The question we meant is about the left side. For a help queue, and for any abstract data type, equal should mean the same abstract value.

Definition: equality for an abstract data type

Two objects of an abstract data type are equal when they stand for the same abstract value.

equals is the method that decides it. Like every method of the type, its specification is about the abstract state, the value an object stands for, and its body works on the fields.

Why did Object not do this for us? Because Object cannot know what its subclasses stand for. Some classes stand for particular things rather than values: a window on the screen, a connection to a server. Two windows with the same size and title are still two windows, so for those classes == is the right equals. A help queue stands for a value, the students waiting, and two queue objects can stand for the same value. Only the class knows which kind it is and what its fields mean. So a class whose objects stand for values writes its own equals, unless its objects can change, a case we come to at the end.

Object does write down what any equals must do: its specification. Every class is a subtype of Object, and a subtype has to keep every promise its supertype makes, so every equals we write has to satisfy it.1 Here it is, from the documentation:

The equals method implements an equivalence relation on non-null object references:

  • It is reflexive: for any non-null reference value x, x.equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x.equals(null) should return false.

The first three bullets say that equals is an equivalence relation, like = on numbers: every number equals itself, x = y if and only if y = x, and x = y and y = z give x = z. That is a lot to promise. But if equals asks whether two objects stand for the same abstract value, every bullet comes for free:

  • = on values of any kind is reflexive, symmetric, and transitive, so equals is too.
  • equals is consistent as long as neither object's abstract value changes between the calls.
  • null stands for no value, so nothing equals it.

And the specification never says that equals is ==. Object's body computes ==, but its promise is only the five bullets, so a class may call more objects equal than == does and still keep every one of them, as a subtype may strengthen a specification.

Let's write one. For the queues it takes two classes that agree with each other, since a LinkedQueue and a FasterQueue can stand for the same queue. So we start with a type that has one class and one number to compare, learn what equals has to compute there and what goes wrong when it does not, and then come back to the queues.

What equals should compute

Here is that type: Duration, a span of time made from minutes and seconds. Its representation invariant lets the seconds run past 59, the way a microwave accepts 0 minutes and 99 seconds.2 So new Duration(1, 39) and new Duration(0, 99) are two values of the fields for one span, just as q1 and q2 were two representations of one queue. The class has no equals yet. Its main makes those two spans, gives the second a second name, o, declared an Object, and puts the first in a HashSet (a set that answers contains in "constant time", the kind FastQueue keeps its students in). Then it asks four questions: are the two spans one object, is d1 equal to d2, is d1 equal to o, and does the set holding d1 contain d2?

Duration.java
/** An immutable span of time, a whole number of seconds long. */
public class Duration {
    private final int min;
    private final int sec;
    // RI: 0 <= min and 0 <= sec
    // AF(this) = the span of 60 * min + sec seconds

    /**
     * @requires 0 <= min and 0 <= sec
     * @effects makes the span of min minutes and sec seconds
     */
    public Duration(int min, int sec) {
        this.min = min;
        this.sec = sec;
        checkRep();
    }

    private void checkRep() {
        if (min < 0 || sec < 0) {
            throw new IllegalStateException("a part is negative");
        }
    }

    /** @return the length of this span, in seconds */
    public int seconds() {
        return 60 * min + sec;
    }

    public static void main(String[] args) {
        Duration d1 = new Duration(1, 39);
        Duration d2 = new Duration(0, 99);
        Object o = d2;
        Set<Duration> set = new HashSet<Duration>();
        set.add(d1);
        System.out.println("d1 == d2: " + (d1 == d2));
        System.out.println("d1.equals(d2): " + d1.equals(d2));
        System.out.println("d1.equals(o): " + d1.equals(o));
        System.out.println("set.contains(d2): " + set.contains(d2));
    }
}

Write down what the four lines print. Here is the run:

$ javac Duration.java

$ java Duration
d1 == d2: false
d1.equals(d2): false
d1.equals(o): false
set.contains(d2): false

All four say no. == and Object's equals we have seen: they ask whether d1 and d2 are one object, and they are two. o is d2 under a second name, so the third line asks the same question again. A HashSet decides what it contains with equals and with a second method of Object's, hashCode, which we come to below. With Object's versions of both, the set holding d1 does not have d2.

A Duration stands for a length, so let's compare lengths. Here is an equals that takes a Duration:

Duration.java
    /** @return whether d is the same length as this */
    public boolean equals(Duration d) {
        return seconds() == d.seconds();
    }

Now d1.equals(d2) prints true:

$ javac Duration.java

$ java Duration
d1 == d2: false
d1.equals(d2): true
d1.equals(o): false
set.contains(d2): false

But read the next line. o and d2 are one object, and d1.equals(o) prints false: the same name, the same two objects, and two different answers. The set cannot find d2 either. Something is wrong with the method, and the third line is the clue.

An overload is not an override

We meant this method to override Object's equals, so let's say so: @Override above a method asks javac to check that the method overrides one. With that line added, javac refuses the file:

$ javac Duration.java
Duration.java:46: error: method does not override or implement a method from a supertype
    @Override
    ^
1 error

Java tells two methods apart by their names and their parameter types. Object's equals takes an Object, and ours takes a Duration, so ours is a second method named equals, an overload, and Object's is still there beside it. Duration now has two methods named equals, and each call picks one of them by the declared type of its argument, when the program is compiled:

  • d1.equals(d2). d2 is declared a Duration, so the call picks ours, and it prints true.
  • d1.equals(o). o is declared an Object, so the call picks Object's, which compares references, and it prints false.

The object is the same in both calls. Only the declared type of the variable differs, and that was enough to change the answer.

HashSet makes the second kind of call. Its code was written against Object: inside the set, an element has the type Object, whatever the set's declared element type, so the one equals it can call is equals(Object), and our method never ran during the call to contains.

An override is what we meant to write: a method with the same name and the same parameter types as the inherited one, which takes its place. Every call that reached Object's equals then reaches ours instead, whatever the declared type of the argument. That is what equals needs, and an overload cannot give it. So an equals whose parameter is anything but an Object is a mistake, and @Override above every equals catches it before the program runs.

The override, and the hash code

Here is an equals that takes an Object:

Duration.java
    /** @return whether o is a Duration of the same length as this */
    @Override
    public boolean equals(Object o) {
        if (o instanceof Duration d) {
            return seconds() == d.seconds();
        }
        return false;
    }

Two things to read in it:

  • o instanceof Duration d is true when o is a Duration. When it is, d refers to the same object, with the type Duration, so that the branch can call seconds on it. The check is false when o is null, since null is an instance of no class. So the last bullet of Object's specification, x.equals(null) is false, comes free.
  • seconds() == d.seconds() compares lengths, not min and sec. The fields are the representation. seconds() computes the length of the span as an int, a Java value that stands in for the abstract one, so comparing the two ints compares the two spans. Two Durations are equal when their lengths are, and an equals that compared the fields would call d1 and d2 different.

Here is the run:

$ javac Duration.java

$ java Duration
d1 == d2: false
d1.equals(d2): true
d1.equals(o): true
set.contains(d2): false

Now d1.equals(d2) and d1.equals(o) agree: the override runs whatever the argument's declared type. And the set still says no. In this run, as in the last, the set never got as far as equals.

Hash codes. A HashSet keeps its elements in buckets, and it picks an element's bucket by a number the element reports, its hash code, the result of a method every class inherits from Object, hashCode. contains computes its argument's hash code, looks in that bucket alone, and calls equals on what it finds there with the same hash code. Object's hashCode, like Object's equals, is about the object, not the value: as far as it can, it gives distinct objects distinct numbers. So d1 and d2 report different hash codes, and the set looked for d2 in a bucket that d1 was never in. Object's documentation3 states the rule that every class has to keep, in the specification of hashCode:

If two objects are equal according to the equals method, then calling the hashCode method on each of the two objects must produce the same integer result.

Equal objects have equal hash codes. Our equals compares lengths, so the hash code has to come from the length too:

Duration.java
    @Override
    public int hashCode() {
        return seconds();
    }

Any number computed from the abstract value alone keeps the rule. The length itself is the simplest. Here is the run:

$ javac Duration.java

$ java Duration
d1 == d2: false
d1.equals(d2): true
d1.equals(o): true
set.contains(d2): true

Rule: equals and hashCode together

Write equals(Object) to compare abstract values, and write hashCode from the same abstract value, in the same class.

An equals whose parameter is anything but an Object is an overload, which @Override catches. An equals without a hashCode is a mistake that javac accepts: the program runs, and a HashSet loses track of things.

Duration has no subclass so far. Let's give it one.

When no answer exists

Suppose we need spans finer than a second. A NanoDuration is a Duration with a count of nanoseconds added, so a subclass looks like the natural shape: it gets the minutes, the seconds, and seconds() from Duration, and adds one field. Here it is, with an equals written the way Duration's was, from the abstract value:

NanoDuration.java
/** An immutable span of time, a whole number of nanoseconds long. */
public class NanoDuration extends Duration {
    private final int nano;
    // RI: 0 <= nano < 1_000_000_000
    // AF(this) = the span of 1_000_000_000 * seconds() + nano
    //     nanoseconds

    /**
     * @requires 0 <= min, 0 <= sec, and 0 <= nano < 1_000_000_000
     * @effects makes the span of min minutes, sec seconds, and nano
     *     nanoseconds
     */
    public NanoDuration(int min, int sec, int nano) {
        super(min, sec);
        this.nano = nano;
        checkRep();
    }

    private void checkRep() {
        if (nano < 0 || 1_000_000_000 <= nano) {
            throw new IllegalStateException("nano is out of range");
        }
    }

    /** @return the length of this span, in nanoseconds */
    public long nanos() {
        return 1_000_000_000L * seconds() + nano;
    }

    /** @return whether o is a NanoDuration of the same length as this */
    @Override
    public boolean equals(Object o) {
        if (o instanceof NanoDuration n) {
            return nanos() == n.nanos();
        }
        return false;
    }

    public static void main(String[] args) {
        Duration d = new Duration(5, 10);
        Duration nd = new NanoDuration(5, 10, 15);
        System.out.println("d.equals(nd): " + d.equals(nd));
        System.out.println("nd.equals(d): " + nd.equals(d));
    }
}

main makes a Duration of 5 minutes 10 seconds and a NanoDuration of the same 5 minutes 10 seconds and 15 nanoseconds, and asks each whether it equals the other. Write down both answers before you look:

$ javac Duration.java NanoDuration.java

$ java NanoDuration
d.equals(nd): true
nd.equals(d): false

The two disagree, and Object's specification promised they would not: equals is symmetric, x.equals(y) if and only if y.equals(x). Here is how each call went:

  • d.equals(nd) ran Duration's equals, which asks o instanceof Duration. A NanoDuration is a Duration, by extends, and the two have the same whole seconds, so it said yes.
  • nd.equals(d) ran NanoDuration's equals. nd is declared a Duration, but a call of an overridden method runs the body of the object's own class, whatever the variable's declared type, by dynamic dispatch. That equals asks o instanceof NanoDuration, and d is not one, so it said no.

Set that beside the overload: an overload is picked by the declared type of the argument, when the program is compiled, and an override by the class of the object the call is made on, when it runs.

The fix most people reach for is to make NanoDuration's equals agree with Duration's when the other object is a plain Duration: compare nanoseconds against a NanoDuration, and otherwise hand the question to Duration's equals, with super.equals(o). Here is that equals, and a main with two NanoDurations that differ only in nanoseconds:

NanoDuration.java
    /**
     * @return whether o is a NanoDuration of the same length as this,
     *     or a Duration of the same length in whole seconds
     */
    @Override
    public boolean equals(Object o) {
        if (o instanceof NanoDuration n) {
            return nanos() == n.nanos();
        }
        return super.equals(o);
    }

    public static void main(String[] args) {
        Duration nd1 = new NanoDuration(5, 10, 15);
        Duration d = new Duration(5, 10);
        Duration nd2 = new NanoDuration(5, 10, 30);
        System.out.println("d.equals(nd1): " + d.equals(nd1));
        System.out.println("nd1.equals(d): " + nd1.equals(d));
        System.out.println("d.equals(nd2): " + d.equals(nd2));
        System.out.println("nd1.equals(nd2): " + nd1.equals(nd2));
    }

Again, predict the four lines first:

$ javac Duration.java NanoDuration.java

$ java NanoDuration
d.equals(nd1): true
nd1.equals(d): true
d.equals(nd2): true
nd1.equals(nd2): false

Symmetry is back, and transitivity is gone: nd1 equals d, d equals nd2, and nd1 does not equal nd2. We could keep patching the method, but the trouble is not in the method. Ask what each class stands for:

  • A Duration stands for a span of whole seconds. d is 5 minutes 10 seconds.
  • A NanoDuration stands for a span of nanoseconds. nd1 is 5 minutes 10 seconds and 15 nanoseconds, and nd2 is 5 minutes 10 seconds and 30 nanoseconds.

So "the same abstract value" has no answer between the two classes. Suppose it did, and d and nd1 were the same value:

  • Then d and nd2 are the same value too, since Duration's equals goes by whole seconds and nd2 has the same whole seconds as nd1.
  • Then nd1 and nd2 have to be the same value as each other, by transitivity.
  • But they are not: one is 15 nanoseconds longer than the other.

No equals that calls a NanoDuration equal to the Duration of its whole seconds, and tells two NanoDurations apart by their nanoseconds, can keep all five promises.

The lecture on subtyping gives the same verdict in its own words. Duration's equals promises symmetry and transitivity to every client, as every equals does. A NanoDuration handed to such a client breaks one or the other, as the two runs showed, so NanoDuration is not a behavioral subtype of Duration, and the rule declare only behavioral subtypes says not to write extends Duration.

The way out is the second of the two that lecture took for its square: hold a Duration in a field instead of extending it, the composition that lecture named. Here is NanoDuration built that way. It stands for a span of nanoseconds, its equals and hashCode are written from that value, and it is not a Duration:

NanoDuration.java
/** An immutable span of time, a whole number of nanoseconds long. */
public class NanoDuration {
    private final Duration whole;
    private final int nano;
    // RI: 0 <= nano < 1_000_000_000
    // AF(this) = the span of
    //     1_000_000_000 * whole.seconds() + nano nanoseconds

    /**
     * @requires 0 <= min, 0 <= sec, and 0 <= nano < 1_000_000_000
     * @effects makes the span of min minutes, sec seconds, and nano
     *     nanoseconds
     */
    public NanoDuration(int min, int sec, int nano) {
        this.whole = new Duration(min, sec);
        this.nano = nano;
        checkRep();
    }
NanoDuration.java
    /** @return the length of this span, in nanoseconds */
    public long nanos() {
        return 1_000_000_000L * whole.seconds() + nano;
    }

    /** @return whether o is a NanoDuration of the same length as this */
    @Override
    public boolean equals(Object o) {
        if (o instanceof NanoDuration n) {
            return nanos() == n.nanos();
        }
        return false;
    }

    @Override
    public int hashCode() {
        return Long.hashCode(nanos());
    }

    public static void main(String[] args) {
        Duration d = new Duration(5, 10);
        NanoDuration nd = new NanoDuration(5, 10, 15);
        System.out.println("d.equals(nd): " + d.equals(nd));
        System.out.println("nd.equals(d): " + nd.equals(d));
    }

The hash code comes from the length again. nanos() is a long, and Long.hashCode folds it into the int that hashCode returns. Here is the run:

$ javac Duration.java NanoDuration.java

$ java NanoDuration
d.equals(nd): false
nd.equals(d): false

Both say no, and that is the choice we made. Duration's equals compares whole seconds, and we left it as it was, as you usually must: the class you would extend is older code, someone else's, or the library's, with clients that rely on its equals. A finer value cannot be added to such a class by extending it. It gets its own class, and a Duration and a NanoDuration never compare equal.

Java does offer a way to keep the subclass: compare getClass(), an object's exact class, instead of using instanceof, so that an object equals only objects of that class. Then a subclass that adds nothing to the abstract value is unequal to its superclass too, and the question the subtyping rule asks, whether the subclass keeps every promise, goes unasked. We do not use it.

Back to the queues

q1 and q2 stand for the same queue, and q1.equals(q2) printed false. Duration showed the fix: an equals and a hashCode written from the abstract value. But Duration is one class, and a help queue has several, so a LinkedQueue and a FasterQueue have to agree with each other. And a Duration never changes, while a queue does.

Two classes, one specification. A LinkedQueue holding bob, eve and a FasterQueue holding bob, eve stand for the same queue, so they should be equal, whatever their fields look like. The JDK's lists are the precedent. java.util.List leaves each implementation free to store its elements as it likes, and its documentation of equals says:4

In other words, two lists are defined to be equal if they contain the same elements in the same order. This definition ensures that the equals method works properly across different implementations of the List interface.

So an ArrayList can equal a list of any other class. For help queues the same promise has to be made where every implementation sees it: in HelpQueue's specification. Every class already has equals and hashCode from Object, so the interface declares the two methods only to say what they must do:

EqualQueues.java
    /**
     * @return whether o is a HelpQueue with the same students
     *     waiting, in the same order
     */
    boolean equals(Object o);

    /** @return the hash code of the list waiting returns */
    int hashCode();

Then each class writes them. Here are LinkedQueue's, and FasterQueue's are the same, line for line:

EqualQueues.java
    @Override
    public boolean equals(Object o) {
        if (o instanceof HelpQueue q) {
            return waiting().equals(q.waiting());
        }
        return false;
    }

    @Override
    public int hashCode() {
        return waiting().hashCode();
    }

Two things to read, again:

  • o instanceof HelpQueue q, not instanceof LinkedQueue. The specification says any HelpQueue, so a FasterQueue has to count.
  • waiting().equals(q.waiting()) compares the two lists of students, front first. Each list is a Java value that stands in for its queue's abstract value, so comparing the lists compares the queues, and List's equals compares them element by element, in order. hashCode is the list's hash code, which List computes from the elements, so equal queues hash alike.

Here is the opening program again, with its main grown by two questions: whether a set holding q2 finds q1, and what happens after dan joins q2:

EqualQueues.java
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class EqualQueues {
    public static void main(String[] args) {
        // The same two queues as before: ada, bob, and eve join q1
        // and ada is served; bob and eve join q2.
        HelpQueue q1 = new FasterQueue();
        q1.join("ada");
        q1.join("bob");
        q1.join("eve");
        q1.next();
        HelpQueue q2 = new LinkedQueue();
        q2.join("bob");
        q2.join("eve");

        // Does each say it equals the other?
        System.out.println("q1.equals(q2): " + q1.equals(q2));
        System.out.println("q2.equals(q1): " + q2.equals(q1));

        // Does a set holding q2 find q1?
        Set<HelpQueue> set = new HashSet<HelpQueue>();
        set.add(q2);
        System.out.println("set.contains(q1): " + set.contains(q1));

        // Then dan joins q2. Are the two still equal, and does the
        // set still find q2?
        q2.join("dan");
        System.out.println("q1.equals(q2): " + q1.equals(q2));
        System.out.println("set.contains(q2): " + set.contains(q2));
    }

Write down the five lines. Here is the run:

$ javac EqualQueues.java

$ java EqualQueues
q1.equals(q2): true
q2.equals(q1): true
set.contains(q1): true
q1.equals(q2): false
set.contains(q2): false

The first three lines are what we wanted. Each queue equals the other, across two classes, and a HashSet holding q2 finds q1, since both report the hash code of the list bob, eve.

One class forgets. The interface's specification binds every implementation, and nothing checks that each one kept it. So suppose one had not. Here is the run of a copy of the program, EqualQueuesOneSided.txt in the public repo, in which LinkedQueue declares no equals and no hashCode, so it keeps Object's, while HelpQueue still specifies both and FasterQueue still writes them, and whose main stops after the first two questions. javac accepts the copy, since the two methods inherited from Object implement the declarations:

$ javac EqualQueues.java

$ java EqualQueues
q1.equals(q2): true
q2.equals(q1): false

That is NanoDuration's symmetry break again, between two implementations of one type: FasterQueue's equals counts the LinkedQueue as equal, and LinkedQueue's, still Object's, counts nothing but itself. A client of HelpQueue that relied on the specification, say by keeping queues in a HashSet, can now be wrong when a LinkedQueue reaches it. So the promise in the interface rests on every class that implements it: the two here, and every one written later. Each of them has to write both methods from the abstract value, and javac does not check that it did.

A queue that changes. Now the last two lines. q2.join("dan") changed q2: it stands for the queue bob, eve, dan now, while q1 still stands for bob, eve. So the fourth line, q1.equals(q2), prints false, as it should. The fifth line is the surprise. set.contains(q2) prints false, and q2 is the only object in the set. Here is what happened, step by step:

  • When set.add(q2) ran, the set asked q2 for its hash code. q2 stood for bob, eve, so hashCode returned the hash code of that list, and the set filed q2 under that number.
  • Then dan joined. The set was not told, and it does not ask again: q2 stays filed under the old number.
  • Now set.contains(q2) asks q2 for its hash code. q2 stands for bob, eve, dan, so hashCode returns the hash code of that list, a different number.
  • The set looks for an element filed under the new number. There is none, so it answers false.

The set did nothing wrong, and java.util.Set's documentation says so:5

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.

So an equals written from the abstract value has a cost on a class whose abstract value can change. Classes you know take the two ways around it. Duration never changes, and neither does String, so a set of either never loses track of one: that is why FasterQueue's set of NetIDs is safe. A StringBuilder, a string under construction, does change, and it keeps Object's equals, so two builders are equal only when they are one object. List took neither way, and every set of lists carries the warning above. A help queue is a list of students that changes, and we gave it List's choice, so Set's warning, quoted above, applies to it too: a queue that sits in a HashSet must not change while it is there.

Exercises

Each answer is folded under its question. Write yours first.

1. == on two arrays asks whether they are one array. Suppose you may not write == between two arrays. Write a method with this specification that answers the question anyway:

Same.java
    /**
     * @requires a and b each have at least one element
     * @return whether a and b are the same array
     */
    static boolean same(int[] a, int[] b) {

Why does the specification ask for an element in each array? Then predict the five lines this main prints:

Same.java
    public static void main(String[] args) {
        int[] a = {1, 2, 3};
        int[] b = a;
        int[] c = {1, 2, 3};
        System.out.println("same(a, b): " + same(a, b));
        System.out.println("same(a, c): " + same(a, c));
        System.out.println("a[0]: " + a[0]);
        System.out.println("a.equals(c): " + a.equals(c));
        System.out.println("Arrays.equals(a, c): " + Arrays.equals(a, c));
    }
Answer
  • A change to one array shows in the other exactly when they are one array. So same changes a and looks at b:

    Same.java
        static boolean same(int[] a, int[] b) {
            int saved = a[0];
            a[0] = b[0] + 1;
            boolean same = b[0] == a[0];
            a[0] = saved;
            return same;
        }
    

    It saves a's first element, writes into a[0] a value that b[0] does not hold, and reads b[0]. If a and b are one array, the read sees the new value. If they are two, b[0] is as it was. Then same puts a's element back, so the caller's arrays are as they were. (The == in b[0] == a[0] compares two ints, not two arrays.)

  • Two empty arrays have no element to change. That is why the specification requires one in each.

  • The run. b is a second name for the array a names, and c is a second array with the same elements:

    $ javac Same.java
    
    $ java Same
    same(a, b): true
    same(a, c): false
    a[0]: 1
    a.equals(c): false
    Arrays.equals(a, c): true
    

    The last two lines are the lecture's question asked of arrays. An array's equals is Object's, so a.equals(c) is false for two arrays with the same elements. The comparison by value has another name, Arrays.equals, which compares the elements in order.

  • This is why a class whose objects change may keep Object's equals. Two changing objects with the same contents can be told apart, by changing one, as same just did. Two objects that never change can be told apart only by ==.

2. Object's specification says that equal objects have equal hash codes. Take two objects a and b of some class that keeps that specification, not Duration in particular, and ask two questions of them: does a.equals(b) return true, and is a.hashCode() the same int as b.hashCode()? There are four ways the two answers can come out. Which of the four can a program observe, and which can it not?6

Answer
  • Three of the four. The one the specification forbids is equals true with the hash codes different:

    a.equals(b) hash codes equal can a program observe it?
    true yes yes: d1 and d2 in Duration's last run
    true no no: equal objects must have equal hash codes
    false yes yes: two values can share a hash code
    false no yes: the usual case for two unequal objects
  • false with equal hash codes is allowed. hashCode's specification does not require unequal objects to have distinct hash codes, and it could not: a hash code is one int, and most types have more values than there are ints. A HashSet allows for it by calling equals on what it finds in a bucket with the same hash code.

  • true with different hash codes is the bug the lecture met. The override without a hashCode printed d1.equals(d2) as true while d1 and d2 reported different hash codes, and a HashSet holding one could not find the other.

3. Here is one more subclass of Duration. It adds nothing to its objects. The class counts how many of them have been made:

CountedDuration.java
/** An immutable span of time. The class counts the objects it has made. */
public class CountedDuration extends Duration {
    // How many CountedDurations have been made: a fact about the
    // class, not part of any object's abstract value.
    private static int created = 0;

    /**
     * @requires 0 <= min and 0 <= sec
     * @effects makes the span of min minutes and sec seconds
     */
    public CountedDuration(int min, int sec) {
        super(min, sec);
        created++;
    }

    /** @return how many CountedDurations have been made */
    public static int created() {
        return created;
    }

    public static void main(String[] args) {
        Duration d = new Duration(5, 10);
        CountedDuration cd = new CountedDuration(5, 10);
        System.out.println("d.equals(cd): " + d.equals(cd));
        System.out.println("cd.equals(d): " + cd.equals(d));
        System.out.println("CountedDuration.created(): " + created());
    }
}

Should CountedDuration override equals? Is it a behavioral subtype of Duration, where NanoDuration was not? Predict the three lines main prints.

Answer
  • No: Duration's equals is already right for it. A CountedDuration stands for a span of whole seconds, the same kind of value as a Duration, and the count belongs to the class, not to any object. Duration's equals asks instanceof Duration, which a CountedDuration is, and compares seconds(), so it gives the right answer in both directions. hashCode, inherited too, is the length, so cd lands where an equal Duration would in a HashSet. Here is the run:

    $ javac Duration.java CountedDuration.java
    
    $ java CountedDuration
    d.equals(cd): true
    cd.equals(d): true
    CountedDuration.created(): 1
    
  • The NanoDuration argument does not apply. NanoDuration added to the abstract value, a span of nanoseconds, so "the same value" had no answer between it and a Duration. CountedDuration adds nothing to the abstract value. With getClass in Duration's equals, cd would never equal d, though the two stand for the same span: that is the cost the section When no answer exists named.

  • It is a behavioral subtype. It overrides nothing, so every method keeps Duration's promises word for word, and its objects never change, as a Duration's do not.

  • The count is 1. main made one CountedDuration. d is a Duration and not a CountedDuration, so the class did not count it.

Further reading

  • The contract, and why a subclass cannot add to the value. Joshua Bloch, Effective Java, third edition (2018), chapter 3, "Methods Common to All Objects", items 10 and 11. Item 10, "Obey the general contract when overriding equals", works through the five promises with examples, shows that no subclass of an instantiable class can add to the value and keep the contract, the trouble of the section When no answer exists, weighs the getClass check and rejects it, and arrives at the composition this lecture used. Item 11, "Always override hashCode when you override equals", is the rule of the section The override, and the hash code, with a recipe for a hash code built from several fields.
  • Equality as the abstraction function. MIT 6.031, Software Construction, Reading 15, "Equality" (2021). It defines equality of an immutable type as AF(a) = AF(b), the definition box's idea in symbols, and names two notions of equality for a type whose objects change, observational and behavioral, that this lecture left unnamed.
  • Mutable against immutable. Barbara Liskov and John Guttag, Program Development in Java (2001), chapter 5, "Data Abstraction", section 5.4, "Additional Methods". Its rule for equals is the strict form of what the last section weighed: mutable objects are equal only when they are the same object, and such a type may inherit Object's equals; immutable objects are equal when they have the same state, and such a type must write its own. Its similarity, the same state now, is the weaker notion a changing queue's equals computes.

  1. The documentation of Object.equals in Java 25. Its first sentence puts "equal to" in quotation marks: Object leaves the meaning to each class. ↩

  2. Michael Ernst's lecture on equality for this course (2018) shows Duration with its microwave representation and the equals that takes a Duration. ↩

  3. The documentation of Object.hashCode in Java 25. ↩

  4. The documentation of List.equals in Java 25. ↩

  5. The documentation of java.util.Set in Java 25, in its description of the interface. ↩

  6. Exercises 2 and 3 follow Michael Ernst's: a question on his first midterm for this course (2018) asked which combinations of equals, ==, hashCode, and toString a correct program can produce, and his lecture on equality that year gave CountedDuration. ↩