Your classes already model the domain, protect their invariants and expose a minimal API. And yet, if you print a book you get this: com.nexussoftware.bibliotech.domain.Book@1b6d3586. And if you compare two copies of "Effective Java" with the same ISBN, Java tells you they are not equal. Both behaviours come from the same place: the Object class, root of Java's whole hierarchy, which provides default implementations of a set of universal methods. Those implementations work, but they are deliberately conservative: toString does not know what is important about your class and equals does not know what "equal" means in your domain. Only you can say. This lesson teaches you to do it properly, with the complete contract of each method, because a badly written equals and hashCode produce bugs that do not show up until your objects enter a collection, and by then the failure looks like black magic. And by the end, you will close the module by keeping the promise you left at the end of module 2.

Contents

  1. Object, the root of everything
  2. A tour of Object's methods
  3. toString: what that @1b6d3586 is
  4. Overriding toString correctly
  5. equals: reference equality versus logical equality
  6. The equals contract
  7. Implementing equals step by step
  8. The classic mistake: equals(Book) instead of equals(Object)
  9. hashCode: the contract and why it always goes with equals
  10. Implementing hashCode with Objects.hash
  11. What breaks if you do not override hashCode
  12. getClass() versus instanceof in equals
  13. Objects.equals and Objects.requireNonNull
  14. Closing the module: BiblioTech's final refactoring
  15. Common Mistakes and Tips
  16. Exercises

  1. Object, the root of everything

As you saw in 03-05, every Java class inherits from java.lang.Object, directly or indirectly. If you do not write extends, the compiler puts it in for you.

classDiagram
    Object <|-- Material
    Object <|-- Employee
    Object <|-- Loan
    Object <|-- String
    Material <|-- Book
    Material <|-- Magazine
    Material <|-- Dvd
    class Object {
        +toString() String
        +equals(Object) boolean
        +hashCode() int
        +getClass() Class
        #clone() Object
    }

This has two practical consequences you have already benefited from without knowing it:

  • Every object has Object's methods. You can call toString() on anything.
  • Object serves as a universal common type. An Object parameter accepts any object, and that is why System.out.println(Object) works with whatever you throw at it. It is the polymorphism of 03-06 taken to the limit.

  1. A tour of Object's methods

Method What it does by default Is it overridden?
toString() Returns ClassName@hexHash Almost always
equals(Object) Compares references (==) When equality is logical
hashCode() Returns an integer derived from identity Whenever you override equals
getClass() Returns the object's actual class No (it is final)
clone() Shallow copy; requires Cloneable Discouraged: use a copy constructor (03-04)
finalize() Was invoked before collection Deprecated since Java 9; never use it
wait(), notify(), notifyAll() Coordination between threads No; they are studied in module 8

Two clarifications about the problematic rows:

clone() is discouraged. Its design forces you to implement the marker interface Cloneable, produces shallow copies by default and has a confusing interaction with final and inheritance. The alternative is the one you already know: a copy constructor.

finalize() must never be used. It has been deprecated since Java 9 and removed from practical use: there is no guarantee that it will run, nor when, nor even that it will run at all. To release resources there is try-with-resources, studied in lesson 06-06.

  1. toString: what that @1b6d3586 is

When you concatenate or print an object, Java calls its toString():

Book book = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
System.out.println(book);
com.nexussoftware.bibliotech.domain.Book@1b6d3586

The default implementation in Object is, literally:

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

Broken down:

Part What it is
com.nexussoftware.bibliotech.domain.Book The class's full name, with package
@ A literal separator
1b6d3586 The object's hashCode(), in hexadecimal

And here it is worth clearing up a very widespread misunderstanding: that number is not the memory address. It is the value returned by hashCode(), which in the default implementation derives from the object's identity, but which the JVM can compute in several ways and which does not change even if the garbage collector moves the object elsewhere. It is an identifier, not a position.

As an identifier it works; as information, it is useless. That is why toString is almost always overridden.

  1. Overriding toString correctly

A good toString is short, informative and free of side effects. It must contain the data that identifies the object to a human.

@Override
public String toString() {
    return String.format("Book[isbn=%s, title=%s, author=%s, year=%d, available=%b]",
                         getIsbn(), getTitle(), author, publicationYear, isAvailable());
}
System.out.println(book);
// Book[isbn=978-0000000001, title=Effective Java, author=Joshua Bloch, year=2018, available=true]

Practical rules:

Rule Reason
Include the identifying fields It is what you will search for in a log
Keep it on one line Logs are read and filtered by lines
Do not use it as a data format If somebody parses your toString, you will never be able to change it
Do not include sensitive data Passwords and tokens end up in logs
Do not cause side effects It is called at unexpected moments, even from the debugger
Beware of recursion If Loan.toString() prints the Employee and that prints its loans, you have a StackOverflowError

A useful toString for Loan, delegating to what it already knows:

@Override
public String toString() {
    return String.format("Loan[%s, material=%s, employee=%s, elapsed=%d, fine=%.2f, %s]",
                         reference, getMaterialTitle(), getEmployeeName(),
                         elapsedDays, calculateFine(), classifySeverity());
}
Loan[LN-0001, material=Effective Java, employee=Marta Ruiz, elapsed=20, fine=1.25, MINOR]

That change, on its own, radically improves debugging: the IDE's variables window and any System.out.println start showing readable information instead of @1b6d3586.

  1. equals: reference equality versus logical equality

Let us pick up == from module 1, now with your own objects. There are two notions of equality:

Notion Operator Question it answers
Reference equality (identity) == Are they the same object in memory?
Logical equality (equivalence) equals Do they represent the same thing according to the domain?

With Object's default implementation, both are identical, because Object.equals is exactly this:

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

Hence the surprising behaviour:

Book a = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Book b = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);

System.out.println(a == b);        // false: two different objects
System.out.println(a.equals(b));   // false: the default equals is ==

Is that correct? It depends on what "equal" means in BiblioTech. And that is a business decision, not a technical one:

  • If Book represents a bibliographic work, two objects with the same ISBN are the same book. → equals must be overridden.
  • If Book represents a physical copy, two copies of the same work are different things even if they share an ISBN. → identity equality is correct.

In BiblioTech we adopt the first interpretation: two books are equal if they have the same ISBN. It is the definition the publishing industry itself uses, and it fits our domain vocabulary.

Remember too the module 1 lesson with String: "Effective Java" == otherString can give false even though the text is identical, and that is why you always compared with equals. Now you know exactly why: String does override equals to compare the content.

  1. The equals contract

equals is not just any method: it has a contract that the whole Java standard library assumes you honour. If you break it, module 5's collections will behave unpredictably and it will not be their fault.

For non-null references x, y, z:

Property What it demands Example of a violation
Reflexive x.equals(x) is true Comparing a double field holding NaN
Symmetric If x.equals(y) then y.equals(x) A Book that considers itself equal to a String with its ISBN
Transitive If x.equals(y) and y.equals(z), then x.equals(z) Comparisons that ignore fields in a subclass
Consistent Repeated calls return the same thing if the state does not change Using a mutable or random field
Against null x.equals(null) is false, never throws an error Forgetting the null check

The most frequent violation in real code is symmetry, and it usually appears like this:

// BAD: asymmetric
@Override
public boolean equals(Object obj) {
    if (obj instanceof String) {
        return getIsbn().equals(obj);      // a Book "equal" to a String
    }
    // ...
}
book.equals("978-0000000001");    // true
"978-0000000001".equals(book);    // false   <-- broken

String.equals will never consider a Book equal, so the relation is not symmetric and any collection depending on it will fail in an apparently random way.

Tip derived from the contract: compare only with objects of your own type.

  1. Implementing equals step by step

The canonical structure has five steps, and it is best to always write it the same way:

@Override
public boolean equals(Object obj) {

    // 1. Identity shortcut: the same object is always equal to itself.
    //    It is fast and guarantees the reflexive property.
    if (this == obj) {
        return true;
    }

    // 2. Type check and null check at the same time.
    //    instanceof returns false if obj is null, so it covers the
    //    "x.equals(null) is false" contract without a separate check.
    if (!(obj instanceof Book)) {
        return false;
    }

    // 3. Safe conversion to our own type.
    Book other = (Book) obj;

    // 4. Comparison of the significant fields.
    //    In BiblioTech, a book's identity is its ISBN.
    return getIsbn().equals(other.getIsbn());
}

With Java 16's instanceof with a pattern (lesson 03-06), steps 2 and 3 merge into one:

@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (!(obj instanceof Book other)) return false;
    return getIsbn().equals(other.getIsbn());
}

Check:

Book a = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Book b = new Book("Effective Java", "J. Bloch", "978-0000000001", 2017);      // different data
Book c = new Book("Refactoring", "Martin Fowler", "978-0000000003", 2018);

System.out.println(a.equals(a));      // true   (reflexive)
System.out.println(a.equals(b));      // true   (same ISBN)
System.out.println(b.equals(a));      // true   (symmetric)
System.out.println(a.equals(c));      // false  (different ISBN)
System.out.println(a.equals(null));   // false  (null contract)
System.out.println(a.equals("978-0000000001"));   // false (different type)
System.out.println(a == b);           // false  (still two objects)

Look at the a.equals(b) case: the titles and years differ, but the ISBN matches, so they are the same book. That is exactly what we decided in section 5: equality is defined by the domain, not by all the fields matching.

On which fields to use, three criteria:

  1. Only fields significant to identity. A derived field or one of transient state (like available) must not go in.
  2. Preferably final fields. If a field used in equals changes, the object changes identity, with the consequences from section 11.
  3. Careful with the types: for float and double, use Float.compare / Double.compare instead of ==, because NaN != NaN would break reflexivity, and 0.0 and -0.0 are == but differ at the bit level.

  1. The classic mistake: equals(Book) instead of equals(Object)

This is the mistake you have to see once so as never to repeat it. Look at the signature:

public class Book extends Material {

    // BAD: this does NOT override Object.equals, it OVERLOADS it
    public boolean equals(Book other) {
        return getIsbn().equals(other.getIsbn());
    }
}

It compiles without a single complaint. And it works... sometimes:

Book a = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Book b = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);

System.out.println(a.equals(b));                 // true  <-- uses YOUR method

Object oa = a;
Object ob = b;
System.out.println(oa.equals(ob));               // false <-- uses Object.equals

The same pair of objects, two opposite results. Why?

Because equals(Book) and equals(Object) have different signatures: they are an overload, not an override (table from 03-05). And overloading is resolved at compile time, from the declared type (03-06). With a and b declared as Book, the compiler picks your method; with oa and ob declared as Object, it picks Object's, which compares references.

And the worst part is that all of module 5's collections call equals(Object), because internally they work with generic references. So your method would never be used where it is most needed, and the bug would show up as "the HashSet has duplicate books" with no clue about the cause.

The defence is the annotation you already know:

@Override
public boolean equals(Book other) { ... }
error: method does not override or implement a method from a supertype

One second writing @Override versus an afternoon debugging. This is probably the most compelling of all the reasons we have given for always adding it.

  1. hashCode: the contract and why it always goes with equals

hashCode() returns an integer that summarises the object's content. Its use is in hash-table-based structures —HashMap and HashSet, module 5—, which use it to locate objects quickly without comparing them one by one.

Its contract has three clauses:

Clause Statement
1. Consistency If the object does not change, hashCode() always returns the same value during the run
2. Coherence with equals If x.equals(y) is true, then x.hashCode() == y.hashCode() compulsorily
3. The converse is not required If x.hashCode() == y.hashCode(), x and y do not have to be equal (that is called a collision, and it is normal)

Clause 2 is the one that imposes the golden rule:

Whenever you override equals, override hashCode. No exceptions.

The logic is inescapable: if two objects are "equal" but produce different hash codes, a hash table will put them in different buckets and will never compare them with each other, so it will never discover that they are equal.

Good news: clause 3 means a hashCode does not have to be unique. Collisions can happen; the structure resolves them by comparing with equals inside the bucket. A hashCode that always returned 0 would be correct (it honours the contract) but useless, because it would degrade the HashMap into a linear search.

  1. Implementing hashCode with Objects.hash

The modern, recommended way is one line, using the utility class java.util.Objects:

import java.util.Objects;

@Override
public int hashCode() {
    return Objects.hash(getIsbn());
}

Essential rule: hashCode must use exactly the same fields as equals. If equals compares the ISBN, hashCode is computed from the ISBN. If you add a field to one, add it to the other. It is the number one cause of inconsistencies.

With several fields:

@Override
public int hashCode() {
    return Objects.hash(reference, loanDay);
}

Objects.hash(...) is a varargs method (03-03) that combines its arguments' hash codes with a standard algorithm. Internally it does the same as the classic formula, which is worth knowing because you will see it in old code and in interviews:

@Override
public int hashCode() {
    int result = 17;                                     // initial prime
    result = 31 * result + isbn.hashCode();              // 31: odd prime
    result = 31 * result + Integer.hashCode(year);
    return result;
}

31 is used because it is an odd prime and because 31 * x is optimised into (x << 5) - x. You do not need to write it by hand: Objects.hash is more readable and good enough.

A performance caution: Objects.hash creates an internal array for the varargs, so in classes with millions of instances in critical loops it can be noticeable. For everything else —including BiblioTech—, it is the right option.

  1. What breaks if you do not override hashCode

This section justifies everything above. Suppose you override equals in Book but forget hashCode:

Book a = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Book b = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);

System.out.println(a.equals(b));               // true
System.out.println(a.hashCode());              // 460141958
System.out.println(b.hashCode());              // 1163157884   <-- different

They are "equal" and have different hash codes: clause 2 is broken. The consequences, which you will suffer in module 5:

// A preview of module 5, just to see the symptom
Set<Book> catalog = new HashSet<>();
catalog.add(a);

System.out.println(catalog.contains(b));   // false, even though a.equals(b) is true!
catalog.add(b);
System.out.println(catalog.size());        // 2, with the same ISBN twice

A HashSet that admits duplicates. A HashMap where you store with one key and cannot retrieve with an identical one. And no error, no exception, no clue: simply, incorrect results.

flowchart TD
    A["catalog.contains(b)"] --> B["Computes b.hashCode()"]
    B --> C{"Is there anything
    in that bucket?"}
    C -- "no (empty bucket)" --> D["Returns false
    without ever calling equals"]
    C -- "yes" --> E["Compares with equals
    inside the bucket"]

There is the key to the diagram: if the hashCode does not match, equals is never even called. That is why the bug is so baffling: your equals is perfect and the result is still wrong.

Final rule, in one sentence: equals and hashCode are written together, modified together and reviewed together.

  1. getClass() versus instanceof in equals

There is a design decision in step 2 of equals, and it is worth knowing because it has real implications with inheritance.

The instanceof variant (the one we have used):

if (!(obj instanceof Book other)) return false;

The getClass() variant:

if (obj == null || getClass() != obj.getClass()) return false;
Book other = (Book) obj;

The difference appears with subclasses. Imagine a SignedBook extends Book:

Comparison With instanceof With getClass()
book.equals(signedBook) with the same ISBN true false
signedBook.equals(book) with the same ISBN Depends on whether the subclass overrides equals false
Symmetry guaranteed? No, if the subclass adds fields to the comparison Yes, always
Does it allow equality across the hierarchy? Yes No

The classic instanceof problem: if SignedBook overrides equals adding the signer to the comparison, then book.equals(signedBook) would give true (it only looks at the ISBN) while signedBook.equals(book) would give false (it also looks at the signer). Asymmetry, and a broken contract.

Choose... When...
getClass() You want guaranteed symmetry and each concrete class to be its own equality type
instanceof You want subclasses to be interchangeable, and you commit to none of them adding fields to equals
No worries at all The class is final (then they are equivalent)

Decision for BiblioTech. Since Book may have subclasses and we want bulletproof symmetry, the definitive implementation will use getClass(). And the cleanest alternative is still the one from 03-07: declare the class final when it is not designed to be extended, at which point the dilemma disappears.

Note on record. A record (lesson 04-07) automatically generates equals, hashCode and toString from all of its components, with the contract correctly implemented. It is the best reason to use them for immutable data objects. But note the nuance: the generated equals uses all the components, whereas ours uses only the ISBN. If your notion of equality is not "all the fields match", a record does not serve you as is.

  1. Objects.equals and Objects.requireNonNull

The java.util.Objects class provides two utilities you will see constantly.

Objects.equals(a, b) compares two references handling null correctly, without you having to write the guard:

// Without Objects.equals: you have to protect yourself
return (author == null) ? (other.author == null) : author.equals(other.author);

// With Objects.equals: one line, and null-safe
return Objects.equals(author, other.author);

Its implementation is exactly (a == b) || (a != null && a.equals(b)). Always use it inside equals when a field can be null.

Objects.requireNonNull(object, message) checks that a reference is not null and fails immediately if it is. Its value lies in failing early and with a clear message, in the constructor, instead of letting a null travel through the system and blow up three layers further on:

public Loan(Material material, Employee employee, int loanDay, int elapsedDays) {
    this.material = Objects.requireNonNull(material, "A loan needs a material");
    this.employee = Objects.requireNonNull(employee, "A loan needs an employee");
    // ...
}

If somebody passes null, the program stops right there with:

Exception in thread "main" java.lang.NullPointerException: A loan needs a material
        at com.nexussoftware.bibliotech.domain.Loan.<init>(Loan.java:42)

Compare it with the "Unknown material" placeholder we had been using: that one masked the error and let the system carry on with false data. requireNonNull is the first correct way of rejecting an invalid argument that you see in the course, and it is a foretaste of module 6, where you will learn to throw exceptions deliberately. From now on, in the domain classes, it is preferable to console warnings.

  1. Closing the module: BiblioTech's final refactoring

The time has come to keep the promise that closed module 2. Remember the problem:

// BiblioTechApp 2.0: three loose variables describing ONE single thing
int    highestDelay         = 0;
String highestDelayEmployee = "-";
String highestDelayBook     = "-";

// ...and its update, which you have to remember to do in full:
if (daysLate > highestDelay) {
    highestDelay         = daysLate;
    highestDelayEmployee = employee;
    highestDelayBook     = title;
}

The three flaws we pointed out then: nothing guarantees the three are updated together; the compiler cannot help; and if tomorrow you want to add the fine or the reference, you have to add a fourth variable and another line to remember.

With what you have learned in the module, the three variables become one:

// BiblioTechApp 3.0: a single coherent object
Loan highestDelayLoan = null;

// ...and its update, atomic by construction:
if (highestDelayLoan == null
        || loan.calculateDaysLate() > highestDelayLoan.calculateDaysLate()) {
    highestDelayLoan = loan;
}

A single assignment. It is impossible to leave the state half-done, because there are no parts to update separately: either the whole object changes, or nothing changes. And displaying it is one line, thanks to toString:

if (highestDelayLoan != null) {
    System.out.println("  Highest delay: " + highestDelayLoan);
} else {
    System.out.println("  Highest delay: (none recorded)");
}
  Highest delay: Loan[LN-0003, material=Refactoring, employee=Nuria Vidal, elapsed=95, fine=20.00, SEVERE]

Compare the two versions:

Aspect Module 2 Now
Variables involved 3 loose ones 1 object
Lines in the update 4, all compulsory 1
Can fall out of sync Yes Impossible
Adding the fine to the report Fourth variable + fourth line Already inside
Displaying it 3 printf 1 println
Comparing two maxima Compare three variables equals

BiblioTech's final state after module 3

The domain classes, with their three Object methods implemented:

// Book.java (extract: the Object methods)

@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null || getClass() != obj.getClass()) return false;
    Book other = (Book) obj;
    return getIsbn().equals(other.getIsbn());      // equality by ISBN
}

@Override
public int hashCode() {
    return Objects.hash(getIsbn());                // SAME field as equals
}

@Override
public String toString() {
    return String.format("Book[isbn=%s, title=%s, author=%s, year=%d, available=%b]",
                         getIsbn(), getTitle(), author, publicationYear, isAvailable());
}
// Employee.java (extract)

@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null || getClass() != obj.getClass()) return false;
    Employee other = (Employee) obj;
    return identifier.equals(other.identifier);        // identity = EMP-XXX
}

@Override
public int hashCode() { return Objects.hash(identifier); }

@Override
public String toString() {
    return String.format("Employee[%s, %s, loans=%d]",
                         identifier, name, totalLoans);
}
// Loan.java (extract)

@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null || getClass() != obj.getClass()) return false;
    Loan other = (Loan) obj;
    return reference.equals(other.reference);          // identity = LN-XXXX
}

@Override
public int hashCode() { return Objects.hash(reference); }

@Override
public String toString() {
    return String.format("Loan[%s, material=%s, employee=%s, elapsed=%d, fine=%.2f, %s]",
                         reference, getMaterialTitle(), getEmployeeName(),
                         elapsedDays, calculateFine(), classifySeverity());
}

And a main that no longer calculates anything:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.presentation.ConsoleReceipt;

public class BiblioTechApp {

    public static void main(String[] args) {

        ConsoleReceipt receipt = new ConsoleReceipt();

        System.out.println("=== BiblioTech 3.2 - End of module 3 ===\n");

        Book     effectiveJava  = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
        Book     designPatterns = new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994);
        Book     refactoring    = new Book("Refactoring", "Martin Fowler", "978-0000000003", 2018);
        Magazine magazine       = new Magazine("Java Magazine", "REV-2024-42", 42, "Bimonthly");
        Dvd      dvd            = new Dvd("Spring Course", "DVD-0007", 240);

        Employee marta = new Employee("Marta Ruiz",   "EMP-001");
        Employee diego = new Employee("Diego Alonso", "EMP-002");
        Employee nuria = new Employee("Nuria Vidal",  "EMP-003");

        // Provisional array: module 5 will bring collections
        Loan[] loans = {
            new Loan(effectiveJava,  marta, 100, 20),
            new Loan(designPatterns, diego, 100, 10),
            new Loan(refactoring,    nuria, 100, 95),
            new Loan(magazine,       marta, 100, 12),
            new Loan(dvd,            diego, 100, 12)
        };

        // Statistics: one object instead of three loose variables
        Loan   highestDelayLoan = null;
        double revenue          = 0.0;

        System.out.println("Registered loans:");
        for (Loan l : loans) {
            System.out.println("  " + l);
            revenue += l.calculateFine();

            if (highestDelayLoan == null
                    || l.calculateDaysLate() > highestDelayLoan.calculateDaysLate()) {
                highestDelayLoan = l;
            }
        }

        System.out.printf("%nExpected revenue: %.2f EUR%n", revenue);
        System.out.println("Highest delay: "
                + (highestDelayLoan != null ? highestDelayLoan : "(none)"));

        // Full return of the most serious loan
        if (highestDelayLoan != null) {
            highestDelayLoan.registerReturn(
                    highestDelayLoan.getElapsedDays());
            receipt.printReceipt(highestDelayLoan);
        }
    }
}

Output:

=== BiblioTech 3.2 - End of module 3 ===

Registered loans:
  Loan[LN-0001, material=Effective Java, employee=Marta Ruiz, elapsed=20, fine=1.25, MINOR]
  Loan[LN-0002, material=Design Patterns, employee=Diego Alonso, elapsed=10, fine=0.00, ON TIME]
  Loan[LN-0003, material=Refactoring, employee=Nuria Vidal, elapsed=95, fine=20.00, SEVERE]
  Loan[LN-0004, material=Java Magazine, employee=Marta Ruiz, elapsed=12, fine=0.50, MINOR]
  Loan[LN-0005, material=Spring Course, employee=Diego Alonso, elapsed=12, fine=4.50, SEVERE]

Expected revenue: 26.25 EUR
Highest delay: Loan[LN-0003, material=Refactoring, employee=Nuria Vidal, elapsed=95, fine=20.00, SEVERE]

========================================
   RETURN RECEIPT - BIBLIOTECH
  Reference:           LN-0003
  Material:            Refactoring
  Employee:            Nuria Vidal
  Days late:           80 days
  Fine:                20.00 EUR
  Severity:            SEVERE
========================================

Project inventory after module 3

Class Package Responsibility
Material domain Base for the formats: identification, availability, fine rules
Book domain Format with 15 days and €0.25/day; adds author and year
Magazine domain Format with 7 days and €0.10/day; adds number and frequency
Dvd domain Format with 3 days and €0.50/day; adds duration
Employee domain Authorised person; enforces their loan limit
Loan domain Relates material and employee; computes delay, fine and severity
ConsoleReceipt presentation Generates the texts the user sees
BiblioTechApp root Startup and coordination

What is still missing, and you already know where it is solved:

Shortcoming Module
Material can be instantiated; implementing getType() is not enforced 4 (abstract classes)
There are no reusable behaviour contracts between unrelated classes 4 (interfaces)
The loan array is rigid: you cannot add or search comfortably 5 (collections)
Validation warns on the console instead of really rejecting 6 (exceptions)
Everything is lost when the program closes 7 (files)

Common Mistakes and Tips

  • Overriding equals and forgetting hashCode. The most expensive mistake in this lesson: nothing fails until the objects enter a HashSet or a HashMap, and then it fails silently.
  • Writing equals(MyClass) instead of equals(Object). Overloading instead of overriding. @Override detects it instantly.
  • Using different fields in equals and hashCode. It breaks clause 2 of the contract. Always write them at the same time and with the same fields.
  • Using mutable fields in equals/hashCode. If the field changes while the object is in a collection, the object becomes unfindable inside it. Use final fields.
  • Comparing double with == inside equals. NaN != NaN breaks reflexivity. Use Double.compare.
  • Formatting data with toString in order to process it. If other code parses your toString, it becomes an API you will not be able to change. There are specific methods for that.
  • A toString that recurses between objects referencing each other. StackOverflowError. Print the other object's identifier, not the whole object.
  • Tip: let the IDE generate them. IntelliJ (Alt+Insertequals() and hashCode()) and Eclipse (Alt+Shift+S) generate all three implementations with the correct contract. Write them by hand once to understand them, and use the generator afterwards.
  • Tip: if the class is an immutable data holder, consider a record (04-07): it gives you the three methods for free and with the contract properly implemented.
  • Tip: test the contract. Four asserts or four printlns checking reflexivity, symmetry, transitivity and null cost a minute and catch 90 % of the errors. In module 11 you will do it with JUnit.

Exercises

Exercise 1: the three methods in Material and its subclasses

Implement toString, equals and hashCode in Material and decide what the subclasses should do:

  1. In Material, equality is defined by reference (a book's ISBN, a DVD's code...).
  2. Use getClass() to guarantee symmetry, and check with console output that a Book and a Dvd with the same reference are not equal.
  3. Override toString in Material including type, title, reference and availability, and check it with the four subclasses.
  4. Argue in writing whether Book, Magazine and Dvd need to override equals and hashCode, or whether inheriting them is enough.

Exercise 2: demonstrate the accidental overload

Write a test class that demonstrates the mistake from section 8 in a single run:

  1. A class BadBook with public boolean equals(BadBook other) (without @Override).
  2. Two objects with the same ISBN.
  3. Compare them with references declared as BadBook and with references declared as Object, printing both results.
  4. Add @Override and copy the compiler's exact message.
  5. Fix the signature and check that both results now agree.

Exercise 3: an equals contract checker

Write a method static boolean checkContract(Object x, Object y, Object z) that checks the five properties of the equals contract on three objects and prints a report with the result of each, plus coherence with hashCode. Test it with:

  1. Three correct Books: two with the same ISBN and one different.
  2. A class BookWithoutHashCode that overrides equals but not hashCode, to see which check fails.

Solutions

Solution 1

package com.nexussoftware.bibliotech.domain;

import java.util.Objects;

public class Material {

    // ... fields, constructor and previous methods ...

    /**
     * Two materials are equal if they are of the SAME class and share a reference.
     * Using getClass() guarantees symmetry even with subclasses.
     */
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Material other = (Material) obj;
        return reference.equals(other.reference);
    }

    /** Same field as equals: the reference. */
    @Override
    public int hashCode() {
        return Objects.hash(reference);
    }

    @Override
    public String toString() {
        return String.format("%s[ref=%s, title=%s, available=%b]",
                             getType(), reference, title, available);
    }
}

Checking symmetry between different types:

Book book = new Book("Effective Java", "Joshua Bloch", "REF-001", 2018);
Dvd  dvd  = new Dvd("Spring Course", "REF-001", 240);        // SAME reference

System.out.println("book.equals(dvd): " + book.equals(dvd));   // false
System.out.println("dvd.equals(book): " + dvd.equals(book));   // false  (symmetric)
System.out.println(book);
System.out.println(dvd);

Book otherCopy = new Book("Effective Java", "J. Bloch", "REF-001", 2017);
System.out.println("book.equals(otherCopy): " + book.equals(otherCopy));           // true
System.out.println("same hash: " + (book.hashCode() == otherCopy.hashCode()));     // true

Output:

book.equals(dvd): false
dvd.equals(book): false
Book[ref=REF-001, title=Effective Java, available=true]
DVD[ref=REF-001, title=Spring Course, available=true]
book.equals(otherCopy): true
same hash: true

Notice an elegant detail of Material's toString: it uses getType(), which is polymorphic, so each subclass identifies itself without needing to override toString. It is lesson 03-06 applied here.

4. Should the subclasses override equals and hashCode? No, and they should not. Three reasons:

  • The reference already identifies any material in the catalog uniquely: there are no two books with the same ISBN nor two DVDs with the same code. Adding author or durationMinutes to the comparison would distinguish nothing new.
  • Overriding equals in a subclass adding fields is precisely the source of the asymmetries in section 12. Since Material.equals uses getClass(), a Book and a Dvd are never equal anyway; the problem is solved in the base class.
  • If they needed to compare more fields, hashCode would have to be updated at the same time in each subclass, multiplying the opportunities for error.

The rule that follows: define equality only once, at the highest level of the hierarchy where it makes sense, and use a stable identifying field rather than the sum of all the fields.

Solution 2

package com.nexussoftware.bibliotech;

public class BadEqualsDemo {

    static class BadBook {
        final String isbn;
        BadBook(String isbn) { this.isbn = isbn; }

        // OVERLOAD, not override: the signature takes BadBook, not Object
        public boolean equals(BadBook other) {
            System.out.println("      [MY equals(BadBook) runs]");
            return isbn.equals(other.isbn);
        }
    }

    static class GoodBook {
        final String isbn;
        GoodBook(String isbn) { this.isbn = isbn; }

        @Override
        public boolean equals(Object obj) {
            System.out.println("      [MY equals(Object) runs]");
            if (this == obj) return true;
            if (obj == null || getClass() != obj.getClass()) return false;
            return isbn.equals(((GoodBook) obj).isbn);
        }

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

    public static void main(String[] args) {

        System.out.println("--- INCORRECT version ---");
        BadBook a = new BadBook("978-0000000001");
        BadBook b = new BadBook("978-0000000001");

        System.out.println("  With declared type BadBook:");
        System.out.println("   a.equals(b) = " + a.equals(b));

        Object oa = a;
        Object ob = b;
        System.out.println("  With declared type Object:");
        System.out.println("   oa.equals(ob) = " + oa.equals(ob));

        System.out.println("\n--- CORRECT version ---");
        GoodBook c = new GoodBook("978-0000000001");
        GoodBook d = new GoodBook("978-0000000001");

        System.out.println("  With declared type GoodBook:");
        System.out.println("   c.equals(d) = " + c.equals(d));

        Object oc = c;
        Object od = d;
        System.out.println("  With declared type Object:");
        System.out.println("   oc.equals(od) = " + oc.equals(od));
    }
}

Output:

--- INCORRECT version ---
  With declared type BadBook:
      [MY equals(BadBook) runs]
   a.equals(b) = true
  With declared type Object:
   oa.equals(ob) = false

--- CORRECT version ---
  With declared type GoodBook:
      [MY equals(Object) runs]
   c.equals(d) = true
  With declared type Object:
      [MY equals(Object) runs]
   oc.equals(od) = true

The traces make it crystal clear: in the incorrect version, the call through Object does not even enter your method —the message does not appear—, because the compiler resolved the call to Object.equals. In the correct one, your method runs in both cases.

4. Compiler message when adding @Override:

error: method does not override or implement a method from a supertype
        @Override
        ^

Five words that would have saved you the whole bug. In a HashSet (module 5), the incorrect version would produce duplicates with no sign of an error at all.

Solution 3

package com.nexussoftware.bibliotech;

/** Teaching checker for the equals contract and its coherence with hashCode. */
public class ContractChecker {

    public static boolean checkContract(Object x, Object y, Object z) {

        System.out.println("Checking the contract with:");
        System.out.println("  x = " + x);
        System.out.println("  y = " + y);
        System.out.println("  z = " + z);

        boolean allCorrect = true;

        // 1. Reflexive
        boolean reflexive = x.equals(x) && y.equals(y) && z.equals(z);
        report("Reflexive   (x.equals(x))", reflexive);
        allCorrect &= reflexive;

        // 2. Symmetric
        boolean symmetric = (x.equals(y) == y.equals(x))
                         && (y.equals(z) == z.equals(y))
                         && (x.equals(z) == z.equals(x));
        report("Symmetric   (x=y implies y=x)", symmetric);
        allCorrect &= symmetric;

        // 3. Transitive: only checked if the antecedent holds
        boolean transitive = true;
        if (x.equals(y) && y.equals(z)) {
            transitive = x.equals(z);
        }
        report("Transitive  (x=y and y=z implies x=z)", transitive);
        allCorrect &= transitive;

        // 4. Consistent: same calls, same results
        boolean consistent = (x.equals(y) == x.equals(y)) && (x.equals(y) == x.equals(y));
        report("Consistent  (repeated calls)", consistent);
        allCorrect &= consistent;

        // 5. Against null
        boolean nullSafe = !x.equals(null) && !y.equals(null) && !z.equals(null);
        report("Null        (x.equals(null) is false)", nullSafe);
        allCorrect &= nullSafe;

        // 6. Coherence with hashCode (clause 2 of the hashCode contract)
        boolean hashConsistent = true;
        if (x.equals(y) && x.hashCode() != y.hashCode()) hashConsistent = false;
        if (y.equals(z) && y.hashCode() != z.hashCode()) hashConsistent = false;
        if (x.equals(z) && x.hashCode() != z.hashCode()) hashConsistent = false;
        report("hashCode    (equal -> same hash)", hashConsistent);
        allCorrect &= hashConsistent;

        System.out.println(allCorrect ? "RESULT: contract HONOURED\n"
                                      : "RESULT: contract BROKEN\n");
        return allCorrect;
    }

    private static void report(String property, boolean ok) {
        System.out.printf("  %-38s %s%n", property, ok ? "OK" : "FAILS");
    }

    /** Deliberately faulty class: equals without hashCode. */
    static class BookWithoutHashCode {
        final String isbn;
        BookWithoutHashCode(String isbn) { this.isbn = isbn; }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) return true;
            if (obj == null || getClass() != obj.getClass()) return false;
            return isbn.equals(((BookWithoutHashCode) obj).isbn);
        }

        @Override
        public String toString() { return "BookWithoutHashCode[" + isbn + "]"; }
        // hashCode() is missing on purpose
    }

    public static void main(String[] args) {

        System.out.println("=== CASE 1: correct Book ===");
        Book x = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
        Book y = new Book("Effective Java", "J. Bloch",     "978-0000000001", 2017);
        Book z = new Book("Refactoring", "Martin Fowler", "978-0000000003", 2018);
        checkContract(x, y, z);

        System.out.println("=== CASE 2: equals without hashCode ===");
        BookWithoutHashCode p = new BookWithoutHashCode("978-0000000001");
        BookWithoutHashCode q = new BookWithoutHashCode("978-0000000001");
        BookWithoutHashCode r = new BookWithoutHashCode("978-0000000003");
        checkContract(p, q, r);
    }
}

Output:

=== CASE 1: correct Book ===
Checking the contract with:
  x = Book[isbn=978-0000000001, title=Effective Java, author=Joshua Bloch, year=2018, available=true]
  y = Book[isbn=978-0000000001, title=Effective Java, author=J. Bloch, year=2017, available=true]
  z = Book[isbn=978-0000000003, title=Refactoring, author=Martin Fowler, year=2018, available=true]
  Reflexive   (x.equals(x))              OK
  Symmetric   (x=y implies y=x)          OK
  Transitive  (x=y and y=z implies x=z)  OK
  Consistent  (repeated calls)           OK
  Null        (x.equals(null) is false)  OK
  hashCode    (equal -> same hash)       OK
RESULT: contract HONOURED

=== CASE 2: equals without hashCode ===
Checking the contract with:
  x = BookWithoutHashCode[978-0000000001]
  y = BookWithoutHashCode[978-0000000001]
  z = BookWithoutHashCode[978-0000000003]
  Reflexive   (x.equals(x))              OK
  Symmetric   (x=y implies y=x)          OK
  Transitive  (x=y and y=z implies x=z)  OK
  Consistent  (repeated calls)           OK
  Null        (x.equals(null) is false)  OK
  hashCode    (equal -> same hash)       FAILS
RESULT: contract BROKEN

The revealing thing about case 2: the equals is impeccable —it honours all five properties— and the class is still broken. That is exactly why the failure is so hard to find in a real project: every equality test passes, and the system fails much later, inside a HashMap, without throwing any exception.

Two technical notes on the checker. First, the transitivity check only makes sense when the antecedent holds; if x and y are not equal, the implication is vacuously true and there is nothing to verify. Second, this checker is a foretaste of what you will do in module 11 with JUnit, where these checks are written as automated tests that run on every build instead of printing to the console.

Conclusion

You have come full circle. You know that Object is the root of the whole hierarchy and you know its methods, including the ones you must not touch (getClass), the discouraged ones (clone), the deprecated ones (finalize) and those that wait until module 8 (wait/notify). You understand what the cryptic Book@1b6d3586 is —the class name and the hashCode in hexadecimal, not a memory address— and you know how to replace it with a short, informative, side-effect-free toString that transforms debugging. You distinguish reference equality from logical equality, and you know that deciding which applies is a domain decision: in BiblioTech, two books are the same book if they share an ISBN. You master the complete equals contract —reflexive, symmetric, transitive, consistent and false against null— and its canonical five-step implementation, with the modern instanceof-with-pattern variant. You have seen in action the classic mistake of writing equals(Book) instead of equals(Object), and why @Override is the difference between a second and a wasted afternoon. You know the hashCode contract, the unbreakable rule of always overriding it alongside equals and with the same fields, Objects.hash for writing it in one line, and exactly what breaks if you forget: a HashSet with duplicates and an equals that nobody ever reaches. And you add Objects.equals and Objects.requireNonNull to your vocabulary, the first correct way of rejecting an invalid argument that you see in the course.

With this you close module 3, and BiblioTech is a different program. Where there were twenty loose variables in a two-hundred-line main, there are now eight classes spread over three packages: a Material hierarchy with three formats that apply their own rules through polymorphism, an Employee that enforces its limit, a Loan that computes its due day at birth and registers complete returns in a single call, a presentation layer separated from the domain, and objects that print and compare properly. The fields are private, the invariants are guaranteed, the public API is pruned and adding a new format costs one class and zero modifications. And those three awkward variables —highestDelay, highestDelayEmployee and highestDelayBook— are today a single coherent Loan object, exactly as promised at the end of module 2.

In module 4, Advanced OOP, you will take the leap from the basic mechanisms to the ones Java's professional libraries really use. You will turn Material into an abstract class that cannot be instantiated and that forces its subclasses to implement what is theirs; you will discover interfaces, with which a class can commit to several contracts at once without multiple inheritance; you will meet inner and anonymous classes, lambda expressions and method references that completely change the way modern Java is written; and you will finish with enums and records, which will replace those "MINOR" and "SEVERE" strings with safe types and will generate on their own the three methods you have just written by hand. BiblioTech is ready to grow.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved