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
Object, the root of everything- A tour of
Object's methods toString: what that@1b6d3586is- Overriding
toStringcorrectly equals: reference equality versus logical equality- The
equalscontract - Implementing
equalsstep by step - The classic mistake:
equals(Book)instead ofequals(Object) hashCode: the contract and why it always goes withequals- Implementing
hashCodewithObjects.hash - What breaks if you do not override
hashCode getClass()versusinstanceofinequalsObjects.equalsandObjects.requireNonNull- Closing the module: BiblioTech's final refactoring
- Common Mistakes and Tips
- Exercises
Object, the root of everything
Object, the root of everythingAs 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 calltoString()on anything. Objectserves as a universal common type. AnObjectparameter accepts any object, and that is whySystem.out.println(Object)works with whatever you throw at it. It is the polymorphism of 03-06 taken to the limit.
- A tour of
Object's methods
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.
toString: what that @1b6d3586 is
toString: what that @1b6d3586 isWhen 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);The default implementation in Object is, literally:
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.
- Overriding
toString correctly
toString correctlyA 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());
}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.
equals: reference equality versus logical equality
equals: reference equality versus logical equalityLet 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:
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
Bookrepresents a bibliographic work, two objects with the same ISBN are the same book. →equalsmust be overridden. - If
Bookrepresents 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.
- The
equals contract
equals contractequals 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
}
// ...
}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.
- Implementing
equals step by step
equals step by stepThe 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:
- Only fields significant to identity. A derived field or one of transient state (like
available) must not go in. - Preferably
finalfields. If a field used inequalschanges, the object changes identity, with the consequences from section 11. - Careful with the types: for
floatanddouble, useFloat.compare/Double.compareinstead of==, becauseNaN != NaNwould break reflexivity, and0.0and-0.0are==but differ at the bit level.
- The classic mistake:
equals(Book) instead of equals(Object)
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.equalsThe 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:
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.
hashCode: the contract and why it always goes with equals
hashCode: the contract and why it always goes with equalshashCode() 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, overridehashCode. 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.
- Implementing
hashCode with Objects.hash
hashCode with Objects.hashThe modern, recommended way is one line, using the utility class java.util.Objects:
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:
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.
- What breaks if you do not override
hashCode
hashCodeThis 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 <-- differentThey 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 twiceA 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.
getClass() versus instanceof in equals
getClass() versus instanceof in equalsThere 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):
The getClass() variant:
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. Arecord(lesson 04-07) automatically generatesequals,hashCodeandtoStringfrom 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 generatedequalsuses all the components, whereas ours uses only the ISBN. If your notion of equality is not "all the fields match", arecorddoes not serve you as is.
Objects.equals and Objects.requireNonNull
Objects.equals and Objects.requireNonNullThe 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.
- 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
equalsand forgettinghashCode. The most expensive mistake in this lesson: nothing fails until the objects enter aHashSetor aHashMap, and then it fails silently. - Writing
equals(MyClass)instead ofequals(Object). Overloading instead of overriding.@Overridedetects it instantly. - Using different fields in
equalsandhashCode. 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. Usefinalfields. - Comparing
doublewith==insideequals.NaN != NaNbreaks reflexivity. UseDouble.compare. - Formatting data with
toStringin order to process it. If other code parses yourtoString, it becomes an API you will not be able to change. There are specific methods for that. - A
toStringthat 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+Insert→ equals() 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 fourprintlns checking reflexivity, symmetry, transitivity andnullcost 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:
- In
Material, equality is defined byreference(a book's ISBN, a DVD's code...). - Use
getClass()to guarantee symmetry, and check with console output that aBookand aDvdwith the same reference are not equal. - Override
toStringinMaterialincluding type, title, reference and availability, and check it with the four subclasses. - Argue in writing whether
Book,MagazineandDvdneed to overrideequalsandhashCode, 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:
- A class
BadBookwithpublic boolean equals(BadBook other)(without@Override). - Two objects with the same ISBN.
- Compare them with references declared as
BadBookand with references declared asObject, printing both results. - Add
@Overrideand copy the compiler's exact message. - 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:
- Three correct
Books: two with the same ISBN and one different. - A class
BookWithoutHashCodethat overridesequalsbut nothashCode, 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())); // trueOutput:
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
authorordurationMinutesto the comparison would distinguish nothing new. - Overriding
equalsin a subclass adding fields is precisely the source of the asymmetries in section 12. SinceMaterial.equalsusesgetClass(), aBookand aDvdare never equal anyway; the problem is solved in the base class. - If they needed to compare more fields,
hashCodewould 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) = trueThe 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:
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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
