Module 5 · Object-Oriented Fundamentals ⏱ 18 min

equals, hashCode, and toString Contracts

By the end of this lesson you will be able to:
  • Override equals so objects compare by value instead of by identity
  • Explain why equal objects must share a hashCode, and predict HashSet membership
  • Override toString and predict what println displays

Suppose you build two Point objects with the same coordinates and ask whether they are equal. Java's first answer is surprising: the == operator returns false. That is because == does not look at the numbers inside the objects — it compares the references, the memory addresses where each object lives. Two objects built separately live at two addresses, so == sees them as different even when they hold identical data.

For value objects — a point, a money amount, a user identifier — you almost never want identity. You want to know whether two objects mean the same thing. Java gives every object a method called equals for exactly that question, and learning to override it correctly is one of the most consequential skills in everyday Java. The reason is practical: the collections you will reach for constantly, such as HashSet and HashMap, rely on equals and its partner hashCode to decide whether two keys match.

flowchart LR
  A["Point a = new Point(1,2)"]
  B["Point b = new Point(1,2)"]
  A --> C["identity check<br/>a == b"]
  B --> C
  C --> D["result: false<br/>two separate objects"]
  A --> E["value check<br/>a.equals(b)"]
  B --> E
  E --> F["result: true<br/>both hold 1 and 2"]
== compares references; equals compares the values inside the objects.

The default equals is identity

The version of equals you inherit from Object behaves exactly like ==: it returns true only when both sides refer to the exact same object. That is safe but useless for comparing values. When you want two Point(1, 2) objects to count as equal, you override equals yourself.

A correct override follows a fixed shape. Handle the cheap cases first: an object equals itself, and it never equals null or something of a completely different type. Then compare the fields that define the value — for Point, that is x and y. The parameter is always typed Object, so you cast to your own type after confirming it with instanceof. Getting this shape right matters because every hash-based collection will eventually call your equals to decide whether two keys are the same.

Main.java — a Point that compares by value. Real, compiling Java.
public class Main {
    static class Point {
        int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Point)) return false;
            Point p = (Point) o;
            return x == p.x && y == p.y;
        }
    }

    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);
        System.out.println(a == b);
        System.out.println(a.equals(b));
    }
}

The hashCode contract

Overriding equals is only half the job. The other half is a method called hashCode, and the rule binding them is strict: if a.equals(b) is true, then a.hashCode() must equal b.hashCode(). Equal objects must produce equal hash codes.

A hash code is an integer used to spread objects across buckets inside collections such as HashSet and HashMap. When you ask whether a set contains an object, it does not compare your object against every element. It computes the hash code, jumps straight to the matching bucket, and runs equals only on the few objects sitting there. If two equal objects had different hash codes, they would land in different buckets, and the set could never find one when handed the other — even though equals says they match. Notice the contract runs one way only: unequal objects are allowed to share a hash code, because buckets may hold several items and equals settles the rest.

flowchart TD
  A["rule: if a.equals(b)<br/>then hashCode must match"]
  A --> B["equal objects<br/>must share a bucket"]
  B --> C["Point(1,2) hashed<br/>into bucket A"]
  B --> D["a second Point(1,2)<br/>also lands in bucket A"]
  E["Point(9,9) has<br/>different field values"] --> F["may land in<br/>a different bucket"]
The contract: equal objects must hash to the same bucket; unequal objects merely may.
Main.java — equals and hashCode overridden together so the contract holds. Real, compiling Java.
public class Main {
    static class Point {
        int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Point)) return false;
            Point p = (Point) o;
            return x == p.x && y == p.y;
        }

        public int hashCode() {
            return 31 * x + y;
        }
    }

    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);
        System.out.println(a.hashCode() == b.hashCode());
    }
}

toString: what gets printed

Every object inherits a toString method that converts it to text. When you pass an object to System.out.println, or concatenate it onto a String with +, Java calls toString automatically. You do not have to call it yourself.

The default toString is barely useful: it returns the class name followed by @ and the identity hash code in hexadecimal, something like Point@1a2b3c4d. That tells you the type but nothing about the data inside. Overriding toString to return something readable — such as (1, 2) for a point, or $10.99 for a money amount — turns your objects into something you can actually read in a log or a debugger. It is the smallest method with the largest payoff for debugging: a good toString turns a wall of cryptic identifiers into meaningful output the moment something goes wrong.

flowchart LR
  Q["set.contains(new Point(1,2))"] --> S["step 1: hashCode<br/>chooses the bucket"]
  S --> T["step 2: equals()<br/>confirms a match"]
  T --> U["result: true"]
  Q --> V["wrong hashCode<br/>wrong bucket chosen"]
  V --> W["equals never runs<br/>result: false"]
HashSet finds the bucket with hashCode, then confirms with equals. Break hashCode and equals never runs.
Main.java — overriding toString so println and string concatenation show readable values. Real, compiling Java.
public class Main {
    static class Point {
        int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        public String toString() {
            return "(" + x + ", " + y + ")";
        }
    }

    public static void main(String[] args) {
        Point p = new Point(1, 2);
        System.out.println(p);
        System.out.println("The point is " + p);
    }
}
Exercise

Value equality. Read each comparison and predict the exact four-line output.

public class Main {
    static class Point {
        int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Point)) return false;
            Point p = (Point) o;
            return x == p.x && y == p.y;
        }
    }

    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);
        Point c = new Point(3, 4);
        System.out.println(a == b);
        System.out.println(a.equals(b));
        System.out.println(a.equals(c));
        System.out.println(a.equals("no"));
    }
}
Exercise

Which statement is a genuine part of the contract between equals and hashCode?

Exercise

You override equals but forget hashCode. You add new Tag("red") to a HashSet, then call set.contains(new Tag("red")). What is the most likely result and why?

Exercise

The contract honoured. With equals and hashCode both overridden, predict the exact three-line output.

import java.util.HashSet;

public class Main {
    static class Point {
        int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Point)) return false;
            Point p = (Point) o;
            return x == p.x && y == p.y;
        }

        public int hashCode() {
            return 31 * x + y;
        }
    }

    public static void main(String[] args) {
        HashSet<Point> set = new HashSet<>();
        set.add(new Point(1, 2));
        set.add(new Point(1, 2));
        set.add(new Point(3, 4));
        System.out.println(set.size());
        System.out.println(set.contains(new Point(1, 2)));
        System.out.println(set.contains(new Point(9, 9)));
    }
}
Exercise

toString in action. toString is called by println and by string concatenation. Predict the exact three-line output.

public class Main {
    static class Money {
        int cents;
        Money(int cents) { this.cents = cents; }

        public String toString() {
            return "$" + (cents / 100) + "." + String.format("%02d", cents % 100);
        }
    }

    public static void main(String[] args) {
        Money a = new Money(1099);
        Money b = new Money(5);
        System.out.println(a);
        System.out.println(b);
        System.out.println("Total: " + a + " and " + b);
    }
}

Recap

  • == compares references; equals compares values. Override equals for any value object.
  • A correct equals handles self, type, and null, then compares the fields that define the value.
  • The contract: if a.equals(b), then a.hashCode() == b.hashCode(). Equal objects must share a bucket.
  • HashSet and HashMap find a bucket with hashCode, then confirm with equals.
  • Overriding equals without hashCode makes equality look right while silently breaking every hash-based collection.
  • Override toString so println and string concatenation show readable values, not Class@hexhash.

Next you will organise many classes into packages and learn how import brings names into scope.

Checkpoint quiz

You override equals but leave hashCode inherited from Object. What breaks first?

What does System.out.println(obj) print if obj's class does not override toString?

Go deeper — technical resources