oop Lesson 4 20 min read

toString, equals, and hashCode

The three methods every Java class needs

Reading: Reges & Stepp: Ch. 8

After this lesson, you will be able to:

  • Explain why the default toString() output is useless and write a custom one
  • Distinguish between == (reference equality) and .equals() (value equality)
  • Write a correct equals() method using the standard five-step recipe
  • Explain the equals/hashCode contract and why violating it breaks collections
  • Implement hashCode() using Objects.hash()
  • Build a complete Student class with all three methods

The Mystery Hash

Create a Student object and print it:

Student alice = new Student("S001", "Alice");
System.out.println(alice);

Output:

Student@6d06d69c

That is the class name followed by an @ sign and a hexadecimal number. It is the default toString() inherited from Java’s Object class, and it tells you almost nothing useful. You wanted to see "Alice (ID: S001)". Instead you got a memory address.

This is the first of three problems that every Java class must solve. The default behavior inherited from Object is almost never what you want. You need to override three methods: toString(), equals(), and hashCode().


Problem 1: toString() — Making Objects Printable

When you call System.out.println(someObject), Java automatically calls someObject.toString(). The default implementation from Object produces that useless ClassName@hexHash output. Override it to return something meaningful.

Writing a Custom toString()

public class Student {
    private final String studentId;
    private String name;
    private double gpa;
    private int credits;

    public Student(final String studentId, final String name) {
        if (studentId == null || studentId.trim().isEmpty()) {
            throw new IllegalArgumentException("ID cannot be null or empty");
        }
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("Name cannot be null or empty");
        }
        this.studentId = studentId;
        this.name = name;
        this.gpa = 0.0;
        this.credits = 0;
    }

    // Getters and setters omitted for brevity

    @Override
    public String toString() {
        return String.format("%s (ID: %s, GPA: %.2f, Credits: %d)",
            name, studentId, gpa, credits);
    }
}

Now:

Student alice = new Student("S001", "Alice");
alice.setGpa(3.8);
alice.setCredits(45);
System.out.println(alice);  // Alice (ID: S001, GPA: 3.80, Credits: 45)

The @Override annotation tells the compiler you intend to replace the parent class’s method. If you misspell the method name (say, tostring() with a lowercase S), the compiler will catch the mistake and give you an error instead of silently creating a new, unused method.

From CSCD 110: In Python, you define __str__ to control what print() shows. Java’s equivalent is toString(). Same idea, different syntax: def __str__(self) becomes public String toString(). Both are called automatically when you print an object.

Check Your Understanding
What does System.out.println(someObject) print if you have NOT overridden toString()?</p>
AThe values of all fields
BAn empty string
CThe class name followed by @ and a hex number
DA compilation error
The default toString() inherited from Object returns something like Student@6d06d69c. It shows the class name and a hash of the memory address. Override toString() to produce useful output.
--- ## Problem 2: equals() --- Comparing Objects by Content Now for a subtler problem. Create two `Student` objects with identical data: ```java Student alice1 = new Student("S001", "Alice"); alice1.setGpa(3.8); Student alice2 = new Student("S001", "Alice"); alice2.setGpa(3.8); System.out.println(alice1 == alice2); // false System.out.println(alice1.equals(alice2)); // false (!) ``` Both are `false`. The `==` operator compares **references** --- it asks "are these the exact same object in memory?" Since `alice1` and `alice2` were created separately with `new`, they are two different objects. Different memory addresses, so `==` returns `false`. But `equals()` also returns `false`. Why? Because the default `equals()` inherited from `Object` is identical to `==`: ```java // Default equals() from Object public boolean equals(Object obj) { return this == obj; // Just reference comparison! } ``` To compare by content, you must override `equals()`. ### The Five-Step equals() Recipe There is a standard recipe that satisfies Java's contract for `equals()`. Follow it exactly: ```java @Override public boolean equals(final Object obj) { // 1. Same reference? Short-circuit to true. if (this == obj) { return true; } // 2. Null? Never equal to null. if (obj == null) { return false; } // 3. Different class? Not equal. if (getClass() != obj.getClass()) { return false; } // 4. Cast to our type. Student other = (Student) obj; // 5. Compare all relevant fields. return Objects.equals(this.studentId, other.studentId) && Objects.equals(this.name, other.name) && Double.compare(this.gpa, other.gpa) == 0 && this.credits == other.credits; } ``` The field comparison rules: - **Object fields** (String, etc.): use `Objects.equals(a, b)` --- handles `null` safely. - **Primitive int, long, boolean**: use `==` directly. - **Primitive double, float**: use `Double.compare(a, b) == 0` --- avoids floating-point precision traps. > **Key Insight:** `==` tests whether two variables point to the **same object** (reference equality). `equals()` tests whether two objects contain the **same data** (value equality). They answer different questions. For objects, you almost always want `equals()`. Now it works: ```java System.out.println(alice1.equals(alice2)); // true! System.out.println(alice1 == alice2); // still false (different objects) ``` Memory diagram: ``` Stack Heap ┌──────────┐ │ alice1: ──┼──────────► [ Student: "S001", "Alice", 3.8, 0 ] ├──────────┤ │ alice2: ──┼──────────► [ Student: "S001", "Alice", 3.8, 0 ] └──────────┘ Two separate objects, same content. == returns false. equals() returns true. ```
Check Your Understanding
Given two Student objects with identical fields created by separate new calls, what do == and equals() return (assuming equals() is properly overridden)?</p>
ABoth return true
BBoth return false
C== returns false, equals() returns true
D== returns true, equals() returns false
== compares references (memory addresses). Since two separate new calls create two separate objects, == is false. A properly overridden equals() compares field values, so it returns true when all fields match.
--- ## The equals() Contract Java specifies strict rules that `equals()` must follow. If you violate these, collections and algorithms that depend on equality will behave unpredictably. | Property | Requirement | |----------|-------------| | **Reflexive** | `x.equals(x)` is always `true` | | **Symmetric** | If `x.equals(y)`, then `y.equals(x)` | | **Transitive** | If `x.equals(y)` and `y.equals(z)`, then `x.equals(z)` | | **Consistent** | Repeated calls return the same result (if the objects have not changed) | | **Null-safe** | `x.equals(null)` is always `false` | The five-step recipe satisfies all five properties automatically. Follow the recipe and you are safe. ### Why the Contract Matters Java's `ArrayList.contains()`, `ArrayList.indexOf()`, and `ArrayList.remove()` all use `equals()` internally to find objects: ```java List roster = new ArrayList<>(); Student alice = new Student("S001", "Alice"); roster.add(alice); // Without equals() override --- fails to find the copy: Student aliceCopy = new Student("S001", "Alice"); System.out.println(roster.contains(aliceCopy)); // false (!) // With equals() override --- finds it: System.out.println(roster.contains(aliceCopy)); // true ``` If `contains()` cannot find objects with matching data, your program logic breaks silently. No exception, no error message --- just wrong results. --- ## Problem 3: hashCode() --- The equals/hashCode Contract If you override `equals()`, you **must** also override `hashCode()`. This is not optional. It is a contract: > If two objects are equal according to `equals()`, they **must** return the same `hashCode()`. The reverse is not required: two unequal objects may have the same hash code (called a *collision*). But equal objects with different hash codes will break `HashMap` and `HashSet`. ### What is a Hash Code? A hash code is an integer that represents an object. Collections like `HashMap` use it as a bucket number to quickly locate objects. Think of it like a library call number --- it tells you which shelf to check. The default `hashCode()` from `Object` is based on the memory address, so two different objects always get different hash codes, even if their content is identical. That violates the contract if you have overridden `equals()` to compare by content. ### Implementing hashCode() with Objects.hash() Java provides `Objects.hash()` to compute a hash code from multiple fields. Include exactly the same fields that you use in `equals()`: ```java import java.util.Objects; @Override public int hashCode() { return Objects.hash(studentId, name, gpa, credits); } ``` That is it. One line. `Objects.hash()` combines all the field values into a single integer. Because it uses the same fields as `equals()`, the contract is satisfied: equal objects produce equal hash codes. > **The Trick:** Always include exactly the same fields in `hashCode()` and `equals()`. If you add a field to one, add it to the other. Let your IDE generate both methods together to avoid this mistake. IntelliJ and Eclipse both have "Generate equals() and hashCode()" as a single menu item.
Check Your Understanding
You override equals() but forget to override hashCode(). What happens when you store the object in a HashMap?</p>
AA compilation error
BA runtime exception
CThe HashMap may fail to find equal objects
DNothing — it works correctly
Without a matching hashCode(), two equal objects may get different hash codes and end up in different buckets. The HashMap looks in the wrong bucket and fails to find the object. No error is thrown --- the lookup just silently returns null. This is one of the most common and hardest-to-debug Java bugs.
--- ## Complete Example: Student with All Three Methods Here is the full `Student` class with `toString()`, `equals()`, and `hashCode()`. This is the standard you should follow for every class you write. ```java import java.util.Objects; public class Student { private final String studentId; private String name; private double gpa; private int credits; public Student(final String studentId, final String name) { if (studentId == null || studentId.trim().isEmpty()) { throw new IllegalArgumentException("ID cannot be null or empty"); } if (name == null || name.trim().isEmpty()) { throw new IllegalArgumentException("Name cannot be null or empty"); } this.studentId = studentId; this.name = name; this.gpa = 0.0; this.credits = 0; } public String getStudentId() { return studentId; } public String getName() { return name; } public void setName(final String newName) { if (newName == null || newName.trim().isEmpty()) { throw new IllegalArgumentException("Name cannot be empty"); } this.name = newName; } public double getGpa() { return gpa; } public void setGpa(final double newGpa) { if (newGpa < 0.0 || newGpa > 4.0) { throw new IllegalArgumentException("GPA must be between 0.0 and 4.0"); } this.gpa = newGpa; } public int getCredits() { return credits; } public void setCredits(final int newCredits) { if (newCredits < 0) { throw new IllegalArgumentException("Credits cannot be negative"); } this.credits = newCredits; } @Override public String toString() { return String.format("%s (ID: %s, GPA: %.2f, Credits: %d)", name, studentId, gpa, credits); } @Override public boolean equals(final Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } Student other = (Student) obj; return Objects.equals(this.studentId, other.studentId) && Objects.equals(this.name, other.name) && Double.compare(this.gpa, other.gpa) == 0 && this.credits == other.credits; } @Override public int hashCode() { return Objects.hash(studentId, name, gpa, credits); } } ``` Usage demonstrating all three methods: ```java import java.util.ArrayList; import java.util.List; public class StudentDemo { public static void main(final String[] args) { Student alice1 = new Student("S001", "Alice"); alice1.setGpa(3.8); alice1.setCredits(45); Student alice2 = new Student("S001", "Alice"); alice2.setGpa(3.8); alice2.setCredits(45); // toString() System.out.println(alice1); // Alice (ID: S001, GPA: 3.80, Credits: 45) // equals() System.out.println(alice1.equals(alice2)); // true System.out.println(alice1 == alice2); // false // hashCode() System.out.println(alice1.hashCode() == alice2.hashCode()); // true // Collections work correctly List roster = new ArrayList<>(); roster.add(alice1); System.out.println(roster.contains(alice2)); // true (found by content!) } } ``` --- ## Choosing Which Fields to Include Not every field necessarily belongs in `equals()` and `hashCode()`. The question is: what defines the identity of this object? For a `Student`, you might decide that the `studentId` alone defines identity --- two students with the same ID are the same student, regardless of GPA or credits. In that case: ```java @Override public boolean equals(final Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } Student other = (Student) obj; return Objects.equals(this.studentId, other.studentId); } @Override public int hashCode() { return Objects.hash(studentId); } ``` This is a design decision. For this course, include all fields unless you have a specific reason not to. The important rule is: **the same fields must appear in both methods**. > **Common Pitfall:** Including a field in `equals()` but forgetting it in `hashCode()` (or vice versa) violates the contract. Two objects could be equal but have different hash codes. Always generate or update both methods together.
Check Your Understanding
In the equals() method, how should you compare two double fields?</p>
AUsing ==
BUsing Double.compare(a, b) == 0
CUsing Objects.equals(a, b)
DUsing Math.abs(a - b) < 0.001
Floating-point comparison with == is unreliable due to precision issues. Double.compare(a, b) == 0 is the standard approach that correctly handles edge cases like NaN and negative zero. Use == for int and other integer primitives, and Objects.equals() for object fields like String.
--- ## Quick Reference | Task | Code | |------|------| | Override toString | `@Override public String toString() { return String.format(...); }` | | Reference equality | `a == b` (same object in memory?) | | Value equality | `a.equals(b)` (same content?) | | Compare Strings in equals | `Objects.equals(this.name, other.name)` | | Compare int in equals | `this.credits == other.credits` | | Compare double in equals | `Double.compare(this.gpa, other.gpa) == 0` | | Hash code from fields | `Objects.hash(field1, field2, field3)` | | Required import | `import java.util.Objects;` | --- ## Summary Every Java class inherits three methods from `Object` that do the wrong thing by default. `toString()` prints a useless hex hash. `equals()` compares references instead of content. `hashCode()` derives from the memory address. Override all three. `toString()` uses `String.format()` to produce readable output. `equals()` follows the five-step recipe: check reference, check null, check class, cast, compare fields. `hashCode()` uses `Objects.hash()` with the same fields as `equals()`. The equals/hashCode contract is non-negotiable: equal objects must have equal hash codes. Violating it causes silent data loss in collections. The easiest way to stay safe is to generate both methods together using your IDE and include exactly the same fields in both. With `toString()`, `equals()`, and `hashCode()` in place, your objects print clearly, compare correctly, and work reliably in `ArrayList`, `HashMap`, and every other collection in the Java standard library. **Next lesson:** How do you know your classes work correctly? We will write automated tests with JUnit to verify constructors, setters, equals, and edge cases.