Study Notes
Design & Patternsfoundation

Object Class, equals/hashCode & SOLID

The Object superclass and its inherited methods, overriding equals() and hashCode() correctly (including the instanceof vs getClass() trap), the equals/hashCode contract, HashSet internals, and the five SOLID principles.

35 min read·verified Java 21·reviewed 2026-08-30

Assumes you know

After this you should be able to

  • Implement equals and hashCode together
  • Explain the HashSet contract
equals / hashCodeSOLIDHashSet internals
Personal study notes · Java · [+] = my own enrichment / corrections beyond core material

I. Notebook Summary (copy by hand)#

Terse, high-density. Keyword → rule. Everything here is derivable from Section II.

1 · The Object class (the "godfather")#

OBJECT CLASS (java.lang.Object) root of ALL classes every class extends Object (direct/indirect) gives free methods ........ toString() equals() hashCode() getClass() clone() finalize() wait()/notify()/notifyAll() inheritance: implicit -> class Foo {} == class Foo extends Object {}
family tree: one box "Object" at top, arrows down to String / Student / List = all descend from it.

2 · toString()#

DEFAULT toString() return getClass().getName() + "@" + Integer.toHexString(hashCode()); e.g. com.demo.Student@1b6d3586 parts: ClassName @ hashCode-in-HEX println(obj) -> calls obj.toString() implicitly OVERRIDE -> meaningful output; great for logging/debugging built-ins already override: wrapper classes, String/SB/SBuf, collections
"@" splits a label: [ClassName] @ [hex] = nameplate with a serial number.

3 · equals()#

DEFAULT equals() == reference (memory address) check same fields, diff objects -> NOT equal (default) OVERRIDE to compare by VALUE / id: null check -> false type check -> instanceof Student (false if null) self check -> (obj==this) true field check -> this.id == ((Student)obj).id CONTRACT: reflexive, symmetric, transitive, consistent, x.equals(null)=false

instanceof vs getClass() in equals() [+] interview favourite#

instanceofgetClass()
Subclass == superclass?can be truenever
Symmetry riskcan break (fix: make equals final)safe
LSPrespectscan violate (trivial subclass)
nullauto-false (no null check needed)need explicit null check

4 · hashCode()#

hashCode() default = ~ identity int (per-instance, JVM-derived) HashSet/HashMap store by BUCKET: bucket index = hash & (capacity-1) (cap = power of 2) so equal objects MUST land in same bucket -> must share hashCode GOLDEN CONTRACT equal objects => SAME hashCode (mandatory) same hashCode => NOT necessarily equal (collision OK) override equals() => MUST override hashCode() [or HashSet breaks]
row of mailbox slots; each slot = a bucket; items with same hash drop into same slot, then equals() picks among them.
hashCode impleffect
return id;good spread → few collisions → fast
return id % 5;only 5 distinct codes → many collisions → slower load
COLLISION = 2 keys, same bucket Java7-: linked list in bucket -> search O(n) Java8+: list treeifies at 8 -> search O(log n) [+] lesson: spread hashCodes wisely

5 · String hashCode & the magic 31#

String.hashCode() s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] WHY 31? odd -> even * overflow would lose bits (like a left shift) prime-> traditional, decent distribution fast -> 31*i == (i<<5) - i (shift + subtract) NOTE [+]: 31 not provably "best"(37/33 ~ tie); Bloch picked it, cheap on RISC

6 · SOLID (Uncle Bob / Robert C. Martin)#

LPrincipleOne-liner
SSingle Responsibility1 class → 1 reason to change
OOpen–Closedopen to extend, closed to modify
LLiskov Substitutionsubtype usable wherever base is
IInterface Segregationmany small interfaces > 1 fat one
DDependency Inversiondepend on abstractions, not concretes
WHY SOLID clean+maintainable | preserve good design loose coupling | reuse + testability
SRP: one box = one hat (not a box juggling 3 hats). OCP: a sealed box with a plug-in slot on the side.
[+] Mnemonic: "S-O-L-I-D" spells itself. Recall hook for the five: "Some Old Ladies Inspect Dependencies."

II. Detailed Study Notes#

Built from scratch, beginner-friendly. [+] marks additions/corrections beyond the core material.

1. The Object class — the root of everything#

Every Java class silently extends java.lang.Object, so a handful of methods are available on every object you ever touch.

Java has a single rooted inheritance hierarchy. When you write class Student {}, the compiler treats it as class Student extends Object {}. If your class already extends something else, that parent (or its parent, eventually) extends Object. So Object is the ancestor of all classes — directly or indirectly. It lives in the java.lang package, which is auto-imported.

Because of this, every object inherits these methods for free:

MethodWhat it does
toString()String representation of the object
equals(Object)Equality test (default = same reference)
hashCode()Integer hash used by hash-based collections
getClass()Runtime class (a Class object) [+]
clone() [+]Field-by-field copy (protected; needs Cloneable)
wait()/notify()/notifyAll() [+]Thread coordination on the object's monitor
finalize() [+]Legacy cleanup hook — deprecated, don't use
Why it mattersThis is why you can call .toString() or drop any object into a HashSet without writing those methods. The three you'll override constantly are toString, equals, and hashCode — the rest of these notes is mostly about doing that correctly.
Interview angle"What's the superclass of every Java class?" → Object. Follow-up: "name methods it provides" → the table above.

→ Code: Object.java

2. toString() — making objects printable#

Printing an object reference secretly calls its toString(); the default output is cryptic, so you override it.

When you run System.out.println(student), Java doesn't print the object's memory; it implicitly calls student.toString() and prints that string. The default implementation in Object is:

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

So an un-overridden object prints like com.demo.Student@1b6d3586. Reading left to right: getClass().getName() is the fully-qualified class name, "@" is a literal separator, and Integer.toHexString(hashCode()) is the object's hash code rendered in hexadecimal. [+] That hex string is not a memory address — a common misconception. It's just the hashCode in base-16; on the default implementation it's derived from the object's identity, but the JVM does not expose raw pointers.

Override toString() to return something meaningful:

Student.toString() — overridden
@Override
public String toString() {
    return "Student{firstName=" + firstName + ", lastName=" + lastName + ", email=" + email + "}";
}

Why override it: meaningful one-line printing via println; invaluable for logging and debugging; represents all fields (including numeric ones) as a single string. Many built-in classes already override it — the wrapper classes (Integer, Long, Double, Boolean, Character, etc.), the collection classes, and String/StringBuilder/StringBuffer — which is why printing a list shows [1, 2, 3] rather than gibberish.

Best practiceMost IDEs generate toString() for you (in VS Code: Right-click → Source Action → Generate toString, then pick fields). [+] Don't put secrets (passwords, tokens) in toString() — they leak straight into logs.

→ Code: ToStringDemo.java · Lab: Exercise 1

3. equals() — value equality vs identity#

By default equals() checks whether two references point to the same object; override it to compare by the fields you care about.

Create two Student objects with identical first name, last name, and email, and a.equals(b) still returns false. Reason: the inherited Object.equals() is literally this == obj — a reference (memory-location) comparison. Two separately-constructed objects live at different locations, so they're "not equal" even when their data matches.

To make equality mean "same student id", override it:

Student.equals()
@Override
public boolean equals(Object obj) {
    if (obj == null) return false;              // nothing equals null
    if (!(obj instanceof Student)) return false; // wrong type -> not equal
    if (obj == this) return true;                // same reference -> fast yes
    return this.getId() == ((Student) obj).getId(); // compare by id
}

Read it as a funnel: reject null, reject wrong types, accept an identical reference instantly, then fall back to the real field comparison. The instanceof check also doubles as a type guard so the cast on the last line is safe. [+] Note instanceof already returns false for null, so the explicit null check above is technically redundant when you use instanceof — it's kept here for clarity.

Why it mattersequals() is the backbone of collections. List.contains, Set dedup, Map key lookup — all defer to equals(). Get it wrong and your data structures quietly misbehave.
Interview angle"Why are two objects with the same data not equal?" → default equals compares references, not fields. The five contract rules (next) are also classic.

→ Code: EqualsDemo.java · Lab: Exercise 2

4. instanceof vs getClass() inside equals() [+]#

The type check at the top of equals() can use instanceof or getClass(), and the choice quietly changes how subclasses behave — a favourite interview trap.

This wasn't spelled out in my core material, but it's the natural follow-up question, so it's worth nailing down with verified reasoning:

Aspectinstanceof StudentgetClass() == obj.getClass()
Can a subclass instance equal a superclass instance?YesNo (exact type required)
Symmetry (a.equals(b) == b.equals(a))Can break if a subclass adds state to equalsAlways preserved
Liskov Substitution PrincipleRespectedCan violate it for trivial subclasses
null handlingAuto-false; no null check neededNeeds an explicit null check

Joshua Bloch (Effective Java) favours instanceof, because getClass() means an object can only ever equal another of the exact same runtime class — so a harmless subclass that merely adds a method is suddenly "not equal" to its parent, which is surprising and brushes against the Liskov principle. The catch with instanceof: if a subclass overrides equals and folds in extra fields, you can break symmetry (parent says "equal", child says "not equal"). The standard fix is to make equals (and hashCode) final, or to only use instanceof when subclasses don't change the notion of equality.

PitfallThere's no universally "right" answer — it's a genuine trade-off. Saying "always use X" in an interview is the wrong move; explaining the symmetry-vs-LSP tension is the right one.

→ Code: InstanceofVsGetClass.java

5. hashCode() — the address into a hash table#

Hash-based collections find objects by their hashCode() first; if you override equals() but not hashCode(), a HashSet will store visible duplicates.

Store two "equal" students (you've already overridden equals) in a HashSet and call set.contains(other) — it can still return false. Why, if a HashSet stores unique objects? Because a HashSet doesn't scan every element. It keeps elements in buckets, and each bucket is chosen from the object's hash code. The lookup goes: compute hashCode() → jump to that bucket → only there compare with equals().

If two equal students return different hash codes (the default behaviour, since default hashCode is per-instance), they land in different buckets and never get compared — so the set never notices they're duplicates. Fix it by making equal objects produce equal hash codes:

Student.hashCode()
@Override
public int hashCode() {
    return id;   // equal ids -> equal hashCode -> same bucket
}
Why it mattersThe rule "override hashCode whenever you override equals" exists precisely because of bucketing. Two objects that are equals but have different hash codes are invisible to each other inside hash collections.

→ Code: HashCodeDemo.java · Lab: Exercise 3

6. The equals/hashCode contract#

Equality and hashing follow a small set of rules; breaking them is how collections silently corrupt.

equals() contract — for non-null references:

  • Reflexive: x.equals(x) is true.
  • Symmetric: x.equals(y) iff y.equals(x).
  • Transitive: if x=y and y=z then x=z.
  • Consistent: repeated calls give the same result if no fields change.
  • x.equals(null) is always false.

The hashCode bridge — the one rule everyone forgets:

StatementTrue?
Two equal objects have the same hashCodeMust be true
Two objects with the same hashCode are equalNot required (collisions are legal)

So can two unequal objects share a hash code? Yes — that's a collision, and it's allowed. But two equal objects must never disagree on their hash code. [+] Mnemonic: "equals implies same hash, but same hash does not imply equals."

Interview angle"If two objects are equal, what about their hash codes?" → must be identical. "If hash codes are equal, are the objects equal?" → not necessarily.

7. Collisions and how HashSet actually stores things#

A bad hashCode() doesn't break correctness — it wrecks performance by piling everything into a few buckets.

Replace return id; with return id % 5; and loading the same data into a HashSet takes noticeably longer. The reason: id % 5 yields only five distinct hash codes, so thousands of students cram into five buckets. Within a bucket, finding/inserting means comparing against everything already there — so you've traded near-instant lookups for slow scans. The lesson: override hashCode() wisely to spread values and avoid collisions.

[+] How a HashSet works under the hood (verified): a HashSet is backed by a HashMap. Insertion:

  1. Compute hashCode() of the element.
  2. Map it to a bucket index — conceptually hash % capacity, but actually hash & (capacity-1) since capacity is a power of two. [+]
  3. Empty bucket → store it. Occupied bucket (collision) → compare via equals(); if no match, chain it in.
Java versionBucket structureWorst-case lookup in bucket
7 and earlierLinked listO(n)
8 and later [+]List → balanced (red-black) tree once a bucket exceeds 8 entriesO(log n)

[+] There's also resizing: when the set fills past its load factor (default 0.75), capacity doubles and everything is rehashed — which is partly why repeatedly adding to a poorly-distributed set is slow.

Trade-offsGood hashCode → O(1) average operations. Degenerate hashCode (e.g. return 1;) → everything in one bucket → O(n) (or O(log n) on Java 8+ after treeify) — a hash set that behaves like a list.

→ Code: CollisionDemo.java · Lab: Exercise 4

8. Why String's hashCode uses 31#

String.hashCode() is a polynomial in 31; the constant is an odd prime chosen mostly because it's cheap to compute, not because it's mathematically optimal.

The formula (from the Java spec):

String.hashCode() — formula
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
// computed iteratively as:  h = 31*h + s[i]

Why 31 specifically (verified against Bloch's Effective Java and the original OpenJDK reasoning):

  • Odd: if the multiplier were even and the multiplication overflowed, low bits would be lost (multiplying by an even number is like left-shifting, which drops information).
  • Prime: traditional; tends to distribute reasonably. [+] The benefit of "prime" here is fuzzier than people claim — Bloch himself noted 33 (composite) performed almost identically.
  • Fast: the JVM can rewrite 31 * i as (i << 5) - i — a shift plus a subtract, cheaper than a multiply on older/RISC hardware.
Misconception[+] 31 is not provably the best multiplier. Measurements show 31, 33, 37, and others give nearly identical collision rates on real word lists. Bloch essentially inherited 31 from K&R and picked it because it's the cheapest to compute. State it as "a reasonable, cheap, traditional choice," not "the optimal one."

→ Code: StringHash31.java · Lab: Exercise 5

9. SOLID — five principles of good OO design#

SOLID is five design principles from Robert C. Martin ("Uncle Bob") that keep object-oriented code clean, loosely coupled, and easy to change.

LetterPrinciplePlain meaning
SSingle ResponsibilityA class should have one, and only one, reason to change.
OOpen–ClosedOpen for extension, closed for modification.
LLiskov SubstitutionSubtypes must be usable anywhere their base type is expected.
IInterface SegregationPrefer several small interfaces over one fat one.
DDependency InversionDepend on abstractions, not concrete classes.

Why SOLID: to write clean and maintainable code, to preserve good design over time, to keep modules loosely coupled, and to improve reusability and testability.

Why it mattersThese aren't rules for their own sake. Loosely coupled, single-purpose code is the code you can change six months later without fear — and the code you can unit-test in isolation.
[+] Recall hooks: the word SOLID is its own acronym; for the order of the five, "Some Old Ladies Inspect Dependencies." Also worth knowing: these are guidelines, not laws — over-applying them (e.g. an interface per method) creates needless complexity.

10. Single Responsibility Principle (SRP) [+] expanded#

A class should do one job; if you can describe it with "and," it probably has too many responsibilities.

My core material named SRP but didn't develop it, so here's the full picture (verified, standard definition). "One reason to change" means each class answers to a single actor/concern. A class that parses input, applies business rules, and writes to a database has three reasons to change — a format change, a rule change, or a storage change — and any of them can break the others.

Analogy: a Swiss-army knife is convenient until one tool jams and you can't trust the rest. Separate tools fail independently. Real-world example: split a Report class into ReportData (holds data), ReportFormatter (renders it), and ReportSaver (persists it). Now changing the export format never risks the persistence logic.

Trade-offsMore classes and indirection. Taken to an extreme you get dozens of one-method classes that are hard to navigate. Aim for cohesion, not maximal fragmentation.
Interview angle"How do you spot an SRP violation?" → a class with mixed concerns (UI + persistence + logic), a name with "Manager/Util/Helper" doing everything, or a class your whole team keeps editing for unrelated reasons.

→ Code: SRP_Before / SRP_After · Lab: Exercise 6

11. Open–Closed Principle (OCP) [+] expanded#

You should be able to add new behaviour by adding new code, not by editing code that already works.

Also named-but-not-developed in my core material, so here's the verified standard treatment. "Open for extension, closed for modification" means: when a new requirement arrives, you extend the system (new class, new implementation of an interface) rather than reopening and rewriting tested classes. The usual vehicle is polymorphism — program to an interface, then add new implementations.

Classic smell: a growing if/else or switch on a type code. Each new type forces an edit to the same method. Fix: define an interface (e.g. Shape.area()), implement it per type (Circle, Square), and let callers depend on Shape. Adding a Triangle means writing one new class — the area-calculating code never changes.

Why it mattersEvery edit to working code is a chance to introduce a bug and a reason to re-test. OCP shrinks that blast radius: new behaviour lives in new, isolated code.
Pitfall[+] Don't pre-build extension points everywhere "just in case" — that's speculative generality. Apply OCP where change is actually likely (the parts that keep changing), not uniformly.

→ Code: OCP_Before / OCP_After

12. L, I, D — quick preview [+]#

My core material focused on S and O; for completeness, the remaining three (verified standard definitions):

  • Liskov Substitution (L): any subclass must work correctly wherever its parent is used. Classic violation: a Square extending Rectangle where setting width also changes height — code that trusts Rectangle's behaviour breaks.
  • Interface Segregation (I): don't force a class to implement methods it doesn't need. Split a fat Machine interface (print, scan, fax) so a simple printer isn't forced to stub out fax().
  • Dependency Inversion (D): high-level modules and low-level modules should both depend on abstractions. Inject a MessageSender interface instead of hard-wiring EmailSender, so you can swap in SMS or a test double without touching the high-level logic.
Interview angleBe ready to give a one-line violation example for each letter — that's what separates "memorised the acronym" from "understands it."

III. Code Samples (runnable)#

Standard library only, Java 17+ (any modern JDK works). Each: filename, run command, comments, expected output.

Student.java — the base model used throughoutjavac Student.java
// Reusable model. Other demos build on this file.
public class Student {
    private int id;
    private String firstName, lastName, email;

    public Student(int id, String firstName, String lastName, String email) {
        this.id = id; this.firstName = firstName;
        this.lastName = lastName; this.email = email;
    }
    public int getId() { return id; }
    public String getEmail() { return email; }
}
Expected output(no output; compiles to Student.class)
ToStringDemo.javajavac ToStringDemo.java && java ToStringDemo
public class ToStringDemo {
    static class Plain { int id = 7; }            // no toString override

    static class Nice {
        int id = 7;
        @Override public String toString() {      // meaningful override
            return "Nice{id=" + id + "}";
        }
    }
    public static void main(String[] a) {
        System.out.println(new Plain()); // default: ClassName@hexHashCode
        System.out.println(new Nice());  // calls our override implicitly
    }
}
Expected outputToStringDemo$Plain@1b6d3586 <- hex part varies per run/JVM Nice{id=7}
[+] Why the hex varies: the default hashCode is identity-based and JVM-specific, so the part after @ changes between runs. Your overridden output is deterministic.
EqualsDemo.javajavac EqualsDemo.java && java EqualsDemo
public class EqualsDemo {
    static class Student {
        int id;
        Student(int id) { this.id = id; }
        @Override public boolean equals(Object obj) {
            if (obj == null) return false;
            if (!(obj instanceof Student)) return false;
            if (obj == this) return true;
            return this.id == ((Student) obj).id; // value equality by id
        }
    }
    public static void main(String[] a) {
        Student s1 = new Student(1), s2 = new Student(1), s3 = new Student(2);
        System.out.println(s1 == s2);       // false: different references
        System.out.println(s1.equals(s2));  // true : same id
        System.out.println(s1.equals(s3));  // false: different id
    }
}
Expected outputfalse true false
[+] Why == is false but equals is true: == compares references (addresses); the overridden equals compares the id field.
InstanceofVsGetClass.java [+]javac InstanceofVsGetClass.java && java InstanceofVsGetClass
public class InstanceofVsGetClass {
    static class A {}
    static class B extends A {}
    public static void main(String[] x) {
        Object oA = new A(), oB = new B();
        // instanceof: a subclass IS-A superclass
        System.out.println(oB instanceof A);              // true
        // getClass: exact runtime type only
        System.out.println(oB.getClass() == A.class);     // false
        System.out.println(oB.getClass() == B.class);     // true
    }
}
Expected outputtrue false true
[+] This is the crux of the equals() type-check debate: instanceof treats a B as an A (enables cross-type equality, risks symmetry); getClass() demands the exact class (safe symmetry, stricter).
HashCodeDemo.javajavac HashCodeDemo.java && java HashCodeDemo
import java.util.HashSet;
public class HashCodeDemo {
    static class Student {
        int id;
        Student(int id) { this.id = id; }
        @Override public boolean equals(Object o) {
            return o instanceof Student && ((Student)o).id == id;
        }
        // Comment OUT the next method to see the bug:
        @Override public int hashCode() { return id; }
    }
    public static void main(String[] a) {
        HashSet<Student> set = new HashSet<>();
        set.add(new Student(1));
        set.add(new Student(1));            // "equal" duplicate
        System.out.println(set.size());     // 1 with hashCode, 2 without
        System.out.println(set.contains(new Student(1))); // true / false
    }
}
Expected output (with hashCode)1 true If you delete hashCode()2 false <- duplicates leak in; lookup fails (different buckets)
CollisionDemo.javajavac CollisionDemo.java && java CollisionDemo
import java.util.HashSet;
public class CollisionDemo {
    static class Key {
        int id; boolean bad;
        Key(int id, boolean bad) { this.id = id; this.bad = bad; }
        @Override public boolean equals(Object o) {
            return o instanceof Key && ((Key)o).id == id;
        }
        @Override public int hashCode() {
            return bad ? id % 5 : id;  // bad: only 5 buckets
        }
    }
    static long load(boolean bad, int n) {
        long t = System.nanoTime();
        HashSet<Key> s = new HashSet<>();
        for (int i = 0; i < n; i++) s.add(new Key(i, bad));
        return (System.nanoTime() - t) / 1_000_000; // ms
    }
    public static void main(String[] a) {
        int n = 200_000;
        System.out.println("good hashCode: " + load(false, n) + " ms");
        System.out.println("bad  hashCode: " + load(true,  n) + " ms");
    }
}
Expected output (numbers vary by machine)good hashCode: 8 ms bad hashCode: 22093 ms <- much slower: everything piles into 5 buckets
[+] Why so much slower: id % 5 gives 5 distinct codes, so 200k items chain into 5 buckets and each insert scans a long chain. You might expect Java 8+ treeification to rescue this — it does not, and the reason is worth knowing: treeify only restores O(log n) lookups when the keys are Comparable. A red-black bin orders entries by hash first; when every hash in the bin is identical, it needs compareTo to break the tie. Key here implements only equals/hashCode, so the bin falls back to scanning, and the load stays quadratic. Measured on this machine: adding implements Comparable<Key> takes n=100k from 3412 ms to 30 ms.
StringHash31.javajavac StringHash31.java && java StringHash31
public class StringHash31 {
    // Reproduce String.hashCode() by hand
    static int myHash(String s) {
        int h = 0;
        for (int i = 0; i < s.length(); i++) h = 31 * h + s.charAt(i);   // h = (h<<5) - h + c
        return h;
    }
    public static void main(String[] a) {
        System.out.println(myHash("Java"));        // matches built-in
        System.out.println("Java".hashCode());
        // 31*x optimization identity:
        int x = 12345;
        System.out.println((31 * x) == ((x << 5) - x)); // true
    }
}
Expected output2301506 2301506 true
[+] The first two lines match exactly because this is the spec's algorithm. The true proves 31*x == (x<<5)-x — the shift/subtract trick the JIT uses.
SRP_Demo.java [+]javac SRP_Demo.java && java SRP_Demo
public class SRP_Demo {
    // BEFORE: one class with 3 reasons to change (data + format + save)
    static class ReportBad {
        String data = "sales=100";
        String toHtml() { return "<p>" + data + "</p>"; }
        void save()     { System.out.println("saving: " + data); }
    }
    // AFTER: one responsibility each
    static class ReportData   { String data = "sales=100"; }
    static class HtmlFormatter { String format(ReportData r){ return "<p>"+r.data+"</p>"; } }
    static class FileSaver     { void save(ReportData r){ System.out.println("saving: "+r.data);} }

    public static void main(String[] a) {
        ReportData d = new ReportData();
        System.out.println(new HtmlFormatter().format(d)); // change format here only
        new FileSaver().save(d);                           // change persistence here only
    }
}
Expected output<p>sales=100</p> saving: sales=100
OCP_Demo.java [+]javac OCP_Demo.java && java OCP_Demo
public class OCP_Demo {
    // BEFORE: adding a shape forces editing this method (closed? no).
    // double area(Object s){ if (s instanceof Circle)... else if ... }

    // AFTER: open for extension via an interface, closed for modification.
    interface Shape { double area(); }
    static class Circle implements Shape {
        double r; Circle(double r){this.r=r;}
        public double area(){ return Math.PI*r*r; }
    }
    static class Square implements Shape {
        double s; Square(double s){this.s=s;}
        public double area(){ return s*s; }
    }
    // Adding Triangle = new class, ZERO edits to this method:
    static double total(Shape[] shapes){
        double t=0; for (Shape s: shapes) t += s.area(); return t;
    }
    public static void main(String[] a){
        System.out.printf("%.2f%n", total(new Shape[]{ new Circle(1), new Square(2) }));
    }
}
Expected output7.14

IV. Hands-On Lab (practice)#

Warm-up → Core → Challenge. Predict before you run; reveal the solution only after.

One-time setup
# 1. Check the JDK (need 11+, 17+ recommended)
java -version
javac -version

# 2. Make a working folder
mkdir solid-lab && cd solid-lab

# 3. Compile & run any file (single-file source mode, JDK 11+):
java Exercise1.java          # runs directly, no separate javac needed
# or the classic two-step:
javac Exercise1.java && java Exercise1

Exercise 1 — Print yourself Warm-up#

Goal: see the default toString(), then override it.

  1. Create Exercise1.java with a class Book holding title and pages.
  2. In main, print a Book instance with no override.
  3. Add an @Override toString() and print again.
Predict then verifyBefore running step 2, write down what you think prints. What does the part after @ represent?
Solution
Exercise1.javajava Exercise1.java
public class Exercise1 {
    static class Book {
        String title; int pages;
        Book(String t, int p){ title=t; pages=p; }
        @Override public String toString(){    // comment out to see default
            return "Book{title=" + title + ", pages=" + pages + "}";
        }
    }
    public static void main(String[] a){
        System.out.println(new Book("SOLID", 320));
    }
}
Expected outputBook{title=SOLID, pages=320} // Without the override: Exercise1$Book@<hex> (hex = hashCode, not an address)

Exercise 2 — Equality by field Warm-up#

Goal: make two distinct objects compare equal by a chosen field.

  1. Class User with an email field.
  2. Create two users with the same email; print == and .equals() before overriding.
  3. Override equals() to compare emails (case-insensitive). Re-test.
Predict then verifyWill == ever become true after your override? Why not?
Solution
Exercise2.javajava Exercise2.java
public class Exercise2 {
    static class User {
        String email; User(String e){ email=e; }
        @Override public boolean equals(Object o){
            if (!(o instanceof User)) return false;       // also handles null
            return email.equalsIgnoreCase(((User)o).email);
        }
    }
    public static void main(String[] a){
        User u1 = new User("A@x.com"), u2 = new User("a@x.com");
        System.out.println(u1 == u2);       // false (always: 2 objects)
        System.out.println(u1.equals(u2));  // true  (case-insensitive match)
    }
}
Expected outputfalse true

== stays false because they remain two separate objects in memory; only equals looks at the field.

Exercise 3 — The HashSet duplicate trap Core#

Goal: reproduce the "equal objects still duplicate" bug, then fix it.

  1. Reuse User from Exercise 2 (with equals overridden, no hashCode).
  2. Add two equal users to a HashSet; print size().
  3. Add @Override hashCode() returning email.toLowerCase().hashCode(). Re-test.
Predict then verifyPredict the size in step 2 and step 3. Why does overriding only equals not fix dedup?
Solution
Exercise3.javajava Exercise3.java
import java.util.HashSet;
public class Exercise3 {
    static class User {
        String email; User(String e){ email=e; }
        @Override public boolean equals(Object o){
            return o instanceof User && email.equalsIgnoreCase(((User)o).email);
        }
        @Override public int hashCode(){            // remove to see the bug
            return email.toLowerCase().hashCode();  // equal -> same bucket
        }
    }
    public static void main(String[] a){
        HashSet<User> s = new HashSet<>();
        s.add(new User("a@x.com")); s.add(new User("A@x.com"));
        System.out.println(s.size());   // 1 with hashCode, 2 without
    }
}
Expected output1 // (2 if you delete hashCode())

Without matching hash codes the two users land in different buckets, so the set never compares them with equals — both get stored.

Exercise 4 — Measure a bad hashCode Core#

Goal: feel the performance cost of collisions.

  1. Make a Key with an int id and a switchable hashCode (id vs id % 4).
  2. Time loading 200,000 keys into a HashSet for each version (System.nanoTime()).
  3. Print both timings.
Predict then verifyHow many buckets does id % 4 effectively use? Predict the slowdown ratio, then check.
Solution
Exercise4.javajava Exercise4.java
import java.util.HashSet;
public class Exercise4 {
    static class Key {
        int id; boolean bad; Key(int id, boolean bad){ this.id=id; this.bad=bad; }
        @Override public boolean equals(Object o){ return o instanceof Key && ((Key)o).id==id; }
        @Override public int hashCode(){ return bad ? id % 4 : id; }
    }
    static long run(boolean bad){
        long t=System.nanoTime(); HashSet<Key> s=new HashSet<>();
        for (int i=0;i<200_000;i++) s.add(new Key(i,bad));
        return (System.nanoTime()-t)/1_000_000;
    }
    public static void main(String[] a){
        System.out.println("good: "+run(false)+" ms");
        System.out.println("bad : "+run(true) +" ms");
    }
}
Expected output (varies by machine)good: 8 ms bad : 23362 ms // 4 buckets -> long chains -> slow inserts

id % 4 uses only 4 distinct codes; all 200k items chain into 4 buckets. [+] Treeification does not save you here: a tree bin orders by hash, and with every hash equal it needs the keys to be Comparable to break the tie. This key is not, so the bin degenerates to a scan and the cost really is quadratic — note the run takes ~23 seconds, not milliseconds. Make the key Comparable and the same workload drops back to tens of ms.

Exercise 5 — Reimplement String.hashCode Core#

Goal: match the JDK's String hash and prove the 31 shortcut.

  1. Write myHash(String) using h = 31*h + ch.
  2. Compare it to "Hello".hashCode() for several strings.
  3. Assert 31*x == (x<<5)-x for some x.
Predict then verifyWill your value match the built-in exactly? Why must it, given the spec?
Solution
Exercise5.javajava Exercise5.java
public class Exercise5 {
    static int myHash(String s){
        int h=0; for (int i=0;i<s.length();i++) h = 31*h + s.charAt(i); return h;
    }
    public static void main(String[] a){
        for (String s : new String[]{"Hello","SOLID","a"}) System.out.println(s+": mine="+myHash(s)+" jdk="+s.hashCode());
        int x=987; System.out.println("shift trick: " + (31*x == (x<<5)-x));
    }
}
Expected outputHello: mine=69609650 jdk=69609650 SOLID: mine=79081099 jdk=79081099 a: mine=97 jdk=97 shift trick: true

It must match: this is the documented algorithm. (A single char like 'a' hashes to its code point, 97.)

Exercise 6 — Refactor to SRP + OCP Challenge#

Goal: take a fat class with a type-switch and refactor it to satisfy both principles.

  1. Start from a PaymentProcessor with process(String type, double amt) that does if "card"... else if "cash"... and also logs.
  2. SRP: pull logging into its own class/interface.
  3. OCP: replace the type-switch with a PaymentMethod interface + one class per method.
  4. Add a WalletPayment without editing the dispatch code — prove OCP.
Predict then verifyAfter refactoring, how many existing classes must you touch to add a new payment type? (Target: zero.)
Solution
Exercise6.javajava Exercise6.java
public class Exercise6 {
    // SRP: logging is its own responsibility
    interface Logger { void log(String m); }
    static class ConsoleLogger implements Logger {
        public void log(String m){ System.out.println("[log] " + m); }
    }
    // OCP: each payment type is a separate implementation
    interface PaymentMethod { void pay(double amt); }
    static class CardPayment implements PaymentMethod {
        public void pay(double a){ System.out.println("card -> " + a); }
    }
    static class CashPayment implements PaymentMethod {
        public void pay(double a){ System.out.println("cash -> " + a); }
    }
    static class WalletPayment implements PaymentMethod {   // ADDED, no edits elsewhere
        public void pay(double a){ System.out.println("wallet -> " + a); }
    }
    // Dispatch depends on the abstraction; never changes when types are added
    static class Processor {
        Logger logger;
        Processor(Logger l){ logger = l; }
        void process(PaymentMethod m, double amt){
            m.pay(amt); logger.log("processed " + amt);
        }
    }
    public static void main(String[] a){
        Processor p = new Processor(new ConsoleLogger());
        p.process(new CardPayment(),   100);
        p.process(new CashPayment(),    50);
        p.process(new WalletPayment(),  25);  // works with zero dispatch edits
    }
}
Expected outputcard -> 100.0 [log] processed 100.0 cash -> 50.0 [log] processed 50.0 wallet -> 25.0 [log] processed 25.0

Adding WalletPayment touched zero existing classes → OCP satisfied. Logging lives apart from payment logic → SRP satisfied.

Self-check rubric — how to know you've got it
  • I can explain why println(obj) shows ClassName@hex and that the hex is a hashCode, not an address.
  • I can state why two field-identical objects are ==-unequal but can be equals-equal.
  • I can recite the contract: equal ⇒ same hashCode; same hashCode ⇏ equal.
  • I can explain why overriding equals without hashCode breaks HashSet dedup (buckets).
  • I can argue both sides of instanceof vs getClass() (symmetry vs LSP).
  • I can justify "31" (odd, prime, (i<<5)-i) and note it isn't provably optimal.
  • I can name all five SOLID letters and give a one-line violation example for each.
  • I refactored Exercise 6 so a new payment type needs zero edits to existing code.

★ Key Takeaways#

  • Object is the universal root. Every class inherits toString, equals, hashCode, getClass, and more.
  • toString() is auto-called by println; override it for readable logging/debugging. Default is ClassName@hexHashCode (hex = hashCode, [+] not a memory address).
  • equals() defaults to reference equality; override for value equality and respect the contract (reflexive, symmetric, transitive, consistent, false-on-null).
  • instanceof vs getClass() [+] is a real trade-off: instanceof respects LSP but can break symmetry; getClass() is symmetric but rejects subclasses.
  • hashCode() & buckets: override it whenever you override equals. Equal objects must share a hashCode or hash collections fail.
  • Collisions cost speed, not correctness. Spread hash codes; [+] Java 8+ treeifies fat buckets (O(n)→O(log n)) once a bin passes 8 entries and the table has at least 64 buckets — under 64 it resizes instead — and resizes past load factor 0.75. But treeify only pays off when the keys are Comparable: a tree bin orders by hash, and if every hash collides it needs compareTo to break the tie. Exercise 4 measures exactly this — a non-Comparable key stays quadratic.
  • The 31 multiplier in String.hashCode is odd + prime + cheap ((i<<5)-i); [+] traditional, not proven optimal.
  • SOLID = Single Responsibility, Open–Closed, Liskov Substitution, Interface Segregation, Dependency Inversion — for clean, loosely-coupled, testable, maintainable code. [+] Guidelines, not laws; don't over-apply.

[+] = my own additions/corrections, all fact-checked. Core spine: Object methods (toString/equals/hashCode) + intro to SOLID with SRP and Open–Closed.