3. Inheritance and Polymorphism
Chapter 2 built single classes that protect their own state. This chapter connects classes to each other: how one type can be defined in terms of another, how a subclass can replace inherited behaviour, and how a single reference can drive many different implementations chosen at runtime. Polymorphism in particular is what lets you add a new case to a program without editing the code that uses it, which is the central mechanism behind most Java frameworks and design patterns.
<a id="31-class-inheritance"></a>
3.1 Class Inheritance
Inheritance establishes an "is-a" relationship between two classes, letting the subclass reuse and extend what the superclass already defines.
The extends Keyword
Two classes frequently share most of their data and behaviour while differing in a few details. Copying the shared parts into both means every future fix must be applied twice, and eventually is not. Inheritance lets you write the shared part once and describe only the difference.
public class Vehicle {
protected String make;
protected String model;
protected int wheels;
public void start() { System.out.println(make + " starting"); }
public void stop() { System.out.println(make + " stopping"); }
}
public class Motorcycle extends Vehicle { // Motorcycle IS-A Vehicle
private boolean hasSidecar;
public void wheelie() { // behaviour unique to Motorcycle
System.out.println("Performing a wheelie");
}
}
Motorcycle m = new Motorcycle();
m.make = "Ducati"; // inherited field
m.start(); // inherited method: "Ducati starting"
m.wheelie(); // its own method
The subclass gains every accessible member of the superclass without restating any of it. The vocabulary varies by source and all of it means the same thing:
| Term | Synonyms |
|---|---|
| Superclass | Parent class, base class |
| Subclass | Child class, derived class, extension |
| Inheritance | Generalisation, specialisation |
Java supports single inheritance only. A class may extend exactly one superclass, though it may implement many interfaces:
public class Car extends Vehicle { } // fine
// public class Hybrid extends Car, Engine { } // ERROR: only one
public class Hybrid extends Car implements Electric, Fuel { } // fine
The restriction avoids the "diamond problem", where a class inherits two conflicting implementations of the same method through different parents. C++ permits multiple inheritance and needs complex rules to resolve it; Java chose to make state inheritance single and allow multiple type inheritance through interfaces, which is covered in chapter 4.
Every class ultimately extends Object. A class with no extends clause
implicitly extends Object, so the hierarchy is a single tree with Object at
its root:
Object
/ | \
Vehicle String Number
/ \ | \
Car Motorcycle Integer Double
/
SportsCar
Every class has exactly one parent; Object has none.
public class Vehicle { }
// is exactly equivalent to:
public class Vehicle extends Object { }
What is and is not inherited:
| Member | Inherited? |
|---|---|
public members | Yes |
protected members | Yes |
| Package-private members | Only if the subclass is in the same package |
private members | No — they exist in the object but are inaccessible |
| Constructors | No — but the subclass must invoke one |
| Static members | Accessible, but hidden rather than overridden |
| Instance initialiser blocks | Not inherited; each class runs its own |
The private row is subtle and worth stating precisely: a private field of the
superclass is present in memory in every subclass instance, but the subclass
has no name for it. It can only be reached through inherited accessor methods.
public class Account {
private double balance; // exists in every subclass
public double getBalance() { return balance; } // the only way in
}
public class SavingsAccount extends Account {
public void addInterest(double rate) {
// balance *= (1 + rate); // ERROR: balance has private access
double current = getBalance(); // reachable only through the accessor
}
}
final prevents extension. A class marked final cannot be subclassed,
which is how String, Integer, and LocalDate guarantee their behaviour
cannot be subverted:
public final class Password { }
// public class Weak extends Password { } // ERROR: cannot inherit from final
Key Takeaways
extendscreates an is-a relationship and gives the subclass the superclass's accessible members.- Java allows single class inheritance, avoiding the diamond problem; multiple interfaces are permitted.
- Every class implicitly extends
Object, so the hierarchy is one tree.- Private members are present in subclass instances but inaccessible except through inherited methods.
- Constructors are never inherited, and
finalclasses cannot be extended.
🧪 Practice
- Create a
Shapeclass with anamefield and adescribe()method, then extend it withSquareadding asidefield.- Add a private field to a superclass and try to use it directly from a subclass; then expose it correctly.
- Interview: Why does Java forbid extending two classes but allow implementing two interfaces? (Hint: what does each one contribute — state, behaviour, or just a type?)
Superclass and Subclass Relationships
Inheritance is not merely code reuse; it declares that instances of the subclass are instances of the superclass. That claim has consequences the compiler enforces and that your design must honour.
Substitutability. Anywhere a Vehicle is expected, a Motorcycle is
acceptable. This is what makes polymorphism possible:
Vehicle v = new Motorcycle(); // upcasting: always safe, always implicit
public void service(Vehicle vehicle) { ... }
service(new Motorcycle()); // a Motorcycle IS a Vehicle
service(new Car());
The relationship is one-directional. Every Motorcycle is a Vehicle, but not
every Vehicle is a Motorcycle:
// Motorcycle m = new Vehicle(); // ERROR: a Vehicle is not necessarily a Motorcycle
The instanceof operator tests the relationship at runtime and returns true
for the class and every ancestor:
Motorcycle m = new Motorcycle();
System.out.println(m instanceof Motorcycle); // true
System.out.println(m instanceof Vehicle); // true -- it is also a Vehicle
System.out.println(m instanceof Object); // true -- everything is an Object
Vehicle v = null;
System.out.println(v instanceof Vehicle); // false -- null is never an instance
Designing a hierarchy means asking whether the is-a claim is genuinely true in every context the superclass is used. The classic counterexample:
// Mathematically a square IS a rectangle. In code, it breaks.
public class Rectangle {
protected int width, height;
public void setWidth(int w) { width = w; }
public void setHeight(int h) { height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override public void setWidth(int w) { width = w; height = w; }
@Override public void setHeight(int h) { width = h; height = h; }
}
// Code written against Rectangle now misbehaves:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
System.out.println(r.area()); // 16, not 20 -- the caller's assumption broke
The fault is not in Square; it is in claiming that a mutable square is
substitutable for a mutable rectangle. This is a violation of the Liskov
Substitution Principle, covered in chapter 9. The practical lesson: an is-a
relationship in the real world does not automatically hold for objects with
behaviour.
Levels of hierarchy. Java permits arbitrarily deep chains, and each level accumulates the members of all its ancestors:
class Animal { void breathe() { } }
class Mammal extends Animal { void nurse() { } }
class Dog extends Mammal { void bark() { } }
class Puppy extends Dog { void whimper() { } }
Puppy p = new Puppy();
p.breathe(); p.nurse(); p.bark(); p.whimper(); // all four available
Animal breathe()
|
Mammal breathe(), nurse()
|
Dog breathe(), nurse(), bark()
|
Puppy breathe(), nurse(), bark(), whimper()
Members accumulate downward; a change at the top affects everything below.
That last line is the real cost of deep hierarchies. A change to Animal
potentially affects every descendant, and understanding Puppy requires reading
four classes. Most style guides suggest keeping hierarchies shallow — two or
three levels — and preferring composition, the subject of section 3.6, when a
hierarchy starts growing deeper.
Key Takeaways
- Inheritance asserts substitutability: a subclass instance is usable wherever the superclass is expected.
- Upcasting is implicit and safe; the reverse is not, because the claim is one-directional.
instanceofis true for the class and all its ancestors, and false fornull.- A real-world is-a relationship does not guarantee behavioural substitutability, as the square-rectangle case shows.
- Deep hierarchies concentrate risk at the top; keep them shallow.
🧪 Practice
- Build a three-level hierarchy and verify with
instanceofthat the most derived instance matches all three types.- Implement the
Square extends Rectangleexample and write a method that behaves incorrectly when given a square.- Interview: Give an example where an is-a relationship is true in the real world but a bad inheritance choice in code. (Hint: think about what the superclass lets callers do.)
Inherited Members and Access Rules
Which members a subclass can actually use depends on the access modifier and, for package-private members, on whether the two classes share a package. This topic turns the access table from chapter 2 into concrete inheritance rules.
package com.example.base;
public class Base {
private String secret = "private";
/*default*/ String internal = "package";
protected String shared = "protected";
public String open = "public";
private void hidden() { }
protected void helper() { }
public void api() { }
}
A subclass in the same package sees everything except the private members:
package com.example.base;
public class SamePackageChild extends Base {
void test() {
// secret; // ERROR: private
System.out.println(internal); // OK: same package
System.out.println(shared); // OK
System.out.println(open); // OK
}
}
A subclass in a different package loses the package-private members too:
package com.example.other;
import com.example.base.Base;
public class OtherPackageChild extends Base {
void test() {
// secret; // ERROR: private
// internal; // ERROR: different package
System.out.println(shared); // OK: protected, via inheritance
System.out.println(open); // OK
}
}
| Member access | Subclass, same package | Subclass, different package | Non-subclass, other package |
|---|---|---|---|
private | No | No | No |
| package-private | Yes | No | No |
protected | Yes | Yes | No |
public | Yes | Yes | Yes |
The protected subtlety, introduced in chapter 2 and worth repeating here
because it only bites in inheritance code: from a different package, a subclass
may access a protected member through this or through a reference of its own
type, but not through a reference of the superclass type.
package com.example.other;
public class Child extends Base {
void test(Base anyBase, Child anotherChild) {
System.out.println(this.shared); // OK
System.out.println(anotherChild.shared); // OK: same subclass type
// System.out.println(anyBase.shared); // ERROR: not through Base
}
}
The rule exists so a subclass cannot use protected as a backdoor into
unrelated sibling objects. Motorcycle should not be able to read
Car's protected fields just because both extend Vehicle.
Private members still occupy memory. A subclass instance contains every field declared anywhere in its ancestry; the subclass simply has no name for the private ones:
new Dog() memory layout
+--------------------------------+
| Object header |
| Animal.age (private) | <- present, unreachable by name from Dog
| Animal.name (protected) | <- present and accessible
| Dog.breed (private) |
+--------------------------------+
Accessors bridge the gap, which is the recommended design anyway:
public class Animal {
private int age; // hidden representation
protected int getAge() { return age; } // controlled access for subclasses
}
public class Dog extends Animal {
public String describe() {
return "Dog aged " + getAge(); // uses the accessor
}
}
Preferring protected methods over protected fields keeps the superclass free to change how the value is stored, exactly as public getters do for outside callers. A protected field is a commitment to every present and future subclass.
Key Takeaways
- Private members are never accessible to subclasses, though they exist in every instance.
- Package-private members are inherited usefully only within the same package.
protectedgrants access throughthisor the subclass's own type, not through a superclass reference.- Prefer protected accessor methods to protected fields, to keep the representation changeable.
- The access level of a member is part of the contract with every future subclass.
🧪 Practice
- Build the four-member
Baseclass and test every access combination from both a same-package and a different-package subclass.- Try to access a protected field through a superclass-typed reference from another package and read the error.
- Convert a protected field into a protected getter and explain what flexibility you gained.
The super Keyword for Fields and Methods
super refers to the superclass portion of the current object. It is needed
whenever a subclass member hides or overrides an inherited one and you still
want the original.
Calling an overridden method. This is by far the most common use, and it enables the "extend, do not replace" pattern:
public class Logger {
public void log(String message) {
System.out.println("[LOG] " + message);
}
}
public class TimestampLogger extends Logger {
@Override
public void log(String message) {
System.out.print(LocalTime.now() + " ");
super.log(message); // delegate to the inherited behaviour
}
}
new TimestampLogger().log("started"); // 14:32:01.123 [LOG] started
Without super., writing log(message) inside the override would call the
override itself, producing infinite recursion and a StackOverflowError:
@Override
public void log(String message) {
log(message); // INFINITE RECURSION: calls itself, not the parent
}
Accessing a hidden field. When a subclass declares a field with the same
name as an inherited one, the subclass field hides the superclass field. Both
exist, and super selects the parent's:
public class Parent {
protected String name = "parent";
}
public class Child extends Parent {
protected String name = "child"; // HIDES Parent.name, does not replace it
public void print() {
System.out.println(name); // "child" -- nearest declaration
System.out.println(this.name); // "child"
System.out.println(super.name); // "parent" -- the hidden one
System.out.println(((Parent) this).name); // "parent" -- same thing
}
}
One Child object, TWO name fields
+----------------------------+
| Parent.name = "parent" | <- super.name
| Child.name = "child" | <- this.name
+----------------------------+
Field hiding is legal and almost always a mistake: it produces two fields that readers assume are one. Rename one of them.
super works one level up only. There is no super.super to reach a
grandparent:
class A { void greet() { System.out.println("A"); } }
class B extends A { @Override void greet() { System.out.println("B"); } }
class C extends B {
@Override void greet() {
super.greet(); // calls B.greet()
// super.super.greet(); // ERROR: no such syntax
}
}
This is deliberate. Skipping a level would let C bypass whatever invariant B
established in its override, breaking B's contract.
Where super is unnecessary. If the subclass does not hide or override
anything, inherited members are reachable by their plain names, and writing
super. adds noise:
public class Dog extends Animal {
public void describe() {
breathe(); // inherited and not overridden: 'super.' is redundant
super.breathe(); // same effect, but implies an override exists
}
}
Using super. gratuitously misleads readers into looking for an override that
is not there.
Static context. super is an instance reference and does not exist in
static methods, for the same reason this does not.
Key Takeaways
super.method()invokes the superclass version, which is how an override extends rather than replaces behaviour.- Omitting
super.inside an override calls the override again and recurses infinitely.super.fieldreaches a hidden superclass field; both fields exist in the object.- Field hiding is legal and almost always a naming mistake.
- There is no
super.super, andsuperis unavailable in static context.
🧪 Practice
- Write an override that prints something and then delegates with
super.method().- Deliberately omit
super.in an override and observe theStackOverflowError.- Create a subclass that hides a superclass field, then print both values from one method.
Constructor Invocation with super()
Constructors are not inherited, yet every object contains its superclass's fields, and those fields must be initialised. Java guarantees this by requiring every constructor to invoke a superclass constructor before doing anything else.
public class Person {
private final String name;
public Person(String name) {
this.name = name;
System.out.println("Person constructor");
}
}
public class Employee extends Person {
private final String employeeId;
public Employee(String name, String employeeId) {
super(name); // MUST be the first statement
this.employeeId = employeeId;
System.out.println("Employee constructor");
}
}
new Employee("Ada", "E-1");
// Person constructor
// Employee constructor
The implicit super(). If a constructor does not begin with this(...) or
super(...), the compiler inserts a no-argument super():
public class Child extends Parent {
public Child() {
// super(); <- inserted automatically by the compiler
System.out.println("Child");
}
}
This is the source of a very common compile error. If the superclass has no no-argument constructor, the implicit call has nothing to target:
public class Parent {
public Parent(String name) { } // only a parameterized constructor
}
public class Child extends Parent {
public Child() {
// ERROR: constructor Parent in class Parent cannot be applied to
// given types; required: String, found: no arguments
}
}
// Fix 1: call the existing constructor explicitly
public class Child extends Parent {
public Child() { super("default"); }
}
// Fix 2: give the parent a no-argument constructor
The rules:
super(...)must be the first statement in a constructor.- A constructor may contain
super(...)orthis(...), never both. - If neither is written,
super()is inserted. - The chain always terminates at
Object's constructor.
new Employee("Ada", "E-1")
Employee(String, String)
| super(name)
v
Person(String)
| implicit super()
v
Object()
|
+--> returns, then Person's body runs, then Employee's body runs
Construction goes UP the chain, then bodies execute DOWNWARD.
Combining with this(...). A constructor that delegates within its own
class does not call super itself; the constructor it delegates to does:
public class Employee extends Person {
public Employee(String name) {
this(name, "UNASSIGNED"); // delegates within Employee
}
public Employee(String name, String employeeId) {
super(name); // this one reaches the superclass
this.employeeId = employeeId;
}
}
Why "first statement" is required. Until the superclass constructor
completes, the superclass fields hold default values. Allowing code before
super(...) would let a subclass observe or use a half-initialised parent:
public class Child extends Parent {
public Child() {
// System.out.println(getParentName()); // would read null
super("name");
}
}
This is the same principle behind the initialisation order from chapter 2, and the reason calling an overridable method from a constructor is dangerous.
Java 22 relaxed this slightly with statements permitted before super(...) as a
preview feature, provided they do not reference the instance. The classic rule
remains what you will encounter in existing code and interviews.
Key Takeaways
- Constructors are not inherited, but every constructor must invoke one from its superclass.
- An implicit
super()is inserted when neithersuper(...)northis(...)is written.- A superclass without a no-argument constructor forces every subclass constructor to call
super(...)explicitly.super(...)must come first and cannot coexist withthis(...).- Construction walks up to
Object, then constructor bodies execute from the top down.
🧪 Practice
- Write a superclass with only a parameterized constructor, then write a subclass constructor and fix the resulting error.
- Add print statements to a three-level hierarchy's constructors and verify the execution order.
- Interview: Why must
super(...)be the first statement? (Hint: what state are the superclass fields in before it runs?)
Single Inheritance and the Class Hierarchy
Java's decision to permit only one superclass shapes how hierarchies are designed and why interfaces exist in the form they do.
The diamond problem is what single inheritance avoids. If a class could inherit from two classes that both define the same method, which implementation wins?
Device
/ \
Printer Scanner both override powerOn()
\ /
MultiFunction which powerOn() does it inherit?
Languages allowing multiple inheritance need explicit rules for this (C++ uses virtual inheritance and scope resolution). Java sidesteps it: a class has exactly one implementation parent, so there is never ambiguity about inherited state or inherited implementation.
// public class MultiFunction extends Printer, Scanner { } // ERROR
Interfaces provide multiple type inheritance. A class can be many types while having one implementation ancestry:
public interface Printable { void print(Document d); }
public interface Scannable { Document scan(); }
public class MultiFunction extends Device implements Printable, Scannable {
@Override public void print(Document d) { ... }
@Override public Document scan() { ... }
}
MultiFunction mf = new MultiFunction();
Device d = mf; // it is a Device
Printable p = mf; // and a Printable
Scannable s = mf; // and a Scannable
Default methods, added in Java 8, reintroduced a limited form of the problem, and Java resolves it by requiring the class to override when two interfaces supply conflicting defaults. Chapter 4 covers the exact rules.
The single-rooted hierarchy. Because every class descends from Object,
some useful guarantees follow:
Object[] anything = { "text", 42, new Dog(), null }; // any object fits
for (Object o : anything) {
System.out.println(o); // toString() is guaranteed to exist
}
Every object has toString, equals, hashCode, and getClass, which is what
makes generic containers and frameworks possible at all. The cost is that these
methods exist even where they make little sense.
Designing a hierarchy well. Three practical guidelines:
1. Keep it shallow. Each level adds coupling and reading effort. Two or three levels is typical; more suggests composition is the better tool.
2. Put only genuinely common behaviour in the superclass. A method that half the subclasses override to do nothing does not belong there:
// Poor: not every Bird flies
public class Bird {
public void fly() { System.out.println("Flying"); }
}
public class Penguin extends Bird {
@Override public void fly() {
throw new UnsupportedOperationException(); // a red flag
}
}
// Better: separate the capability from the classification
public class Bird { }
public interface Flying { void fly(); }
public class Sparrow extends Bird implements Flying { public void fly() { ... } }
public class Penguin extends Bird { } // simply does not implement Flying
Throwing UnsupportedOperationException from an override is a reliable signal
that the hierarchy is wrong.
3. Design for inheritance or forbid it. A class intended for subclassing
must document which methods are overridable and how they interact. A class not
intended for it should be final:
public final class Password { } // explicitly closed
public abstract class Shape { } // explicitly open, and incomplete
| Approach | Type inheritance | Implementation inheritance | State inheritance |
|---|---|---|---|
extends a class | Single | Yes | Yes |
implements interfaces | Multiple | Default methods only | No (constants only) |
| Composition (chapter 3.6) | None | By delegation | By containment |
Key Takeaways
- Single inheritance eliminates ambiguity about inherited state and implementation.
- Interfaces give multiple type inheritance without that ambiguity.
- Every class descends from
Object, which guarantees a small universal API.- An override that throws
UnsupportedOperationExceptionsignals a broken hierarchy.- Classes should be explicitly designed for extension or marked
final.
🧪 Practice
- Model a
MultiFunctiondevice using one superclass and two interfaces, and assign it to three different reference types.- Find the design flaw in a
Birdhierarchy wherePenguinoverridesfly()to throw, then fix it.- Interview: What is the diamond problem, and how does Java avoid it while still allowing multiple interfaces? (Hint: what does an interface historically not contain?)
<a id="32-method-overriding"></a>
3.2 Method Overriding
Inheritance alone only reuses behaviour. Overriding lets a subclass replace an inherited method with its own version, which is the mechanism that makes polymorphism useful.
Overriding Rules and Signature Matching
To override a method, the subclass must declare a method the JVM recognises as the same method. Get any part of it wrong and you have written a new, unrelated method instead, which compiles silently and does nothing.
public class Animal {
public String speak() { return "Some sound"; }
}
public class Dog extends Animal {
@Override
public String speak() { return "Woof"; } // overrides Animal.speak()
}
Animal a = new Dog();
System.out.println(a.speak()); // "Woof" -- the subclass version runs
The requirements:
| Element | Rule |
|---|---|
| Method name | Identical |
| Parameter list | Identical in type, order, and count |
| Return type | Identical, or a subtype (covariant) |
| Access modifier | Same or more permissive; never narrower |
| Checked exceptions | Same, narrower, fewer, or none; never broader |
static | Cannot override; declaring it again hides instead |
final | Cannot be overridden at all |
private | Not inherited, so not overridable |
The parameter-list trap is the one that catches everyone:
public class Animal {
public void feed(String food) { }
}
public class Dog extends Animal {
public void feed(int amount) { } // NOT an override -- it is an overload
public void Feed(String food) { } // NOT an override -- different name
public void feed(Object food) { } // NOT an override -- different parameter type
}
Animal a = new Dog();
a.feed("bone"); // calls Animal.feed -- the Dog versions are irrelevant here
None of those three declarations produces a compile error. They are all valid
new methods, and the inherited one remains in place. This is precisely why
@Override exists, covered in a moment.
Access may widen, never narrow. A subclass cannot make an inherited method less accessible, because that would break substitutability — code holding a superclass reference is entitled to call the method:
public class Animal {
public void speak() { }
}
public class Dog extends Animal {
// private void speak() { } // ERROR: attempting to assign weaker
// protected void speak() { } // access privileges
public void speak() { } // OK: same level
}
Widening is allowed: private -> package -> protected -> public
Narrowing is not: public -> protected is a compile error
Exceptions may narrow, never broaden. The same reasoning applies: a caller
holding a superclass reference wrote a catch block based on the superclass
declaration.
public class Reader {
public void read() throws IOException { }
}
public class FileReader extends Reader {
@Override public void read() throws FileNotFoundException { } // OK: subtype
}
public class StrictReader extends Reader {
@Override public void read() { } // OK: none at all
}
public class BadReader extends Reader {
// @Override public void read() throws Exception { } // ERROR: broader
}
Unchecked exceptions (RuntimeException and its subclasses) are exempt from
this rule entirely — an override may throw any of them without declaring
anything.
Instance methods only. Constructors, static methods, private methods, and
final methods cannot be overridden. Each for a different reason: constructors
are not inherited, static methods have no dispatch target, private methods are
invisible, and final explicitly forbids it.
Key Takeaways
- An override must match the name and parameter list exactly; anything else creates a new method.
- Access may widen but never narrow, and checked exceptions may narrow but never broaden.
- Unchecked exceptions are unrestricted in overrides.
static,private,final, and constructors cannot be overridden.- A failed override compiles silently, which is why
@Overridematters.
🧪 Practice
- Write an intended override with a slightly different parameter type and demonstrate that the superclass version still runs.
- Try narrowing the access modifier of an override and read the error.
- Interview: Why can an override not throw a broader checked exception? (Hint: think about what a caller holding a superclass reference already wrote.)
Covariant Return Types
Before Java 5, an override had to declare exactly the same return type as the method it overrode. That forced callers to cast constantly. Covariant returns relaxed the rule: an override may return a subtype of the original return type.
public class Animal {
public Animal reproduce() { return new Animal(); }
}
public class Dog extends Animal {
@Override
public Dog reproduce() { return new Dog(); } // Dog is a subtype of Animal
}
Dog parent = new Dog();
Dog puppy = parent.reproduce(); // no cast needed
Animal a = parent;
Animal offspring = a.reproduce(); // still type-safe through the supertype
Without covariance, the second line would need (Dog) parent.reproduce(), which
is both noisy and unchecked.
Why it is safe. Substitutability is preserved: anyone expecting an Animal
back receives a Dog, which is an Animal. The promise is strengthened, not
weakened. The reverse would be unsafe and is rejected:
public class Dog extends Animal {
// @Override public Object reproduce() { } // ERROR: Object is not a subtype
} // of Animal
Superclass: Animal reproduce() promises "at least an Animal"
Override: Dog reproduce() delivers "a Dog" -> stronger, allowed
Override: Object reproduce() delivers "maybe not an Animal" -> rejected
The classic application is clone, and more generally any method returning
"one of me":
public class Shape implements Cloneable {
@Override
public Shape clone() { // Object.clone() returns Object
try {
return (Shape) super.clone(); // covariant: narrows to Shape
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
}
public class Circle extends Shape {
@Override
public Circle clone() { return (Circle) super.clone(); } // narrows further
}
Circle c = new Circle().clone(); // no cast at the call site
Builders use it heavily, though the generic version is more common in practice:
public class Builder {
public Builder name(String n) { ...; return this; }
}
public class AdvancedBuilder extends Builder {
@Override
public AdvancedBuilder name(String n) { super.name(n); return this; }
public AdvancedBuilder extra(String e) { ...; return this; }
}
new AdvancedBuilder().name("x").extra("y"); // chaining survives the override
Restrictions:
| Situation | Allowed? |
|---|---|
| Subtype of the original return | Yes |
| Supertype of the original return | No |
| Unrelated type | No |
| Primitive to wrapper, or reverse | No — covariance applies to references only |
void changed to anything | No |
public class Base { public long count() { return 0; } }
public class Derived extends Base {
// @Override public int count() { return 0; } // ERROR: int is not a subtype
} // of long
Numeric widening is not subtyping, so int cannot covariantly override long
even though an int value fits in a long.
Key Takeaways
- An override may return a subtype of the declared return type.
- This is safe because it strengthens the promise made to callers.
- It removes casts at call sites, most visibly for
cloneand fluent builders.- Covariance applies to reference types only; primitive widening does not qualify.
- Returning a supertype or an unrelated type is rejected.
🧪 Practice
- Write a hierarchy where each class overrides a
copy()method returning its own type, and verify no casts are needed.- Try to override a method returning
Integerwith one returningObjectand explain the error.- Interview: Why is a covariant return type safe while a covariant parameter type would not be? (Hint: consider who supplies each one.)
The @Override Annotation
@Override tells the compiler that the annotated method is intended to override
an inherited one. If it does not, compilation fails. It costs nothing and
prevents an entire class of silent bugs.
public class Animal {
public String speak() { return "..."; }
}
public class Dog extends Animal {
@Override
public String speak() { return "Woof"; } // verified at compile time
}
The bug it catches. Without the annotation, a typo or signature mismatch produces a new method and no warning:
public class Dog extends Animal {
public String Speak() { return "Woof"; } // capital S -- silently useless
}
Animal a = new Dog();
System.out.println(a.speak()); // "..." -- the Animal version, not what you wrote
Add the annotation and the compiler refuses:
public class Dog extends Animal {
@Override
public String Speak() { return "Woof"; }
// ERROR: method does not override or implement a method from a supertype
}
The most costly instance of this bug is a mistyped equals:
public class Point {
private int x, y;
// Intended as an override of Object.equals -- it is not
public boolean equals(Point other) { // parameter should be Object
return this.x == other.x && this.y == other.y;
}
}
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
System.out.println(set.contains(new Point(1, 2))); // false!
HashSet calls equals(Object), which is still Object's identity-based
version. The overload is never invoked by the collection. With @Override, this
fails to compile immediately.
@Override
public boolean equals(Object o) { // correct signature, verified
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
It also works for interface methods, since Java 6:
public class Task implements Runnable {
@Override
public void run() { } // verified against the interface
}
This catches the case where an interface method is renamed and implementations silently stop implementing it.
A second benefit is documentation. A reader sees immediately that a method participates in polymorphism and that a superclass version exists, which is information they would otherwise have to go looking for.
Where it does not apply:
public class Example {
// @Override
public void brandNewMethod() { } // ERROR: nothing to override
// @Override
public static void staticMethod() { } // ERROR: hiding is not overriding
}
That second case is useful in itself: it prevents the mistaken belief that a static method is being overridden.
Every modern style guide and IDE treats a missing @Override as a warning. Turn
that warning on; there is no reason to omit the annotation.
Key Takeaways
@Overridemakes the compiler verify that a method really overrides something.- It catches typos and signature mismatches that would otherwise compile silently.
- The mistyped
equals(Point)bug is the canonical example, and breaks collections.- It works for interface implementations too, catching renamed interface methods.
- It documents participation in polymorphism for readers.
🧪 Practice
- Write a class with
equals(Point)instead ofequals(Object)and show that aHashSetfails to find an equal element.- Add
@Overrideto that method and observe the compile error.- Interview: What does
@Overrideactually do at runtime? (Hint: check its retention policy.)
Access Modifier Widening Constraints
The rule that an override may widen but never narrow access follows directly from substitutability, and is worth examining on its own because it also governs how a hierarchy's public surface evolves.
public class Service {
protected void process() { }
}
public class PublicService extends Service {
@Override
public void process() { } // widening protected -> public: allowed
}
public class BrokenService extends Service {
// @Override
// private void process() { } // ERROR: attempting to assign weaker
} // access privileges; was protected
Why narrowing must be forbidden. Consider what would happen if it were allowed:
Service s = new BrokenService(); // legal: BrokenService IS-A Service
s.process(); // the compiler permits this: Service.process
// is protected and we are in a subclass...
// but the object's method is private!
The compiler checks access against the static type, while dispatch uses the runtime type. If narrowing were permitted, a call the compiler approved would hit a method the caller was not allowed to invoke. Forbidding narrowing keeps the two views consistent.
Declared: Service.process() protected
|
| a caller with a Service reference may call it
v
Runtime: BrokenService.process() private <- would be unreachable
The compiler's promise and the runtime's behaviour must not diverge.
The permitted direction:
private -> package-private -> protected -> public
An override may move RIGHT along this line, or stay put. Never left.
| Superclass method | Legal override modifiers |
|---|---|
public | public only |
protected | protected, public |
| package-private | package-private, protected, public |
private | Not inherited; any modifier, but it is a new method |
That last row is worth expanding. A private method is invisible to the subclass, so declaring the same signature creates an unrelated method rather than an override:
public class Parent {
private void secret() { System.out.println("Parent"); }
public void run() { secret(); } // binds to Parent.secret at compile time
}
public class Child extends Parent {
private void secret() { System.out.println("Child"); } // unrelated method
}
new Child().run(); // "Parent" -- NOT polymorphic, because private methods
// are resolved statically
This is a known interview question, and the reasoning is that private methods are implicitly final: since no subclass can see them, the compiler binds the call directly with no virtual dispatch.
Practical implications for design. Because access can only widen,
overridable methods should start at the narrowest level that makes sense. A
protected hook can later be widened to public if needed, while a public
one can never be pulled back.
public abstract class Template {
public final void execute() { // the public, fixed algorithm
validate();
performStep(); // the extension point
cleanup();
}
protected abstract void performStep(); // protected: for subclasses only
private void validate() { } // private: not an extension point
private void cleanup() { }
}
This is the template method pattern, revisited in chapter 9. Note how the access modifiers document exactly which methods a subclass may participate in.
Key Takeaways
- An override may widen access or keep it the same, never narrow it.
- Narrowing would let the compiler approve a call the runtime object forbids.
- Private methods are not inherited, so a same-named subclass method is unrelated and non-polymorphic.
- Private methods are effectively final and bound at compile time.
- Start extension points at the narrowest workable access level, since widening later is always possible.
🧪 Practice
- Attempt every narrowing combination in the table and record which produce errors.
- Reproduce the private-method example and explain why the parent version runs.
- Interview: Are private methods polymorphic in Java? Justify the answer. (Hint: can a subclass even see one?)
Preventing Overriding with final
final on a method forbids subclasses from overriding it. On a class, it
forbids extension entirely. Both are tools for protecting behaviour you cannot
allow a subclass to change.
public class Account {
private double balance;
public final double getBalance() { // cannot be overridden
return balance;
}
public final void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException();
balance += amount; // the rule is now unbreakable
}
}
public class Savings extends Account {
// @Override public double getBalance() { return 1_000_000; } // ERROR
}
Without final, a subclass could override getBalance() to lie, and every
piece of code holding an Account reference would be deceived.
Where final methods matter most:
| Reason | Example |
|---|---|
| Security-critical logic | Authentication, permission checks |
| Invariant enforcement | Methods that maintain a class's guarantees |
| Template method skeletons | The fixed algorithm, with overridable hooks |
| Constructor-called methods | Prevents the uninitialised-field hazard |
That last row connects to the initialisation-order problem from chapter 2:
public class Base {
public Base() {
init(); // safe ONLY because init is final
}
protected final void init() { ... } // no subclass can intercept this
}
final classes close the type entirely:
public final class Password {
private final char[] value;
public boolean matches(char[] attempt) { ... }
}
// public class FakePassword extends Password { } // ERROR
The JDK uses this for String, Integer, LocalDate, and the other value
types. If String were extensible, a malicious subclass could override
equals or hashCode and subvert every security check that compares strings.
The performance argument is largely obsolete. final once helped the JIT
inline methods. Modern JVMs perform class-hierarchy analysis and inline
non-final methods when only one implementation is loaded, deoptimising if a
second appears. Use final for design reasons, not for speed.
The design argument is the real one, and is stated forcefully in Effective Java: design and document a class for inheritance, or else prohibit it. An un-final class that was never considered as a superclass will eventually be subclassed by someone, and its internal method calls become an accidental, undocumented API.
// Fragile: addAll calls add internally, which a subclass may override
public class CountingList<E> extends ArrayList<E> {
private int addCount;
@Override public boolean add(E e) { addCount++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // super.addAll internally calls add()
} // -> addCount is incremented TWICE per element
}
This is the self-use problem: the superclass's internal calls are part of its behaviour, and subclasses that override those methods change it unpredictably. Composition, the subject of section 3.6, avoids the issue entirely.
| Modifier target | Effect |
|---|---|
final class | Cannot be extended |
final method | Cannot be overridden |
final field | Cannot be reassigned (chapter 1) |
final parameter or local | Cannot be reassigned |
Key Takeaways
finalon a method prevents overriding; on a class it prevents extension.- Use it for security-critical logic, invariant enforcement, and methods called from constructors.
- The JDK's value types are final so their behaviour cannot be subverted.
- The performance justification is obsolete; the design justification is not.
- Either design and document a class for inheritance, or make it
final.
🧪 Practice
- Write a class whose
getBalance()is final and try to override it.- Reproduce the
CountingListdouble-counting bug and explain why it happens.- Interview: Why is
Stringdeclared final? (Hint: think about whatequalsis used for in security checks.)
Overriding versus Overloading versus Hiding
Three mechanisms let two methods share a name. They behave completely differently, and distinguishing them is a standard interview topic because the consequences are easy to get wrong.
Overriding replaces an inherited instance method. The version that runs is decided at runtime by the object's actual class.
class Parent { void show() { System.out.println("Parent.show"); } }
class Child extends Parent {
@Override void show() { System.out.println("Child.show"); }
}
Parent p = new Child();
p.show(); // "Child.show" -- runtime type decides
Overloading declares a different method with the same name but a different parameter list. The version that runs is decided at compile time by the static types of the arguments.
class Printer {
void print(int x) { System.out.println("int"); }
void print(String s) { System.out.println("String"); }
void print(Object o) { System.out.println("Object"); }
}
Printer p = new Printer();
Object value = "text"; // static type Object, runtime type String
p.print(value); // "Object" -- compile-time decision, not runtime
That example is the crux of the distinction: overloading never looks at the runtime type.
Hiding occurs with static methods and with fields. The version used is decided at compile time by the reference type.
class Parent {
static void greet() { System.out.println("Parent.greet"); }
String name = "parent";
}
class Child extends Parent {
static void greet() { System.out.println("Child.greet"); } // HIDES
String name = "child"; // HIDES
}
Parent p = new Child();
p.greet(); // "Parent.greet" -- static type decides
System.out.println(p.name);// "parent" -- static type decides
Child c = new Child();
c.greet(); // "Child.greet"
System.out.println(c.name);// "child"
Parent p = new Child();
p.show() -> Child.show OVERRIDING: runtime type
p.greet() -> Parent.greet HIDING: static type
p.name -> "parent" HIDING: static type
p.print(o) -> chosen by the compiler from the declared argument type
| Aspect | Overriding | Overloading | Hiding |
|---|---|---|---|
| Applies to | Instance methods | Any methods | Static methods, fields |
| Location | Subclass | Same or subclass | Subclass |
| Signature | Must match | Must differ | Must match |
| Resolved at | Runtime | Compile time | Compile time |
| Based on | Object's actual class | Argument static types | Reference's static type |
| Also called | Dynamic polymorphism | Static polymorphism | Shadowing |
@Override valid | Yes | No | No |
A combined example that appears in interviews in various forms:
class A {
static String s() { return "A.static"; }
String i() { return "A.instance"; }
String field = "A.field";
}
class B extends A {
static String s() { return "B.static"; }
@Override String i() { return "B.instance"; }
String field = "B.field";
}
A ref = new B();
System.out.println(ref.s()); // "A.static" -- hidden, static type
System.out.println(ref.i()); // "B.instance" -- overridden, runtime type
System.out.println(ref.field); // "A.field" -- hidden, static type
Only the instance method is polymorphic. Everything else follows the declared
type of ref.
Practical guidance. Never hide a static method or a field deliberately: the result is code whose behaviour depends on the declared type of a variable, which no reader expects. If you need polymorphic behaviour, use instance methods. If two methods do genuinely different things, give them different names rather than overloading.
Key Takeaways
- Overriding is resolved at runtime by the object's class; overloading and hiding are resolved at compile time.
- Overloading selects on the static types of the arguments, never the runtime types.
- Static methods and fields are hidden, not overridden, and follow the reference's declared type.
- Only instance methods participate in polymorphism.
- Avoid hiding entirely; it makes behaviour depend on a variable's declared type.
🧪 Practice
- Build the combined
A/Bexample and predict all three outputs before running it.- Write an overloaded method taking
ObjectandString, call it with anObject-typed variable holding a string, and explain the result.- Interview:
Parent p = new Child();— which of an overridden method, a hidden static method, and a hidden field use the child's version? (Hint: only one is dispatched dynamically.)
<a id="33-polymorphism"></a>
3.3 Polymorphism
Polymorphism, from the Greek for "many forms", lets one reference type stand for many concrete types, with the actual behaviour selected at runtime. It is the mechanism that makes object-oriented code extensible.
Subtype Polymorphism and Upcasting
A reference of a supertype may point at an object of any subtype. This is upcasting, and it is implicit, always safe, and the foundation of everything else in this section.
public class Animal { public void speak() { System.out.println("..."); } }
public class Dog extends Animal { @Override public void speak() { System.out.println("Woof"); } }
public class Cat extends Animal { @Override public void speak() { System.out.println("Meow"); } }
Animal a1 = new Dog(); // upcast: implicit, no syntax required
Animal a2 = new Cat();
Animal a3 = (Animal) new Dog(); // explicit cast: legal but redundant
a1.speak(); // "Woof"
a2.speak(); // "Meow"
The reference type determines WHAT YOU MAY CALL.
The object type determines WHICH CODE RUNS.
Animal a = new Dog();
|_____| |_______|
| |
| +--> Dog.speak() executes
+-----------------> only Animal's members are visible
The visibility restriction is the price of the abstraction:
public class Dog extends Animal {
public void fetch() { System.out.println("Fetching"); }
}
Animal a = new Dog();
a.speak(); // OK: declared in Animal
// a.fetch(); // ERROR: Animal has no fetch(), even though the object is a Dog
The compiler only knows the declared type. To reach fetch, you must downcast,
which is the next topic.
Why this is useful becomes obvious when writing code that handles many types:
// Without polymorphism: every new type requires editing this method
public void makeSound(Object animal) {
if (animal instanceof Dog) { ((Dog) animal).bark(); }
else if (animal instanceof Cat) { ((Cat) animal).meow(); }
else if (animal instanceof Bird) { ((Bird) animal).chirp(); }
// ... and so on forever
}
// With polymorphism: this method never changes again
public void makeSound(Animal animal) {
animal.speak();
}
Adding a Horse class requires writing Horse and nothing else. The calling
code is closed to modification but open to extension, which is the Open-Closed
Principle from chapter 9.
Polymorphic collections are the most common practical use:
List<Animal> zoo = List.of(new Dog(), new Cat(), new Dog());
for (Animal animal : zoo) {
animal.speak(); // each one behaves according to its actual class
}
// Woof
// Meow
// Woof
// Method parameters and return types work the same way
public void feedAll(List<? extends Animal> animals) { ... }
public Animal createRandom() { return Math.random() < 0.5 ? new Dog() : new Cat(); }
Four kinds of polymorphism appear in Java, and it helps to know the vocabulary:
| Kind | Java mechanism | Resolved |
|---|---|---|
| Subtype | Overriding through a supertype reference | Runtime |
| Parametric | Generics (List<T>) | Compile time |
| Ad hoc | Method overloading | Compile time |
| Coercion | Implicit type conversion (int to long) | Compile time |
When the word "polymorphism" is used unqualified in Java discussions, it almost always means the first one.
Key Takeaways
- Upcasting a subclass reference to a supertype is implicit and always safe.
- The reference type controls which members are visible; the object's class controls which override runs.
- Polymorphic code is extended by adding classes, not by editing existing methods.
- Collections of a supertype hold any mixture of subtypes.
- Java has four kinds of polymorphism; only subtype polymorphism is resolved at runtime.
🧪 Practice
- Build an
Animalhierarchy with three subclasses, put them in one list, and call the same method on each.- Try to call a subclass-only method through a superclass reference and read the error.
- Interview: What is the difference between the reference type and the object type, and what does each control? (Hint: one is checked by the compiler, the other by the JVM.)
Dynamic Method Dispatch
Dynamic dispatch is the runtime mechanism that makes overriding work: the JVM selects the method implementation based on the object's actual class, not the reference's declared type.
Animal a = new Dog();
a.speak(); // the JVM looks at the OBJECT, finds Dog.speak, runs it
How it works. Each loaded class has a method table (conceptually a vtable) mapping method signatures to implementations. A subclass's table starts as a copy of its superclass's, with overridden entries replaced:
Animal method table Dog method table (inherits, then overrides)
+---------------------+ +---------------------+
| speak -> Animal.speak| | speak -> Dog.speak | <- replaced
| eat -> Animal.eat | | eat -> Animal.eat | <- inherited
| toString -> Object... | | toString -> Object... |
+---------------------+ | fetch -> Dog.fetch | <- added
+---------------------+
Every object header points at its class, so the JVM can find the right table.
a.speak() -> read a's class (Dog) -> look up "speak" -> Dog.speak
The bytecode instruction is invokevirtual, which performs exactly this lookup.
Compare it with the others:
| Instruction | Used for | Dispatch |
|---|---|---|
invokevirtual | Normal instance methods | Dynamic |
invokeinterface | Methods called through an interface | Dynamic |
invokestatic | Static methods | Static |
invokespecial | Constructors, private, super. calls | Static |
invokedynamic | Lambdas, string concatenation | Bootstrapped |
invokespecial explains why private methods and super. calls are not
polymorphic: they are bound at compile time.
Only instance methods are dispatched dynamically. Fields and static methods are not, which is the hiding behaviour from the previous section:
class Parent {
String label = "parent";
static void stat() { System.out.println("Parent.stat"); }
void inst() { System.out.println("Parent.inst"); }
}
class Child extends Parent {
String label = "child";
static void stat() { System.out.println("Child.stat"); }
@Override void inst() { System.out.println("Child.inst"); }
}
Parent p = new Child();
p.inst(); // "Child.inst" <- dynamic
p.stat(); // "Parent.stat" <- static
System.out.println(p.label); // "parent" <- static
A practical consequence: dispatch inside the superclass. When a superclass method calls another overridable method, the call is also dispatched dynamically, which means the subclass's version runs:
public class Report {
public void generate() {
System.out.println(header()); // dispatched dynamically
System.out.println("body");
}
protected String header() { return "=== Report ==="; }
}
public class SalesReport extends Report {
@Override protected String header() { return "=== Sales ==="; }
}
new SalesReport().generate();
// === Sales === <- the subclass version, called from the superclass
// body
This is the engine of the template method pattern, and also the reason calling an overridable method from a constructor is hazardous, as established in chapter 2.
Performance. Dynamic dispatch is not free in principle, but modern JVMs optimise it aggressively. When only one implementation of a method is loaded, the JIT compiles a direct call ("monomorphic inline cache") and deoptimises if a second class appears. Call sites seeing two types stay fast; sites seeing many ("megamorphic") fall back to a table lookup. In practice this is rarely a bottleneck, and designing around it prematurely is a mistake.
Key Takeaways
- The JVM selects an override by looking at the object's class, using its method table.
invokevirtualandinvokeinterfacedispatch dynamically;invokestaticandinvokespecialdo not.- Fields, static methods, private methods, and
super.calls are bound at compile time.- A superclass method calling an overridable method reaches the subclass version.
- The JIT optimises call sites with few implementations to direct calls.
🧪 Practice
- Write a superclass method that calls an overridable helper, then override the helper and observe which runs.
- Use
javap -con a class to findinvokevirtualandinvokestaticinstructions.- Interview: A superclass constructor calls an overridden method. What does the subclass see, and why? (Hint: combine dynamic dispatch with initialisation order.)
Static versus Dynamic Binding
Binding is the association of a method call with a specific implementation. Java does some of this at compile time and some at runtime, and knowing which applies where explains nearly every polymorphism puzzle.
Static binding (also early binding) happens at compile time, using the declared types. It applies to:
staticmethodsprivatemethodsfinalmethods- Constructors
- Field access
- Overload selection
Dynamic binding (also late binding) happens at runtime, using the object's actual class. It applies to:
- Non-final, non-private, non-static instance methods
class Base {
static void s() { System.out.println("Base.s"); }
private void p() { System.out.println("Base.p"); }
final void f() { System.out.println("Base.f"); }
void v() { System.out.println("Base.v"); }
void callAll() { s(); p(); f(); v(); }
}
class Derived extends Base {
static void s() { System.out.println("Derived.s"); }
private void p() { System.out.println("Derived.p"); }
@Override void v() { System.out.println("Derived.v"); }
}
Base b = new Derived();
b.callAll();
// Base.s static -> statically bound
// Base.p private -> statically bound
// Base.f final -> statically bound
// Derived.v virtual -> dynamically bound
| Bound statically | Bound dynamically |
|---|---|
static methods | Instance methods |
private methods | |
final methods | |
| Constructors | |
| Fields | |
| Which overload is chosen | Which override runs |
The overload-versus-override interaction is the subtlest case, because both happen to one call. The compiler picks the signature statically; the JVM picks the implementation dynamically:
class Parent {
void handle(Object o) { System.out.println("Parent.handle(Object)"); }
void handle(String s) { System.out.println("Parent.handle(String)"); }
}
class Child extends Parent {
@Override void handle(Object o) { System.out.println("Child.handle(Object)"); }
}
Parent p = new Child();
Object value = "text";
p.handle(value); // "Child.handle(Object)"
Two decisions happened. The compiler saw value declared as Object and chose
handle(Object). The JVM then saw the object was a Child and ran the
override. Change Object value to String value and the output becomes
Parent.handle(String), because a different signature was selected and Child
does not override it.
p.handle(value)
COMPILE TIME RUNTIME
which signature? which implementation of that signature?
-> handle(Object) -> Child.handle(Object)
(from value's
declared type) (from the object's actual class)
A second classic puzzle, mixing fields and methods:
class A {
int x = 1;
int getX() { return x; }
}
class B extends A {
int x = 2;
@Override int getX() { return x; } // 'x' here means B.x
}
A a = new B();
System.out.println(a.x); // 1 -- field access: static binding
System.out.println(a.getX()); // 2 -- method call: dynamic binding
The same conceptual value gives two different answers depending on how it is reached. This is precisely why field hiding should be avoided.
Practical guidance. Rely on dynamic binding for behaviour that varies by type, and never design around static binding of fields or static methods — readers will not expect it. If you want a value to be polymorphic, expose it through a method, not a field.
Key Takeaways
- Static binding uses declared types at compile time; dynamic binding uses the object's class at runtime.
- Fields, static, private, and final methods are bound statically; ordinary instance methods are bound dynamically.
- Overload selection is static even when the chosen method is then dispatched dynamically.
- Accessing a hidden field and calling an overridden getter can return different values for the same concept.
- Expose polymorphic values through methods, never through fields.
🧪 Practice
- Reproduce the
A/Bfield-versus-getter example and explain both outputs.- Write the overload-plus-override case and change the variable's declared type to see the output change.
- Interview: Which of these are polymorphic: fields, static methods, private methods, final methods, instance methods? (Hint: which ones can a subclass actually replace?)
Downcasting and the instanceof Operator
Upcasting loses access to subtype-specific members. Downcasting converts a supertype reference back to a subtype so those members become visible again. It is checked at runtime and fails loudly when the object is not of the expected type.
Animal animal = new Dog(); // upcast: implicit and safe
// animal.fetch(); // ERROR: Animal has no fetch()
Dog dog = (Dog) animal; // downcast: explicit, checked at runtime
dog.fetch(); // now visible
UPCAST (widening) DOWNCAST (narrowing)
Dog ------> Animal Animal ------> Dog
implicit, always safe explicit, may throw ClassCastException
Every Dog IS an Animal Not every Animal IS a Dog
The failure mode:
Animal animal = new Cat();
Dog dog = (Dog) animal; // ClassCastException: class Cat cannot be cast to Dog
This compiles, because Cat and Dog are both Animal subtypes and the
compiler cannot rule it out. It fails at runtime.
The compiler does reject impossible casts between unrelated types:
String s = "text";
// Integer i = (Integer) s; // ERROR: incompatible types, no relationship
instanceof guards the cast:
public void handle(Animal animal) {
if (animal instanceof Dog) { // test before casting
Dog dog = (Dog) animal;
dog.fetch();
}
}
instanceof returns false for null, so a null check is unnecessary:
Animal animal = null;
if (animal instanceof Dog) { } // false, no exception
Pattern matching (Java 16) merges the test and the cast, eliminating the repetition. It is covered fully in the next topic.
if (animal instanceof Dog dog) { // test, cast, and bind in one step
dog.fetch();
}
When downcasting signals a design problem. A chain of instanceof tests
usually means polymorphism was available and was not used:
// Code smell: the method must change every time a subtype is added
public double area(Shape shape) {
if (shape instanceof Circle) {
return Math.PI * Math.pow(((Circle) shape).getRadius(), 2);
} else if (shape instanceof Square) {
return Math.pow(((Square) shape).getSide(), 2);
}
throw new IllegalArgumentException("Unknown shape");
}
// Better: let each shape answer for itself
public abstract class Shape {
public abstract double area();
}
public double area(Shape shape) { return shape.area(); }
When downcasting is legitimate:
| Situation | Why it is acceptable |
|---|---|
Implementing equals(Object) | The signature is fixed by Object |
| Reading from a pre-generics or raw collection | No type information available |
| Deserialization and reflection | Types are only known at runtime |
| A sealed hierarchy with exhaustive handling | The set of subtypes is closed and known |
| Framework code dispatching on runtime type | The framework cannot modify user classes |
@Override
public boolean equals(Object o) { // signature imposed by Object
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point other = (Point) o; // a necessary downcast
return x == other.x && y == other.y;
}
getClass() versus instanceof. They answer different questions:
Animal a = new Dog();
System.out.println(a instanceof Animal); // true -- "is a kind of"
System.out.println(a.getClass() == Animal.class); // false -- "is exactly"
System.out.println(a.getClass() == Dog.class); // true
instanceof includes subtypes; getClass() comparison is exact. The choice
matters when implementing equals across a hierarchy, a subtlety revisited in
section 3.5.
Key Takeaways
- Downcasting is explicit and checked at runtime, throwing
ClassCastExceptionon mismatch.instanceofreturnsfalsefornull, so no separate null check is needed.- A chain of
instanceoftests usually indicates missing polymorphism.- Legitimate uses include
equals, deserialization, raw collections, and sealed hierarchies.instanceofaccepts subtypes; comparinggetClass()demands an exact match.
🧪 Practice
- Write code that triggers a
ClassCastException, then guard it withinstanceof.- Refactor an
instanceofchain computing areas into a polymorphicarea()method.- Interview: When is downcasting unavoidable in well-designed code? (Hint: look at the signature
Object.equalsforces on you.)
Pattern Matching for instanceof
The test-then-cast pattern was one of Java's most repetitive idioms: name the type twice and declare a variable that the compiler already knew the type of. Pattern matching, standard since Java 16, removes the redundancy.
// Before Java 16
if (object instanceof String) {
String s = (String) object; // type named twice, cast is redundant
System.out.println(s.length());
}
// Java 16 and later
if (object instanceof String s) { // test, cast, and bind in one construct
System.out.println(s.length());
}
The variable s is called a pattern variable. It is in scope exactly where
the compiler can prove the test succeeded, which is smarter than it first
appears.
Flow scoping. The pattern variable's scope follows the logic of the condition, not the braces:
// Available in the then-branch
if (o instanceof String s) {
System.out.println(s.length());
}
// s is not in scope here
// Available after a negated early return
if (!(o instanceof String s)) {
return;
}
System.out.println(s.length()); // in scope: we only reach here if the test passed
// Available in the right-hand side of &&
if (o instanceof String s && s.length() > 5) { // s usable immediately
System.out.println(s);
}
// NOT available after ||, because the test may not have run
// if (o instanceof String s || s.isEmpty()) { } // ERROR
The && case is particularly useful, and it is the reason the operator's
short-circuit semantics matter here.
Combining with equals, which becomes markedly cleaner:
// Before
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
// After
@Override
public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
The second version is a single expression and handles null automatically,
since instanceof is false for null.
Pattern matching for switch (standard in Java 21) extends the idea to
multi-branch dispatch:
static String describe(Object o) {
return switch (o) {
case Integer i when i > 100 -> "large number: " + i; // guarded pattern
case Integer i -> "number: " + i;
case String s -> "text of length " + s.length();
case int[] arr -> "int array of " + arr.length;
case null -> "nothing"; // explicit null case
default -> "something else";
};
}
Note three things: patterns bind variables per branch, when adds a guard
condition, and case null is now expressible — a plain switch throws
NullPointerException on a null selector.
Record patterns (Java 21) destructure records directly:
record Point(int x, int y) { }
record Line(Point start, Point end) { }
static String describe(Object o) {
return switch (o) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
"line from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")";
case Point(var x, var y) -> "point at (" + x + "," + y + ")";
default -> "unknown";
};
}
Exhaustiveness with sealed types. When the hierarchy is sealed (covered in
section 3.6), the compiler knows every possible case and no default is needed:
sealed interface Shape permits Circle, Square { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square q -> q.side() * q.side();
// no default needed; adding a permitted subtype breaks compilation here
};
}
This combination — sealed types plus pattern switches — gives Java something close to algebraic data types, and it makes "handle every case" a compile-time guarantee rather than a hope.
| Version | Feature |
|---|---|
| Java 16 | Pattern matching for instanceof |
| Java 17 | Sealed classes |
| Java 21 | Pattern matching for switch, record patterns |
Key Takeaways
instanceof Type variabletests, casts, and binds in one step.- Flow scoping makes the pattern variable available exactly where the test is known to have passed, including after
&&and after a negated early return.equalsimplementations collapse to a single expression.- Pattern switches support guards with
when, an explicitcase null, and record destructuring.- Sealed hierarchies make pattern switches exhaustive without a
default.
🧪 Practice
- Rewrite three test-then-cast blocks using pattern matching.
- Write an
equalsmethod as a single expression usinginstanceofwith a pattern variable.- Interview: Why can a pattern variable be used after
&&but not after||? (Hint: which operand is guaranteed to have been evaluated?)
ClassCastException Avoidance
ClassCastException is an unchecked exception thrown when a cast does not match
the object's actual type. It always indicates a programming error rather than a
recoverable condition, so the goal is to make the cast unnecessary or provably
safe.
Object o = "a string";
Integer i = (Integer) o; // ClassCastException: class java.lang.String
// cannot be cast to class java.lang.Integer
Strategy 1: use polymorphism instead. The best cast is the one you never write:
// Requires a cast
public void process(Object item) {
if (item instanceof Order) { ((Order) item).ship(); }
}
// Requires no cast
public void process(Shippable item) {
item.ship();
}
Strategy 2: use generics. Raw types were the main source of cast exceptions before Java 5, and still are in legacy code:
// Raw type: the compiler cannot help
List list = new ArrayList();
list.add("text");
list.add(42);
for (Object o : list) {
String s = (String) o; // ClassCastException on the second element
}
// Generic: the error is caught at compile time
List<String> list = new ArrayList<>();
list.add("text");
// list.add(42); // ERROR: incompatible types
for (String s : list) { } // no cast needed at all
Strategy 3: guard with pattern matching. When a cast is genuinely needed, never write one unguarded:
// Unsafe
Dog dog = (Dog) animal;
// Safe, and the cast disappears
if (animal instanceof Dog dog) {
dog.fetch();
}
// Safe with a fallback
String result = (animal instanceof Dog dog) ? dog.fetch() : "not a dog";
Strategy 4: Class.cast and isInstance for reflective code, where the
type is not known statically:
public <T> T convert(Object value, Class<T> type) {
if (!type.isInstance(value)) {
throw new IllegalArgumentException(
"Expected " + type.getSimpleName() + " but got "
+ (value == null ? "null" : value.getClass().getSimpleName()));
}
return type.cast(value); // checked cast with a clear failure message
}
The generics pitfall: type erasure. Generic type arguments are erased at runtime (chapter 6), so an unchecked cast can defer the failure to a surprising place:
public static <T> T unsafeCast(Object o) {
return (T) o; // unchecked warning; no runtime check happens
}
String s = unsafeCast(42); // ClassCastException thrown HERE, at the
// assignment, not inside the method
The exception surfaces where the value is first used as its declared type, which can be far from the actual mistake. Treat unchecked-cast warnings as real.
Diagnosing one when it happens:
Exception in thread "main" java.lang.ClassCastException:
class com.example.Cat cannot be cast to class com.example.Dog
(com.example.Cat and com.example.Dog are in unnamed module of loader 'app')
at com.example.Shelter.walk(Shelter.java:23)
The message names both types and the line. A confusing variant reports the same class name on both sides, which means two different class loaders loaded it — a common situation in application servers and plugin systems.
| Technique | Prevents the cast, or makes it safe |
|---|---|
| Polymorphic method | Removes it |
| Generics | Removes it |
instanceof pattern | Makes it safe |
Class.isInstance / cast | Makes it safe with a clear message |
| Sealed types with exhaustive switch | Removes it |
Key Takeaways
ClassCastExceptionis unchecked and always signals a programming error.- Prefer polymorphism and generics so that no cast is written at all.
- Guard any unavoidable cast with an
instanceofpattern.- Use
Class.isInstanceandClass.castin reflective code for clear failures.- Erasure means an unchecked cast fails at the point of use, not at the point of the mistake.
🧪 Practice
- Produce a
ClassCastExceptionusing a rawList, then fix it with generics.- Write an unchecked generic cast and observe where the exception actually surfaces.
- Interview: You see a
ClassCastExceptionsayingFoo cannot be cast to Foo. What is going on? (Hint: class identity involves more than the name.)
Polymorphic Collections of Objects
Holding many subtypes in one collection and treating them uniformly is where polymorphism pays off most visibly in everyday code.
public abstract class Shape {
public abstract double area();
public abstract String name();
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override public double area() { return Math.PI * radius * radius; }
@Override public String name() { return "Circle"; }
}
public class Rectangle extends Shape {
private final double w, h;
public Rectangle(double w, double h) { this.w = w; this.h = h; }
@Override public double area() { return w * h; }
@Override public String name() { return "Rectangle"; }
}
List<Shape> shapes = List.of(
new Circle(2),
new Rectangle(3, 4),
new Circle(1));
double total = 0;
for (Shape s : shapes) {
System.out.printf("%-10s %8.2f%n", s.name(), s.area());
total += s.area(); // each call dispatches to the right class
}
System.out.printf("%-10s %8.2f%n", "TOTAL", total);
Circle 12.57
Rectangle 12.00
Circle 3.14
TOTAL 27.71
The loop contains no type tests and never changes when a new shape is added.
With streams, the same idea reads more declaratively:
double total = shapes.stream().mapToDouble(Shape::area).sum();
Map<String, List<Shape>> byType = shapes.stream()
.collect(Collectors.groupingBy(Shape::name));
List<Shape> large = shapes.stream()
.filter(s -> s.area() > 10)
.sorted(Comparator.comparingDouble(Shape::area).reversed())
.toList();
Sorting a polymorphic collection works through Comparable or Comparator,
both of which are themselves polymorphic:
List<Shape> sortable = new ArrayList<>(shapes);
sortable.sort(Comparator.comparingDouble(Shape::area));
The variance pitfall. List<Dog> is not a subtype of List<Animal>, even
though Dog is a subtype of Animal. Generics are invariant:
List<Dog> dogs = new ArrayList<>();
// List<Animal> animals = dogs; // ERROR: incompatible types
If it were allowed, this would follow:
List<Animal> animals = dogs; // hypothetically
animals.add(new Cat()); // legal for a List<Animal>
Dog d = dogs.get(0); // ClassCastException: it is a Cat
The remedy is a bounded wildcard, which permits reading as Animal while
forbidding unsafe writes:
public double totalArea(List<? extends Shape> shapes) { // accepts List<Circle> too
double sum = 0;
for (Shape s : shapes) sum += s.area();
// shapes.add(new Circle(1)); // ERROR: cannot add through '? extends'
return sum;
}
totalArea(List.of(new Circle(1))); // a List<Circle> is now acceptable
Chapter 6 covers wildcards and the PECS rule in full; the essential point here is that arrays and generics differ:
Object[] array = new Dog[3]; // arrays ARE covariant -- and this is unsafe
array[0] = new Cat(); // compiles, throws ArrayStoreException at runtime
Arrays were made covariant before generics existed, and the runtime check is the price. Generics chose compile-time safety instead.
| Container | Covariant? | Error detected |
|---|---|---|
Dog[] | Yes | Runtime (ArrayStoreException) |
List<Dog> | No | Compile time |
List<? extends Dog> | Read-only view | Compile time |
Design guidance. Declare collections by the most general type that callers
need, and take ? extends T in parameters that only read. That maximises what
the method accepts without giving up safety.
Key Takeaways
- A collection of a supertype holds any mixture of subtypes and dispatches each call correctly.
- Polymorphic loops need no type tests and survive the addition of new subtypes.
- Generics are invariant:
List<Dog>is not aList<Animal>.- Use
List<? extends Shape>for read-only parameters to accept subtype collections.- Arrays are covariant and therefore fail at runtime with
ArrayStoreException.
🧪 Practice
- Build a
List<Shape>with three subtypes and compute the total area with both a loop and a stream.- Try assigning a
List<Circle>to aList<Shape>variable, then fix the method signature with a wildcard.- Interview: Why is
List<Dog>not aList<Animal>whenDog[]is anAnimal[]? (Hint: what could you put into each one?)
<a id="34-abstract-classes"></a>
3.4 Abstract Classes
An abstract class defines a partial implementation: shared state and behaviour plus a declaration of what subclasses must supply.
Declaring Abstract Classes and Methods
Some classes exist only to be extended. A Shape has an area, but there is no
such thing as a generic shape you could instantiate and ask. abstract makes
that explicit and lets the compiler enforce it.
public abstract class Shape { // cannot be instantiated
protected final String name; // shared state
protected Shape(String name) { // constructors are still needed
this.name = name;
}
public abstract double area(); // no body: subclasses MUST provide one
public String describe() { // concrete: shared by all subclasses
return name + " with area " + String.format("%.2f", area());
}
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) {
super("Circle"); // the abstract class's constructor
this.radius = radius;
}
@Override
public double area() { return Math.PI * radius * radius; } // required
}
// Shape s = new Shape("generic"); // ERROR: Shape is abstract; cannot be instantiated
Shape s = new Circle(2); // fine: instantiate a concrete subclass
System.out.println(s.describe()); // "Circle with area 12.57"
The rules:
| Rule | Consequence |
|---|---|
| An abstract class cannot be instantiated | Only subclasses produce objects |
| It may have zero abstract methods | Still uninstantiable |
| A class with any abstract method must be abstract | The compiler enforces this |
| An abstract method has no body, only a semicolon | abstract double area(); |
| A concrete subclass must implement every abstract method | Or be declared abstract itself |
| Abstract classes have constructors | Called via super(...) |
abstract and final are contradictory | Compile error |
abstract and private are contradictory | A private method cannot be overridden |
abstract and static are contradictory | Static methods are not dispatched |
public abstract class Example {
// public abstract final void a(); // ERROR: illegal combination
// private abstract void b(); // ERROR: cannot be overridden
// public abstract static void c(); // ERROR: not dispatchable
public abstract void d(); // correct
}
An intermediate abstract subclass may leave some methods unimplemented:
public abstract class Shape {
public abstract double area();
public abstract double perimeter();
}
public abstract class Polygon extends Shape {
protected final int sides;
protected Polygon(int sides) { this.sides = sides; }
@Override public double perimeter() { return sides * sideLength(); }
protected abstract double sideLength(); // a new abstract method
// area() is still abstract: Polygon stays abstract
}
public class Square extends Polygon {
private final double side;
public Square(double side) { super(4); this.side = side; }
@Override protected double sideLength() { return side; }
@Override public double area() { return side * side; } // now complete
}
Shape (abstract)
area() abstract
perimeter() abstract
|
Polygon (abstract)
perimeter() implemented
sideLength() abstract, newly added
area() still abstract
|
Square (concrete)
everything implemented -> instantiable
Abstract classes can still be referenced and are used constantly as types:
List<Shape> shapes = List.of(new Circle(1), new Square(2)); // Shape as a type
Shape s = shapes.get(0); // Shape as a variable
public double total(List<Shape> shapes) { ... } // Shape as a parameter
You simply cannot write new Shape(...). Anonymous subclasses are the one
syntactic exception, and they are creating a concrete subclass, not the abstract
class:
Shape s = new Shape("anon") { // an anonymous CONCRETE subclass
@Override public double area() { return 0; }
};
Key Takeaways
abstracton a class prevents instantiation; on a method it demands an implementation from subclasses.- A class containing an abstract method must itself be abstract.
- Abstract classes have constructors, fields, and concrete methods.
- An intermediate abstract subclass may implement some methods and leave others.
abstractcannot combine withfinal,private, orstatic.
🧪 Practice
- Write an abstract
Employeewith an abstractcalculatePay()and two concrete subclasses.- Try to instantiate the abstract class and read the error, then do it with an anonymous subclass.
- Interview: Can an abstract class have a constructor, and if so, who calls it? (Hint: what runs when a subclass is instantiated?)
Partial Implementation Strategies
The real value of an abstract class is that it can implement the parts it knows and delegate only the parts it cannot know. Deciding where to draw that line is the design work.
Strategy 1: implement in terms of abstract methods. The abstract class provides useful behaviour built on top of what subclasses supply:
public abstract class Account {
protected double balance;
public abstract double interestRate(); // subclass supplies the rate
// Everything below is written ONCE, using that one unknown
public void applyMonthlyInterest() {
balance += balance * interestRate() / 12;
}
public double projectedAnnualInterest() {
return balance * interestRate();
}
public boolean beatsInflation(double inflation) {
return interestRate() > inflation;
}
}
One abstract method yields three concrete ones. A SavingsAccount supplies a
rate and gets all three behaviours free.
Strategy 2: provide a default with an override point. A concrete method that subclasses may replace, rather than must:
public abstract class Report {
public abstract List<Row> rows(); // must supply
protected String header() { return "Report"; } // may override
protected String footer() { return "End"; } // may override
public final String render() { // fixed algorithm
StringBuilder sb = new StringBuilder(header()).append('\n');
rows().forEach(r -> sb.append(r).append('\n'));
return sb.append(footer()).toString();
}
}
This is the template method pattern: render() is final so the sequence
cannot change, while header and footer are hooks.
Strategy 3: the skeletal implementation. Pair an interface with an abstract class that implements most of it, so implementers can either extend the skeleton or implement the interface from scratch:
public interface Collection<E> {
int size();
boolean isEmpty();
boolean contains(Object o);
// ... many more
}
public abstract class AbstractCollection<E> implements Collection<E> {
public abstract int size(); // left to subclasses
public abstract Iterator<E> iterator();
@Override public boolean isEmpty() { return size() == 0; } // derived
@Override public boolean contains(Object o) { // derived
for (E e : this) {
if (Objects.equals(e, o)) return true;
}
return false;
}
}
The JDK uses this throughout: AbstractList, AbstractMap, AbstractSet.
Implementing a custom list means extending AbstractList and writing two
methods instead of twenty.
public class RangeList extends AbstractList<Integer> {
private final int from, to;
public RangeList(int from, int to) { this.from = from; this.to = to; }
@Override public Integer get(int index) { return from + index; } // 1
@Override public int size() { return to - from; } // 2
// contains, indexOf, iterator, subList, equals, hashCode, toString: inherited
}
Choosing what to leave abstract. A practical test: a method should be abstract when its implementation genuinely differs per subclass, and concrete when a sensible shared version exists.
| Make it abstract when | Make it concrete when |
|---|---|
| Every subclass needs a different answer | A reasonable default exists |
| There is no sensible default | The behaviour is derivable from other methods |
| Forgetting to implement it would be a bug | Most subclasses want the same thing |
A caution about protected fields. It is tempting for an abstract class to expose its state to subclasses directly:
public abstract class Account {
protected double balance; // every subclass can now corrupt it
}
public abstract class Account {
private double balance; // better
protected double getBalance() { return balance; }
protected void adjust(double delta) { // controlled mutation
if (balance + delta < 0) throw new IllegalStateException();
balance += delta;
}
}
The second version keeps the invariant enforceable. A protected field is a permanent commitment to every subclass, present and future.
Key Takeaways
- Implement concrete behaviour in terms of a small number of abstract methods.
- A
finaltemplate method with overridable hooks fixes the algorithm while allowing variation.- Skeletal implementations such as
AbstractListlet implementers write two methods instead of twenty.- Leave a method abstract only when no sensible default exists.
- Prefer protected accessors over protected fields so invariants stay enforceable.
🧪 Practice
- Write an abstract class with one abstract method and three concrete methods built on it.
- Implement a custom read-only list by extending
AbstractListand writing onlygetandsize.- Interview: What is a skeletal implementation, and what problem does it solve? (Hint: count the methods in
Listversus the ones you must actually write.)
Abstract Classes as Template Providers
The template method pattern deserves its own treatment because it is the single most common reason to reach for an abstract class, and it demonstrates the inversion of control that inheritance enables.
The idea: the superclass owns the algorithm, the subclass owns the steps. Control flows from the superclass down, not the other way around.
public abstract class DataImporter {
// The template method: fixed sequence, cannot be changed by subclasses
public final ImportResult importData(Path source) {
validateSource(source); // fixed
List<String> raw = readLines(source); // varies
List<Record> parsed = new ArrayList<>();
for (String line : raw) {
if (shouldSkip(line)) continue; // hook with a default
parsed.add(parseLine(line)); // varies
}
validate(parsed); // hook with a default
int written = persist(parsed); // varies
afterImport(written); // hook, does nothing by default
return new ImportResult(written);
}
// Required steps: each subclass must define these
protected abstract List<String> readLines(Path source);
protected abstract Record parseLine(String line);
protected abstract int persist(List<Record> records);
// Optional hooks: sensible defaults, may be overridden
protected boolean shouldSkip(String line) { return line.isBlank(); }
protected void validate(List<Record> records) { }
protected void afterImport(int count) { }
// Private: not an extension point
private void validateSource(Path source) {
if (!Files.exists(source)) throw new IllegalArgumentException("No such file");
}
}
public class CsvImporter extends DataImporter {
@Override protected List<String> readLines(Path source) { ... }
@Override protected Record parseLine(String line) {
String[] parts = line.split(",");
return new Record(parts[0], Integer.parseInt(parts[1]));
}
@Override protected int persist(List<Record> records) { ... }
@Override protected boolean shouldSkip(String line) {
return line.isBlank() || line.startsWith("#"); // extends the default
}
}
CONTROL FLOW: the superclass calls down into the subclass
caller ---> DataImporter.importData() <- the algorithm lives here
|
+--> validateSource() (private, fixed)
+--> readLines() --> CsvImporter.readLines()
+--> shouldSkip() --> CsvImporter.shouldSkip()
+--> parseLine() --> CsvImporter.parseLine()
+--> persist() --> CsvImporter.persist()
This is the Hollywood Principle: "don't call us, we'll call you."
Why final on the template method matters. Without it, a subclass could
override importData and skip validation entirely, defeating the point:
public final ImportResult importData(Path source) { ... } // sequence guaranteed
Three categories of method in a well-designed template class:
| Category | Access | Purpose |
|---|---|---|
| Template method | public final | The invariant algorithm |
| Abstract steps | protected abstract | Must be supplied by subclasses |
| Hooks | protected with a default | May be customised |
| Internal helpers | private | Not extension points |
The access modifiers document the design: a reader can tell at a glance what they must implement, what they may implement, and what is off limits.
Real examples in the JDK and common frameworks:
// java.io.InputStream: read(byte[], int, int) is built on the abstract read()
public abstract class InputStream {
public abstract int read() throws IOException;
public int read(byte[] b, int off, int len) throws IOException { ... }
}
// java.util.AbstractList, javax.servlet.http.HttpServlet.service(),
// Spring's JdbcTemplate.execute(), JUnit's test lifecycle
When a lambda is better. If there is exactly one varying step, an abstract class is heavy machinery. A functional parameter does the same job without a hierarchy:
// Template method with one hook
public abstract class Processor {
public final void run(List<String> items) {
items.forEach(this::handle);
}
protected abstract void handle(String item);
}
// Equivalent with composition and a lambda -- no subclass needed
public class Processor {
private final Consumer<String> handler;
public Processor(Consumer<String> handler) { this.handler = handler; }
public void run(List<String> items) { items.forEach(handler); }
}
new Processor(System.out::println).run(items);
The rule of thumb: several related varying steps that share state justify a template class; one varying step is better as a function.
Key Takeaways
- The template method pattern puts the algorithm in the superclass and the steps in subclasses.
- Mark the template method
finalso the sequence cannot be bypassed.- Use
protected abstractfor required steps andprotectedwith a default for optional hooks.- Access modifiers document which methods are extension points.
- A single varying step is usually better expressed as a functional parameter than a subclass.
🧪 Practice
- Write an abstract
Gameclass with a finalplay()template callinginitialise(),takeTurn(), andannounceWinner().- Add an optional hook with a default and override it in only one subclass.
- Interview: Why should a template method be
final? (Hint: what could a subclass otherwise skip?)
Abstract versus Concrete Design Choices
Deciding whether a class should be abstract, concrete, or an interface is a recurring design question. The answer follows from what the type is for.
Make it abstract when the type represents an incomplete concept that cannot sensibly exist on its own, and when subclasses share state or implementation:
public abstract class PaymentMethod {
protected final String accountId; // shared state
protected PaymentMethod(String accountId) { this.accountId = accountId; }
public abstract PaymentResult charge(long amountCents); // varies
public final String maskedAccount() { // shared implementation
return "****" + accountId.substring(accountId.length() - 4);
}
}
Make it concrete when instances of the type itself are meaningful, even if subclasses exist:
public class Employee { } // an employee is a real thing
public class Manager extends Employee { } // and so is a manager
Make it an interface when you are describing a capability rather than a kind of thing, or when implementers may already have a superclass:
public interface Auditable { AuditRecord toAuditRecord(); }
public interface Comparable<T> { int compareTo(T other); }
Chapter 4 covers interfaces fully; the comparison table below is the summary most often needed.
| Question | Abstract class | Interface |
|---|---|---|
| Can hold instance state | Yes | No (constants only) |
| Can define constructors | Yes | No |
| How many can a class have | One | Many |
| Can have non-public members | Yes | private methods since Java 9 |
| Default implementations | Concrete methods | Default methods since Java 8 |
| Describes | What something is | What something can do |
| Adding a method to it | Breaks nothing if concrete | Breaks implementers unless default |
A worked decision. Suppose you are modelling notifications sent by email, SMS, and push.
// They share state (recipient, retry count) and an algorithm (retry with backoff).
// The shared parts are substantial, so an abstract class fits.
public abstract class Notification {
protected final String recipient;
private int attempts;
protected Notification(String recipient) { this.recipient = recipient; }
public final boolean send() { // shared retry algorithm
while (attempts < 3) {
attempts++;
if (doSend()) return true;
sleepBackoff(attempts);
}
return false;
}
protected abstract boolean doSend(); // the only difference
private void sleepBackoff(int attempt) { ... }
}
Now suppose some notifications must also be archived, and archiving applies to unrelated types too. That is a capability, not a kind, so it becomes an interface:
public interface Archivable { void archive(); }
public class EmailNotification extends Notification implements Archivable { ... }
This combination — an abstract class for the shared implementation and interfaces for orthogonal capabilities — is the standard modern shape.
A caution against premature abstraction. Creating an abstract base class before you have two real implementations usually produces the wrong abstraction:
// Written with one implementation in mind, before the second existed
public abstract class AbstractRepository {
protected Connection connection; // assumes SQL
protected abstract String tableName(); // assumes tables
}
// The second implementation turns out to be a REST API. Nothing fits.
The common guidance is to write two or three concrete implementations first, observe what is genuinely shared, and extract the abstraction afterwards.
Key Takeaways
- Abstract classes suit incomplete concepts with shared state and implementation.
- Interfaces suit capabilities and allow a class to have many of them.
- Use both together: an abstract base for the shared algorithm, interfaces for orthogonal capabilities.
- Adding a method to an abstract class is safe; adding one to an interface breaks implementers unless it is
default.- Extract an abstraction after two or three concrete cases exist, not before.
🧪 Practice
- Decide, with justification, whether each should be an abstract class, concrete class, or interface:
Vehicle,Serializable,Order,Comparable,DatabaseConnection.- Design a notification hierarchy using an abstract class plus one capability interface.
- Interview: When would you choose an abstract class over an interface? (Hint: what can one hold that the other cannot?)
<a id="35-the-object-superclass"></a>
3.5 The Object Superclass
Every class inherits from Object, so every object carries a small universal
API. Overriding those methods correctly is a routine but error-prone task.
Universal Methods Inherited from Object
java.lang.Object is the root of the class hierarchy. Its methods are available
on every reference, which is what makes generic containers, collections, and
reflection possible.
public class Plain { } // implicitly extends Object
Plain p = new Plain();
p.toString(); p.equals(other); p.hashCode(); p.getClass();
The eleven methods, grouped by purpose:
| Method | Purpose | Commonly overridden |
|---|---|---|
toString() | Human-readable representation | Yes |
equals(Object) | Logical equality | Yes |
hashCode() | Hash bucket value | Yes, with equals |
clone() | Field-by-field copy | Rarely |
finalize() | Pre-collection callback | Never; deprecated |
getClass() | Runtime class object | Cannot: final |
wait(), wait(long) | Release the monitor and pause | Cannot: final |
notify(), notifyAll() | Wake waiting threads | Cannot: final |
The threading methods (wait, notify, notifyAll) exist on Object because
every object can act as a monitor lock; they are covered in chapter 10.
The default implementations are deliberately minimal and identity-based:
public class Point {
int x = 1, y = 2;
}
Point a = new Point();
Point b = new Point();
System.out.println(a); // Point@1b6d3586 -- class name + hex hashCode
System.out.println(a.equals(b)); // false -- identity comparison
System.out.println(a.hashCode()); // 460141958 -- derived from identity
System.out.println(a.getClass()); // class Point
Object.toString() is literally this:
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
That output is useless in a log, which is why toString is the first method
most classes override.
getClass() is final and returns the runtime class, not the declared
type:
Object o = "text";
System.out.println(o.getClass()); // class java.lang.String
System.out.println(o.getClass().getSimpleName()); // String
System.out.println(o.getClass().getName()); // java.lang.String
finalize() is deprecated for removal and should never be used. It ran
before garbage collection, with no guarantee of when or whether, and it caused
unpredictable delays and resurrection bugs. Use try-with-resources and
AutoCloseable instead (chapter 5).
Records and enums override some of these automatically:
public record Point(int x, int y) { } // equals, hashCode, toString generated
Point a = new Point(1, 2);
Point b = new Point(1, 2);
System.out.println(a); // Point[x=1, y=2]
System.out.println(a.equals(b)); // true
This is one of the strongest arguments for using records for pure data types: the three methods that are tedious and error-prone to write by hand come for free and are guaranteed consistent.
Key Takeaways
- Every class inherits
Object's methods, which is what makes collections and reflection work.toString,equals, andhashCodeare the three intended for overriding.- The defaults are identity-based and rarely useful.
getClass,wait,notify, andnotifyAllarefinaland cannot be overridden.finalizeis deprecated; useAutoCloseablefor cleanup.
🧪 Practice
- Print a plain object and decode which part of the output is the class name and which is the hash.
- Compare
getClass().getName()andgetSimpleName()for a nested class and an array type.- Interview: Why do
waitandnotifylive onObjectrather than onThread? (Hint: what can be used as a lock in Java?)
Overriding toString
toString produces a textual representation of an object. It is called
implicitly by string concatenation, System.out.println, and most logging
frameworks, so overriding it improves every diagnostic message in the program at
once.
public class Point {
private final int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
Point p = new Point(3, 4);
System.out.println(p); // Point(3, 4)
System.out.println("at " + p); // at Point(3, 4) -- implicit call
List.of(p, p).toString(); // [Point(3, 4), Point(3, 4)]
Without the override, the same three lines print Point@1b6d3586, which tells a
debugger nothing about the object's state.
Where it is called implicitly:
String s = "" + object; // string concatenation
System.out.println(object); // print methods
String.valueOf(object); // null-safe: returns "null"
logger.info("processing {}", object); // logging frameworks
collection.toString(); // collections call it on each element
String.format("%s", object); // format specifiers
Note String.valueOf(null) returns "null" while object.toString() on a null
reference throws. Concatenation is null-safe for the same reason.
What to include. A good toString contains the fields a human debugging the
program would want, in a format that is easy to scan:
// Poor: no context
@Override public String toString() { return name; }
// Poor: dumps everything, including a 10,000-element list
@Override public String toString() { return name + allTransactions; }
// Good: identifying fields, clearly labelled
@Override
public String toString() {
return "Account[id=" + id + ", owner=" + owner + ", balance=" + balance + "]";
}
The conventional format used by the JDK and by IDE generators is
ClassName[field=value, field=value]:
@Override
public String toString() {
return getClass().getSimpleName() // survives subclassing
+ "[id=" + id
+ ", name=" + name
+ ']';
}
Using getClass().getSimpleName() rather than a hard-coded literal means a
subclass inherits a correct representation automatically.
Security and privacy. toString output ends up in logs, which are often
retained and widely readable. Never include passwords, tokens, or personal data:
@Override
public String toString() {
return "User[email=" + email + ", passwordHash=" + passwordHash + "]"; // NO
}
@Override
public String toString() {
return "User[id=" + id + ", email=" + maskEmail(email) + "]"; // better
}
Performance. toString is often called in logging statements that are
disabled at runtime. Building an expensive string that is then discarded is pure
waste, which is why parameterised logging exists:
logger.debug("state: " + expensiveObject); // toString ALWAYS runs
logger.debug("state: {}", expensiveObject); // runs only if DEBUG is enabled
Generating it. Every IDE generates toString, and several libraries do it
reflectively. Records generate it automatically, which is the cleanest option
for data classes:
public record Point(int x, int y) { }
System.out.println(new Point(3, 4)); // Point[x=3, y=4]
Unlike equals and hashCode, toString has no contract that code depends on
for correctness. Treat it as documentation for humans, and feel free to change
the format — unless someone has started parsing it, which is itself a design
error worth preventing by documenting that the format is not stable.
Key Takeaways
toStringis called implicitly by concatenation, printing, logging, and collections.- The default prints a class name and a hash, which is useless for debugging.
- Use the
ClassName[field=value]convention andgetClass().getSimpleName()so subclasses inherit correct output.- Never include secrets or personal data; logs outlive the program.
- Use parameterised logging so a disabled log statement does not build the string.
🧪 Practice
- Add a
toStringto a class with four fields following the conventional format.- Show that
System.out.println(obj)and"" + objboth call it, whileobjbeing null behaves differently in each.- Compare a hand-written
toStringwith the one a record generates for the same fields.
The equals Contract
equals defines logical equality: whether two objects represent the same
value. Object's default compares identity, which is almost never what you
want for value types.
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b); // false -- different objects
System.out.println(a.equals(b)); // true -- String overrides equals
The contract. A correct equals must satisfy five properties, stated in the
Javadoc of Object.equals:
| Property | Requirement |
|---|---|
| Reflexive | x.equals(x) is true |
| Symmetric | x.equals(y) is true if and only if y.equals(x) is true |
| Transitive | If x.equals(y) and y.equals(z), then x.equals(z) |
| Consistent | Repeated calls return the same result if nothing changes |
| Null-safe | x.equals(null) is false, never throws |
Violating any of these breaks collections in ways that are hard to diagnose,
because HashMap, HashSet, List.contains, and List.remove all rely on
them.
The canonical implementation:
public final class Point {
private final int x, y;
@Override
public boolean equals(Object o) {
if (this == o) return true; // 1. identity shortcut
if (o == null || getClass() != o.getClass()) return false; // 2. type check
Point other = (Point) o; // 3. cast, now safe
return x == other.x && y == other.y; // 4. compare significant fields
}
@Override
public int hashCode() { return Objects.hash(x, y); } // 5. always pair them
}
Modern form with pattern matching:
@Override
public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
Comparing fields correctly depends on the type:
@Override
public boolean equals(Object o) {
if (!(o instanceof Item other)) return false;
return count == other.count // primitives: ==
&& Objects.equals(name, other.name) // objects: null-safe equals
&& Double.compare(price, other.price) == 0 // double: handles NaN and -0.0
&& Arrays.equals(tags, other.tags); // arrays: Arrays.equals
}
Double.compare matters: Double.NaN == Double.NaN is false, so a raw ==
would make an object containing NaN unequal to itself, violating reflexivity.
The symmetry trap with inheritance. Using instanceof across a hierarchy
breaks symmetry:
public class Point {
@Override public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
}
public class ColourPoint extends Point {
private final String colour;
@Override public boolean equals(Object o) {
return o instanceof ColourPoint c && super.equals(c) && colour.equals(c.colour);
}
}
Point p = new Point(1, 2);
ColourPoint c = new ColourPoint(1, 2, "red");
System.out.println(p.equals(c)); // true -- Point only checks x and y
System.out.println(c.equals(p)); // false -- p is not a ColourPoint
Symmetry is broken, and a HashSet containing both behaves differently
depending on insertion order. Two remedies exist:
- Use
getClass() != o.getClass(), which makes different classes never equal but also breaks substitutability for proxies and subclasses that add no state. - Favour composition over inheritance for value types, and make value classes
final.
The second is the recommendation from Effective Java: there is no way to extend an instantiable class and add a value component while preserving the contract.
Records handle all of this, generating an equals that compares every
component and is automatically correct:
public record Point(int x, int y) { } // equals and hashCode generated
Key Takeaways
equalsmust be reflexive, symmetric, transitive, consistent, and false fornull.- The default implementation compares identity, not contents.
- Compare primitives with
==, objects withObjects.equals, doubles withDouble.compare, and arrays withArrays.equals.- Extending an instantiable class and adding state cannot preserve symmetry.
- Make value classes
final, or use records, which generate a correct implementation.
🧪 Practice
- Write
equalsfor a class with anint, aString, and adouble[]field.- Reproduce the
Point/ColourPointsymmetry violation and demonstrate the inconsistentHashSetbehaviour.- Interview: Why must
equalsbe symmetric? (Hint: think about whatlist.contains(x)does internally and in which order.)
The hashCode Contract and equals Consistency
hashCode returns an int used by hash-based collections to choose a bucket.
It must be consistent with equals, and forgetting to override it alongside
equals is one of the most common bugs in Java.
The contract:
| Rule | Consequence if broken |
|---|---|
| Equal objects must have equal hash codes | Hash collections cannot find items |
| Unequal objects may share a hash code | Only a performance concern |
| The hash must not change while the object is in a collection | Lookups silently fail |
// The bug: equals overridden, hashCode forgotten
public class Point {
private final int x, y;
@Override public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
// no hashCode: inherits Object's identity-based version
}
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
System.out.println(set.contains(new Point(1, 2))); // false!
System.out.println(set.add(new Point(1, 2))); // true -- duplicate added
Why it fails. A HashMap locates an entry in two steps:
map.get(key)
1. bucket = hash(key.hashCode()) % tableSize <- finds the bucket
2. within the bucket, compare with equals() <- finds the entry
If two equal objects produce different hash codes, step 1 looks in the
wrong bucket and step 2 never runs.
+--------+ +--------+ +--------+ +--------+
| bucket | | bucket | | bucket | | bucket |
| 0 | | 1 | | 2 | | 3 |
+--------+ +--------+ +--------+ +--------+
^ ^
| |
stored here searched here -> never found
The correct implementation:
@Override
public int hashCode() {
return Objects.hash(x, y); // uses the same fields as equals
}
Objects.hash is convenient but allocates a varargs array. For a hot path,
the manual form avoids that:
@Override
public int hashCode() {
int result = Integer.hashCode(x);
result = 31 * result + Integer.hashCode(y);
result = 31 * result + Objects.hashCode(name);
return result;
}
The multiplier 31 is conventional: it is odd and prime, and 31 * i compiles to
a shift and subtract. The point of multiplying is that order matters, so that
("a", "b") and ("b", "a") hash differently.
Field-type specifics:
int -> Integer.hashCode(f) or just f
long -> Long.hashCode(f)
double -> Double.hashCode(f)
boolean -> Boolean.hashCode(f)
Object -> Objects.hashCode(f) // null-safe, returns 0 for null
array -> Arrays.hashCode(f) // NOT f.hashCode(), which is identity-based
Mutability is the other half of the problem. If a field used in hashCode
changes while the object is in a hash collection, the object is stranded in the
wrong bucket:
Set<MutablePoint> set = new HashSet<>();
MutablePoint p = new MutablePoint(1, 2);
set.add(p);
p.setX(99); // hash code changes
System.out.println(set.contains(p)); // false -- even for the SAME object
System.out.println(set.size()); // 1 -- it is still in there somewhere
This is the strongest practical argument for immutable keys, and it is why
String being immutable matters so much.
Caching for expensive hashes. Immutable objects can compute the hash once:
public final class LongKey {
private final String value;
private int hash; // 0 means "not yet computed"
@Override
public int hashCode() {
int h = hash;
if (h == 0 && !value.isEmpty()) {
h = value.hashCode();
hash = h; // safe: the value never changes
}
return h;
}
}
String itself uses exactly this technique.
The rule to remember: always override equals and hashCode together,
using the same fields. Every IDE generates both at once, and records generate
both automatically.
Key Takeaways
- Equal objects must have equal hash codes; unequal objects may collide.
- Overriding
equalswithouthashCodebreaksHashMap,HashSet, and every hash-based lookup.- Use
Objects.hash(...)for convenience or the31 * resultidiom for performance.- Use
Arrays.hashCodefor array fields, never the array's ownhashCode.- Mutating a field used in the hash while the object is in a collection strands it; prefer immutable keys.
🧪 Practice
- Write a class overriding
equalsbut nothashCode, and show that aHashSetfails to find an equal element.- Add a correct
hashCodeand verify the set now works.- Interview: Two objects have the same
hashCodebut are not equal. Is that a bug? (Hint: read the contract in both directions.)
Object Cloning and the Cloneable Interface
Object.clone() produces a field-by-field copy of an object. The mechanism is
widely regarded as a design mistake, and understanding why is more useful than
learning to use it.
public class Point implements Cloneable { // marker interface required
private int x, y;
@Override
public Point clone() { // covariant return type
try {
return (Point) super.clone(); // Object.clone does the copying
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // unreachable: we are Cloneable
}
}
}
Why the design is criticised:
Cloneableis a marker interface with no methods. It does not declareclone(); it merely changes whatObject.clone()does. Implementing an interface to alter a superclass method's behaviour is unlike anything else in Java.Object.clone()isprotected, soCloneablealone does not make an object cloneable by callers. You must also overrideclone()aspublic.CloneNotSupportedExceptionis checked, forcing a try-catch for a condition that cannot occur if the class isCloneable.- The copy is shallow. Nested mutable objects are shared between the original and the clone.
- It bypasses constructors, so invariants enforced there are not applied,
and
finalfields cannot be reassigned during cloning.
The shallow-copy problem in practice:
public class Person implements Cloneable {
private String name; // immutable: sharing is fine
private List<String> hobbies; // mutable: sharing is a bug
@Override
public Person clone() {
try {
return (Person) super.clone(); // hobbies is SHARED, not copied
} catch (CloneNotSupportedException e) { throw new AssertionError(e); }
}
}
Person original = new Person("Ada", new ArrayList<>(List.of("chess")));
Person copy = original.clone();
copy.getHobbies().add("cycling");
System.out.println(original.getHobbies()); // [chess, cycling] -- the original changed
The fix is to deep-copy the mutable components explicitly:
@Override
public Person clone() {
try {
Person copy = (Person) super.clone();
copy.hobbies = new ArrayList<>(this.hobbies); // deep copy
return copy;
} catch (CloneNotSupportedException e) { throw new AssertionError(e); }
}
Note this cannot work if hobbies is final, because clone assigns after the
object exists. That restriction alone rules out clone for well-designed
immutable classes.
The recommended alternatives:
// 1. A copy constructor -- explicit, works with final fields, no interface needed
public Person(Person other) {
this.name = other.name;
this.hobbies = new ArrayList<>(other.hobbies);
}
// 2. A static factory -- can return a subtype or a cached instance
public static Person copyOf(Person other) { return new Person(other); }
// 3. A record -- immutable, so copying is unnecessary; use a wither
public record Point(int x, int y) {
public Point withX(int newX) { return new Point(newX, y); }
}
// 4. For collections, the library already provides copies
List<String> copy = new ArrayList<>(original);
List<String> snapshot = List.copyOf(original);
| Approach | Deep copy control | Works with final | Type-safe | Verbose |
|---|---|---|---|---|
clone() | Manual, awkward | No | With covariant return | Yes |
| Copy constructor | Explicit | Yes | Yes | No |
| Static factory | Explicit | Yes | Yes | No |
| Serialization round-trip | Deep, automatic | Yes | Yes | Slow |
Arrays are the one place clone is idiomatic, because arrays have no other
copy syntax and their element type is known:
int[] original = {1, 2, 3};
int[] copy = original.clone(); // conventional and safe for primitives
String[][] grid = {{"a"}, {"b"}};
String[][] shallow = grid.clone(); // still shallow: rows are shared
The guidance in Effective Java is blunt: provide a copy constructor or copy
factory instead of implementing Cloneable. New code should not use it.
Key Takeaways
Cloneableis a marker interface that changesObject.clone()'s behaviour rather than declaring a method.Object.clone()performs a shallow copy and bypasses constructors.- Deep copying requires manual field fixes, which cannot work with
finalfields.- Prefer a copy constructor or a static copy factory.
array.clone()is idiomatic for arrays, though still shallow for nested arrays.
🧪 Practice
- Implement
cloneon a class with aListfield and demonstrate the shared-state bug, then fix it.- Rewrite the same class using a copy constructor and compare the two.
- Interview: Why is
Cloneableconsidered a broken design? (Hint: name two things it does not do that its name implies.)
getClass and Runtime Type Information
getClass() returns a Class object describing an object's runtime type. That
object is the entry point to reflection, and it also appears in ordinary code in
equals implementations and error messages.
Object o = "text";
Class<?> c = o.getClass();
System.out.println(c.getName()); // java.lang.String
System.out.println(c.getSimpleName()); // String
System.out.println(c.getPackageName()); // java.lang
System.out.println(c.isInterface()); // false
System.out.println(c.getSuperclass()); // class java.lang.Object
Three ways to obtain a Class object:
Class<?> a = "text".getClass(); // from an instance
Class<String> b = String.class; // a class literal, compile-time
Class<?> d = Class.forName("java.lang.String"); // by name, at runtime
The class literal String.class is typed Class<String>, while getClass()
returns Class<? extends X> because of erasure. Class.forName throws
ClassNotFoundException and triggers class initialisation.
Runtime type versus declared type, which is the distinction that matters most:
Animal a = new Dog();
System.out.println(a.getClass().getSimpleName()); // Dog -- the ACTUAL class
The declared type is Animal, but getClass() always reports what the object
really is. This is the same information dynamic dispatch uses.
Name formats differ by type, which surprises people reading logs:
System.out.println(int.class.getName()); // int
System.out.println(int[].class.getName()); // [I
System.out.println(String[].class.getName()); // [Ljava.lang.String;
System.out.println(new Outer.Inner().getClass().getName()); // Outer$Inner
System.out.println(new Runnable(){public void run(){}}.getClass().getName());
// Enclosing$1 (anonymous)
Common uses in ordinary code:
// 1. Strict type checking in equals
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
...
}
// 2. Informative error messages
throw new IllegalArgumentException(
"Expected a Number but got " + value.getClass().getSimpleName());
// 3. Logger naming, the standard idiom
private static final Logger log = LoggerFactory.getLogger(MyService.class);
// 4. A subclass-aware toString
@Override public String toString() {
return getClass().getSimpleName() + "[id=" + id + "]";
}
getClass() versus instanceof, restated because the choice is
consequential:
Animal a = new Dog();
a instanceof Animal // true -- includes subtypes
a.getClass() == Animal.class // false -- exact match only
a.getClass() == Dog.class // true
Animal.class.isInstance(a) // true -- the reflective form of instanceof
For equals, the choice determines whether a subclass instance can ever equal a
superclass instance. getClass() says no, preserving symmetry at the cost of
substitutability; instanceof says maybe, which can break symmetry as shown
earlier.
Reflection, which Class unlocks, is covered in chapter 10. A brief taste:
Class<?> c = obj.getClass();
for (Method m : c.getDeclaredMethods()) {
System.out.println(m.getName());
}
Object instance = c.getDeclaredConstructor().newInstance();
Reflection is how frameworks such as Spring, Jackson, and JUnit operate on classes they have never seen. It is powerful, slow relative to direct calls, and bypasses compile-time checking, so it belongs in framework code rather than application logic.
getClass() is final, so no class can lie about its identity. That
guarantee is what makes it usable in security checks and in equals.
Key Takeaways
getClass()returns the object's actual runtime class, not its declared type.- A
Classobject can also be obtained from a class literal or by name withClass.forName.- Array and nested class names use special formats such as
[IandOuter$Inner.- In
equals,getClass()gives exact matching whileinstanceofaccepts subtypes.getClass()isfinal, so its answer can be trusted; it is the gateway to reflection.
🧪 Practice
getName()andgetSimpleName()for a primitive, an array, a nested class, and an anonymous class.- Write two
equalsimplementations for one class, one usinggetClass()and one usinginstanceof, and find an input where they disagree.- Interview: What is the difference between
String.classand"x".getClass()in terms of static type? (Hint: consider erasure.)
<a id="36-composition-and-design-choices"></a>
3.6 Composition and Design Choices
Inheritance is one way to reuse code and it is frequently the wrong one. This section covers the alternative, the failure modes of inheritance, and how to choose.
Has-A versus Is-A Relationships
Two classes can relate in two fundamentally different ways, and picking the wrong one produces designs that resist every later change.
Is-a means the subclass is a specialised kind of the superclass, expressed
with extends. Has-a means one class contains another as a component,
expressed with a field.
// IS-A: a Manager is a kind of Employee
public class Manager extends Employee {
private String department;
}
// HAS-A: a Car has an Engine; it is not a kind of engine
public class Car {
private final Engine engine; // composition
public Car(Engine engine) { this.engine = engine; }
public void start() { engine.ignite(); } // delegation
}
IS-A (inheritance) HAS-A (composition)
Employee Car
^ |
| extends | field
Manager Engine
Manager inherits every Car chooses which Engine
Employee member capabilities to expose
The substitution test. Before writing extends, check whether the sentence
"every X is a Y" is true for every use of Y in the program:
// Passes: every savings account is an account, everywhere
public class SavingsAccount extends Account { }
// Fails: a Stack is not a List -- a List allows insertion at any index,
// which would violate the stack discipline
public class Stack<E> extends ArrayList<E> { } // Java's actual Stack does
// this, and it is a known mistake
Stack<Integer> s = new Stack<>();
s.push(1);
s.add(0, 99); // inherited from List: inserts at the bottom. Nonsense.
Java's own java.util.Stack extends Vector for historical reasons, and the
JDK documentation now recommends Deque instead. It is a useful reminder that
the standard library contains inheritance mistakes too.
The composition alternative:
public class Stack<E> {
private final List<E> items = new ArrayList<>(); // HAS-A list
public void push(E item) { items.add(item); }
public E pop() {
if (items.isEmpty()) throw new NoSuchElementException();
return items.remove(items.size() - 1);
}
public boolean isEmpty() { return items.isEmpty(); }
// No add(int, E). No get(int). The API is exactly the stack operations.
}
The composed version exposes only what a stack should support. The inherited
version exposes everything List has, including operations that break the
abstraction.
A practical checklist:
| Question | Answer suggests |
|---|---|
| Is every X genuinely a Y in all contexts? | Inheritance |
| Would X want to hide or forbid some of Y's methods? | Composition |
| Does X need to swap its Y at runtime? | Composition |
| Does X need several Ys of different kinds? | Composition |
| Is the relationship "uses", "has", or "is made of"? | Composition |
| Is Y's author committed to supporting subclasses? | Inheritance, cautiously |
Both relationships can coexist:
public class ElectricCar extends Car { // IS-A: a kind of car
private final Battery battery; // HAS-A: contains a battery
private final List<Sensor> sensors; // HAS-MANY
}
A third relationship, "implements", describes a capability and is covered in
chapter 4. ElectricCar implements Chargeable says what it can do rather than
what it is or has.
Key Takeaways
extendsexpresses is-a; a field expresses has-a.- Use inheritance only when the substitution claim holds in every context.
- Composition lets the containing class expose exactly the API it wants.
java.util.Stack extends Vectoris a standard-library example of getting this wrong.- A class commonly combines inheritance, composition, and interfaces.
🧪 Practice
- Classify each pair as is-a or has-a: Dog/Animal, Car/Engine, Square/Rectangle, Order/LineItem, Manager/Employee.
- Implement a
Stacktwice, once extendingArrayListand once containing one, then find an operation that breaks the first.- Interview: Why is
Stack extends Vectorconsidered a design mistake? (Hint: what can you do to a Vector that makes no sense for a stack?)
Aggregation and Composition
"Has-a" subdivides into two relationships that differ in lifetime ownership. The distinction comes from UML and affects how you construct and dispose of objects.
Aggregation is a weak has-a: the contained object exists independently and can outlive or be shared by the container.
public class Department {
private final List<Employee> employees = new ArrayList<>();
public void add(Employee e) { employees.add(e); } // received from outside
}
Employee ada = new Employee("Ada");
Department eng = new Department();
eng.add(ada);
// Deleting the department does not delete Ada; she may join another department.
Composition is a strong has-a: the container owns the part, creates it, and the part cannot meaningfully exist without the whole.
public class Order {
private final List<LineItem> items = new ArrayList<>();
public void addItem(String sku, int qty) {
items.add(new LineItem(sku, qty)); // created and owned here
}
public List<LineItem> getItems() {
return List.copyOf(items); // never hand out the originals
}
}
// A LineItem has no meaning outside its Order; deleting the Order deletes them.
AGGREGATION (hollow diamond in UML) COMPOSITION (filled diamond)
Department <>---- Employee Order <*>---- LineItem
- Employee created elsewhere - LineItem created by Order
- may belong to several departments - belongs to exactly one Order
- survives the department - dies with the Order
| Aspect | Aggregation | Composition |
|---|---|---|
| Lifetime | Independent | Tied to the container |
| Creation | Passed in | Created by the container |
| Sharing | Can be shared | Exclusively owned |
| On container death | Part survives | Part is destroyed |
| Typical example | Department and Employee | Order and LineItem, House and Room |
Why it matters in code. The distinction drives three concrete decisions:
1. Defensive copying. Owned parts must not leak; shared parts are expected to be shared:
public class Order { // composition: protect the parts
public List<LineItem> getItems() { return List.copyOf(items); }
}
public class Department { // aggregation: sharing is the point
public List<Employee> getEmployees() { return Collections.unmodifiableList(employees); }
}
2. Cascading operations. Deleting an order should delete its line items; deleting a department should not delete its employees. This maps directly onto database cascade rules and ORM mappings:
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) // composition
private List<LineItem> items;
@ManyToMany // aggregation
private List<Employee> employees;
3. Construction. A composed part is created inside the container, so the container controls its invariants. An aggregated part arrives already built.
// Composition: the Order decides how a LineItem is made
public void addItem(String sku, int qty) {
if (qty <= 0) throw new IllegalArgumentException();
items.add(new LineItem(sku, qty));
}
// Aggregation: the Employee was validated by whoever created it
public void add(Employee e) {
employees.add(Objects.requireNonNull(e));
}
In practice the boundary blurs, and many teams use "composition" loosely to mean any has-a relationship, which is how the phrase "favour composition over inheritance" is normally meant. The ownership distinction still matters whenever lifecycle, copying, or persistence is involved.
Key Takeaways
- Aggregation is a weak has-a where the part exists independently.
- Composition is a strong has-a where the container owns the part's lifetime.
- Owned parts should be created internally and never leaked; shared parts are passed in.
- The distinction drives defensive copying, cascade rules, and construction responsibility.
- "Composition" is often used loosely to mean any has-a relationship.
🧪 Practice
- Model a
HousewithRoomobjects as composition and aLibrarywithBookobjects as aggregation, and justify each choice.- Write a composed class that creates its parts internally and returns only copies.
- Interview: Should deleting an
Orderdelete itsLineItems? What about deleting aDepartmentand itsEmployees? (Hint: can the part exist alone?)
Favoring Composition Over Inheritance
This is one of the most repeated pieces of object-oriented advice, and it rests on a specific technical argument rather than mere taste: inheritance couples a subclass to the implementation of its superclass, not just its interface.
The problem, demonstrated. A set that counts how many elements have been added:
public class CountingSet<E> extends HashSet<E> {
private int addCount;
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() { return addCount; }
}
CountingSet<String> set = new CountingSet<>();
set.addAll(List.of("a", "b", "c"));
System.out.println(set.getAddCount()); // 6, not 3
The count is doubled because HashSet.addAll internally calls add for each
element, and that call dispatches to the override. The subclass depends on an
undocumented implementation detail of its superclass — and that detail could
change in the next JDK release, silently changing the subclass's behaviour.
This is self-use, and it makes a superclass's internal call structure part of its effective API.
The composition solution. Wrap rather than extend:
public class CountingSet<E> implements Set<E> {
private final Set<E> delegate; // HAS-A set
private int addCount;
public CountingSet(Set<E> delegate) { this.delegate = delegate; }
@Override public boolean add(E e) {
addCount++;
return delegate.add(e); // no self-use: a different object
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return delegate.addAll(c); // delegate's internal calls stay internal
}
public int getAddCount() { return addCount; }
// remaining Set methods delegate straightforwardly
@Override public int size() { return delegate.size(); }
@Override public boolean contains(Object o){ return delegate.contains(o); }
// ...
}
CountingSet<String> set = new CountingSet<>(new HashSet<>());
set.addAll(List.of("a", "b", "c"));
System.out.println(set.getAddCount()); // 3 -- correct
INHERITANCE COMPOSITION
CountingSet CountingSet
extends HashSet holds a Set
| |
addAll() calls add() addAll() calls delegate.addAll()
which is OVERRIDDEN delegate's internal add() is its own
| |
double counting correct counting
The additional benefits of composition:
| Benefit | Explanation |
|---|---|
Works with any Set | new CountingSet<>(new TreeSet<>()) also works |
| Survives library changes | No dependence on the delegate's internal call structure |
| Runtime flexibility | The delegate can be swapped or decided by configuration |
| Narrower API | Expose only the methods that make sense |
| Easier testing | Inject a mock or stub delegate |
The cost is boilerplate: every interface method must be forwarded. Three mitigations exist:
// 1. A reusable forwarding class, then extend it (Effective Java's approach)
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public int size() { return s.size(); }
public boolean add(E e) { return s.add(e); }
// ... all methods forwarded once, reusable by many wrappers
}
public class CountingSet<E> extends ForwardingSet<E> { ... } // only the delta
// 2. IDE generation: "Generate delegate methods" writes them all
// 3. Project Lombok's @Delegate, if the project already uses Lombok
When inheritance is still the right choice:
- The superclass was explicitly designed and documented for extension, stating which methods are self-used.
- You control both classes and they live in the same package or module.
- The is-a relationship genuinely holds in every context.
- You need polymorphic substitution through the superclass type and the superclass is abstract.
// Designed for inheritance: abstract, documents its self-use, and the
// extension points are clearly marked
public abstract class AbstractList<E> implements List<E> { ... }
The maxim is "favour", not "never use". Inheritance within a deliberately designed hierarchy is fine; inheritance across a library boundary, of a concrete class you do not control, is where the trouble is.
Key Takeaways
- Inheritance couples a subclass to its superclass's implementation, including undocumented self-use.
- The
CountingSetdouble-count bug is the canonical demonstration.- Composition with delegation avoids self-use, works with any implementation, and narrows the exposed API.
- The cost is forwarding boilerplate, mitigated by a reusable forwarding class or IDE generation.
- Inheritance remains correct for hierarchies explicitly designed and documented for extension.
🧪 Practice
- Build the
CountingSetbug by extendingHashSet, confirm the double count, then fix it with composition.- Write a
LoggingListwrapper that logs every mutation and delegates the rest.- Interview: Why does extending
HashSetand overridingaddproduce a wrong count fromaddAll? (Hint: what doesaddAllcall internally?)
Delegation Patterns
Delegation is the mechanism that makes composition useful: an object forwards a request to a component instead of implementing it itself. It is the runtime counterpart to inheritance's compile-time reuse.
public class Printer {
private final PrintEngine engine; // the delegate
public Printer(PrintEngine engine) { this.engine = engine; }
public void print(Document d) {
engine.render(d); // delegate the work
}
}
Delegation plus an interface gives runtime substitution, which inheritance cannot do:
public interface SortStrategy { void sort(int[] data); }
public class QuickSort implements SortStrategy { public void sort(int[] d) { ... } }
public class MergeSort implements SortStrategy { public void sort(int[] d) { ... } }
public class Sorter {
private SortStrategy strategy;
public Sorter(SortStrategy strategy) { this.strategy = strategy; }
public void setStrategy(SortStrategy s) { this.strategy = s; } // swap at runtime
public void sort(int[] data) { strategy.sort(data); }
}
Sorter sorter = new Sorter(new QuickSort());
sorter.sort(smallArray);
sorter.setStrategy(new MergeSort()); // impossible with inheritance
sorter.sort(hugeArray);
A subclass is fixed at construction; a delegate can change at any time.
Common delegation-based patterns, each covered in chapter 9:
// STRATEGY: the delegate is an interchangeable algorithm (above)
// DECORATOR: the delegate is the same type, and the wrapper adds behaviour
public class BufferedReader extends Reader {
private final Reader in; // wraps another Reader
public int read() { ... in.read() ... } // adds buffering
}
new BufferedReader(new FileReader("data.txt")); // JDK's own I/O design
// ADAPTER: the delegate has a different interface, translated by the wrapper
public class LegacyLoggerAdapter implements Logger {
private final OldLogger old;
@Override public void info(String msg) { old.writeLine("INFO: " + msg); }
}
// PROXY: the delegate is the real object, and the wrapper controls access
public class CachingRepository implements Repository {
private final Repository real;
private final Map<String, Entity> cache = new HashMap<>();
@Override public Entity find(String id) {
return cache.computeIfAbsent(id, real::find); // delegate on a miss
}
}
DELEGATION SHAPES
Strategy Context --> [ interchangeable algorithm ]
Decorator Wrapper --> [ same interface, adds behaviour ]
Adapter Adapter --> [ different interface, translates ]
Proxy Proxy --> [ same interface, controls access ]
All four are one object holding another and forwarding calls.
Dependency injection is delegation applied systematically: a class declares what it needs and receives it rather than constructing it.
// Hard-wired: impossible to test without a real database
public class OrderService {
private final Repository repo = new PostgresRepository();
}
// Injected: the collaborator is chosen by the caller
public class OrderService {
private final Repository repo;
public OrderService(Repository repo) { this.repo = repo; }
}
new OrderService(new PostgresRepository()); // production
new OrderService(new InMemoryRepository()); // tests
This is the basis of Spring and every other dependency-injection framework, and it is covered in chapter 13.
Delegation in Java 8 and later is often expressed with a functional interface rather than a class, which removes the boilerplate entirely:
public class Sorter {
private final Consumer<int[]> strategy;
public Sorter(Consumer<int[]> strategy) { this.strategy = strategy; }
public void sort(int[] data) { strategy.accept(data); }
}
new Sorter(Arrays::sort); // a method reference as the delegate
new Sorter(d -> customSort(d, comparator)); // or a lambda
The one genuine drawback is indirection: following a delegated call means finding which implementation was injected, which a reader cannot determine from the source alone. Naming the field well and keeping the delegation shallow mitigates it.
Key Takeaways
- Delegation forwards a request to a held component, and is the runtime counterpart of inheritance.
- Combined with an interface, it permits swapping behaviour at runtime.
- Strategy, decorator, adapter, and proxy are all delegation with different intents.
- Dependency injection is delegation applied to a class's collaborators.
- Functional interfaces let a delegate be a lambda or method reference instead of a class.
🧪 Practice
- Implement a
Sorterthat accepts a strategy and swap it at runtime.- Write a decorator that wraps a
Listand logs everyaddcall.- Interview: What can delegation do that inheritance cannot? (Hint: think about when the relationship is fixed.)
Fragile Base Class Problem
The fragile base class problem is the general name for the failure mode seen in
CountingSet: a change to a superclass that is correct in isolation breaks a
subclass that was also correct in isolation.
Cause 1: self-use changes. The superclass alters which of its own methods it calls internally:
// Library version 1
public class Collection {
public void addAll(List<String> items) {
for (String s : items) add(s); // addAll USES add
}
public void add(String s) { ... }
}
// A subclass written against version 1
public class LoggingCollection extends Collection {
@Override public void add(String s) {
log("adding " + s);
super.add(s);
}
// addAll is inherited, and logs each element because addAll calls add
}
// Library version 2: an internal optimisation, documented as a bug fix
public class Collection {
public void addAll(List<String> items) {
bulkInsert(items); // no longer calls add()
}
}
// LoggingCollection now silently logs nothing for addAll. Nothing failed
// to compile, and no test of the library caught it.
Cause 2: a new superclass method collides. A method added to the superclass accidentally matches a subclass method's signature:
// Subclass, written today
public class MyList extends AbstractList<String> {
public boolean addIfAbsent(String s) { ... } // our own helper
}
// A later JDK version adds AbstractList.addIfAbsent with different semantics
// -> our method silently becomes an override, and may now violate the
// superclass's documented contract, or fail to compile if the return
// type differs.
Cause 3: a new superclass field shadows one in the subclass, or an added constructor changes which one is selected.
THE FRAGILE BASE CLASS PROBLEM
Base v1 Subclass Result
+----------+ +------------+
| addAll |--calls-->| add (over- | works correctly
| add | | ridden) |
+----------+ +------------+
Base v2 same Subclass Result
+----------+ +------------+
| addAll |--calls-->| bulkInsert | subclass behaviour
| add | | (not over- | silently changed
| bulkInsert| | ridden) |
+----------+ +------------+
Mitigations, from strongest to weakest:
1. Use composition. The delegate's internal calls are its own business, so none of these failure modes reach the wrapper.
2. Make the class final. If nobody can extend it, its internals stay
internal:
public final class ImmutableList<E> { ... }
3. Design for inheritance explicitly. Document self-use, and mark extension points clearly. The JDK does this in places:
/**
* ...
* @implSpec This implementation calls {@code add(e)} once for each element,
* in the order returned by the collection's iterator. Overriding
* {@code add} therefore affects this method.
*/
public boolean addAll(Collection<? extends E> c) { ... }
The @implSpec tag exists precisely to turn undocumented self-use into a
documented contract.
4. Make self-used methods final or private. If the superclass calls a
method that subclasses cannot override, the coupling disappears:
public class Collection {
public void addAll(List<String> items) {
for (String s : items) addInternal(s); // private: not an override point
}
public void add(String s) { addInternal(s); }
private void addInternal(String s) { ... }
}
5. Provide a skeletal implementation that subclasses extend, keeping the
concrete classes final, as AbstractList and ArrayList do.
The practical rule stated in Effective Java: design and document for inheritance or else prohibit it. A concrete class that is neither is a liability, because its behaviour becomes accidentally load-bearing for subclasses you never anticipated.
| Situation | Recommended approach |
|---|---|
| Reusing a class you do not control | Composition |
| Reusing a class across a library boundary | Composition |
| A hierarchy you own, designed for extension | Inheritance, documented |
| A value type | final, no inheritance |
| A framework extension point | Abstract class or interface |
Key Takeaways
- A superclass change that is correct in isolation can break subclasses that are also correct in isolation.
- The causes are changed self-use, newly added colliding methods, and shadowed fields.
- Composition eliminates the problem entirely, because delegate internals stay internal.
- If inheritance is intended, document self-use with
@implSpecand make non-extension pointsprivateorfinal.- Design and document a class for inheritance, or make it
final.
🧪 Practice
- Write a base class whose
addAllcallsadd, subclass it, then change the base to stop callingaddand observe the silent behaviour change.- Make the self-used method private and show that the fragility disappears.
- Interview: What does
@implSpecdocument, and why does it exist? (Hint: what does a subclass author need to know that the signature does not say?)
Sealed Classes and Permitted Subtypes
Java historically offered only two extremes: a class was either open to
extension by anyone, or final and closed to everyone. Sealed classes, standard
since Java 17, add a middle option — extensible by a known, fixed set of
subclasses.
public sealed class Shape permits Circle, Square, Triangle { }
public final class Circle extends Shape { }
public final class Square extends Shape { }
public non-sealed class Triangle extends Shape { } // reopens the hierarchy
Any other class attempting to extend Shape fails to compile:
// public final class Hexagon extends Shape { }
// ERROR: class is not allowed to extend sealed class: Shape
Every permitted subclass must choose one of three modifiers:
| Modifier | Meaning |
|---|---|
final | No further extension |
sealed | Extensible by its own permitted list |
non-sealed | Open to anyone again, ending the seal on that branch |
public sealed interface Vehicle permits Car, Truck { }
public sealed class Car implements Vehicle permits Sedan, Hatchback { } // nested seal
public final class Sedan extends Car { }
public final class Hatchback extends Car { }
public non-sealed class Truck implements Vehicle { } // anyone may extend Truck
The permits clause may be omitted when all subclasses are in the same
source file, which is the most common form for small hierarchies:
public sealed interface Result { // no permits clause needed
record Success(String value) implements Result { }
record Failure(String error) implements Result { }
}
The two motivations:
1. Modelling a closed set of alternatives. Some concepts genuinely have a fixed number of cases. A payment is card, transfer, or cash; a result is success or failure. Sealing states that in the type system.
2. Exhaustive pattern matching. Because the compiler knows every subtype, a
switch over a sealed type needs no default:
public sealed interface Shape permits Circle, Square { }
public record Circle(double radius) implements Shape { }
public record Square(double side) implements Shape { }
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
// no default: the compiler verifies all cases are covered
};
}
Adding a Triangle to the permits clause causes every such switch to fail
compilation until it is handled. That is the key benefit: the compiler finds
every place that must change, instead of a default branch silently
mishandling the new case at runtime.
WITHOUT SEALING WITH SEALING
switch (shape) { switch (shape) {
case Circle c -> ... case Circle c -> ...
case Square s -> ... case Square s -> ...
default -> throw ... <- new type } <- new permitted type
} silently breaks the build
hits here at every switch
Requirements:
- Permitted subclasses must be in the same module, or the same package if the code is not modularised.
- Each permitted subclass must directly extend the sealed class.
- Each must be declared
final,sealed, ornon-sealed.
Sealed types plus records produce something close to algebraic data types from functional languages, which is why the two features arrived together:
public sealed interface Expr permits Num, Add, Mul { }
public record Num(int value) implements Expr { }
public record Add(Expr left, Expr right) implements Expr { }
public record Mul(Expr left, Expr right) implements Expr { }
static int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
case Add(Expr l, Expr r) -> eval(l) + eval(r); // record pattern
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
Ten lines give a complete, exhaustively-checked expression evaluator.
| Class kind | Who may extend | Use for |
|---|---|---|
| open (default) | Anyone | Framework extension points |
final | Nobody | Value types, security-critical |
sealed | A fixed, declared list | Closed sets of alternatives |
abstract | Anyone, and must be extended | Partial implementations |
Key Takeaways
sealedpermits extension by a declared, fixed list of subclasses.- Each permitted subclass must be
final,sealed, ornon-sealed.- The
permitsclause is optional when subclasses share the source file.- Sealing enables exhaustive
switchwith nodefault, so a new subtype breaks the build rather than failing at runtime.- Sealed interfaces plus records model closed sets of alternatives concisely.
🧪 Practice
- Define a sealed
PaymentMethodwith three permitted records and write an exhaustive switch over it.- Add a fourth permitted type and observe which code stops compiling.
- Interview: What does
non-sealedmean, and why would you use it? (Hint: what if one branch of the hierarchy should stay open?)
<a id="4-interfaces-and-abstraction-contracts"></a>