Defining Equality for Student

Our search now calls .equals, but roster.contains(...) still returns false. To fix it, we first need to understand where Student’s equals comes from, since we never wrote one.

Every class inherits equals

You already met the Object class when we considered storing everything as Object instead of using generics. It sits at the top of Java’s type hierarchy, and every class you write ultimately inherits from it. What I did not say then is that this inheritance also gives you a few methods, whether you write them or not.

One of those inherited methods is equals. So even though we never gave Student an equals, it has one, inherited from Object. That is why arr[i].equals(value) compiled in the first place.

The problem is what that inherited equals actually does. Object’s version compares references, the very same thing == does. So student1.equals(student2) is only true when both refer to literally the same object. That is why switching to .equals did not change the answer.

Overriding equals

To make equals mean what we want, we override it. That is, we write our own version in Student, and it replaces the one inherited from Object.

@Override
public boolean equals(Object o) {
  if (this == o) {
    return true;
  }
  if (o == null || getClass() != o.getClass()) {
    return false;
  }
  Student other = (Student) o;
  return id == other.id;
}

There is some boilerplate in there, but the idea is small: two students are the same student when their ids match, whatever their names and GPAs are. That is what the line return id == other.id; does.

Let’s go through the rest of it, because the same shape shows up every time you override equals on a class of your own.

Why is the parameter an Object?

Because we are overriding Object’s equals, and its parameter is an Object. To replace a method, our version must match its signature exactly. If we wrote equals(Student other) instead, that would be a different method. Then code that calls .equals through an Object reference, which is what our indexOf does, would still call the inherited version which accepts Object. This would still contain the default implementation that compares references, effectively acting the same as ==. What do the first three lines do?

  • if (this == o) { return true; } — if it is literally the same object, it is certainly equal. This is a shortcut that saves the rest of the work.
  • if (o == null || getClass() != o.getClass()) { return false; } — if o is null, or is not exactly a Student, it cannot be an equal student.
  • Student other = (Student) o; — now that we know it is a Student, we can cast it and read its fields.
Aside

You might be tempted to write if (!(o instanceof Student)) { return false; } for the second line instead. The reason to prefer getClass() is symmetry. Symmetry means that if a.equals(b) is true, then b.equals(a) has to be true as well. Suppose someone writes a subclass of Student that adds a field and overrides equals to compare it. With instanceof, a plain Student would accept the subclass instance as equal, because the subclass instance is an instance of Student. The subclass, comparing its extra field, would say they are not equal. So a.equals(b) and b.equals(a) disagree. Collections like HashSet assume they never disagree, and they behave unpredictably when that assumption breaks. The getClass() check rules the case out: only two objects of the exact same class can be equal.

Override hashCode too

equals is not the only method Student inherits from Object. There is a second one we have to override, called hashCode. It returns an int for an object. What that number is used for is a topic for later in the course, when we build hash tables. Right now we only need one rule that Java attaches to it: if two objects are equal according to equals, they must return the same number from hashCode.

The version of hashCode inherited from Object derives its number from the object’s identity (memory location), so two objects created separately with new almost always get different numbers. That was fine while Student used the inherited equals, because separately created students were not equal either. Once we overrode equals, that stopped being true. Two students with the same id are now equal, and they still return different hash codes. That breaks the rule.

Collections like HashSet and HashMap rely on the rule. They use the hash code to decide where to look for an object, and if two equal objects hash differently, the collection looks under the hash code of the object you passed in, not the hash code of the object it stored, and reports that an object it contains is not there. It is the same kind of bug we just fixed in contains.

So when we override equals, we override hashCode to match. Ours is built from the same field equals uses:

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

Integer.hashCode turns an int into a hash code for us, which is enough here, since we do not yet know how hash codes are built. What we need from it is that two students with the same id get the same number. Those are the students our equals says are equal.

The bug is fixed

Now Student defines equality by id, and our search compares with .equals. Running the check one more time:

roster.add(new Student(1, "Ada", 3.9));
roster.contains(new Student(1, "Ada", 3.9));  // true

The two objects are still distinct in memory, but they are equal in the sense we care about: they represent the same student.