JAVA / INHERITANCE AND POLYMORPHISM
Choosing inheritance only when it earns its place
Decide between extends and delegation by checking whether a subclass can honor the parent's full contract, and wrap a delegate field when it cannot.
What you will learn
- Reject extends when the subclass cannot honor every inherited method's contract
- Spot fragile self-use: a parent method that calls its own overridable method
- Swap extends for a private delegate field plus a shared interface
- Test substitutability, not just the words 'is-a', before you extend
Understanding Choosing inheritance only when it earns its place
extends looks like a way to reuse code, but what it really does is sign a contract on your behalf. The subclass inherits every public and protected member the parent has today, including the ones you never read, and callers are free to use all of them. So the useful question is not whether your class shares code with the parent, but whether your class can keep every promise the parent's documentation makes, for every inherited method. One method you cannot honor is enough to rule inheritance out, no matter how much typing it would save.
The deeper problem is that a parent's methods often call each other. When a parent's recordAll loops over its argument calling record, or when AbstractCollection.addAll calls add once per element, overriding one method silently rewires the other, and the only way to predict the result is to read the parent's source. That coupling also runs forward in time: the parent can change its internal self-calls in a later release, your subclass still compiles, and it quietly starts doing the wrong thing. A delegate cannot surprise you that way, because it is only reached through calls you wrote yourself.
A workable rule is to extend only when the parent was designed and documented for extension and the subtype is substitutable everywhere the parent is used; otherwise hold the parent type in a private final field and forward the few methods you actually want. The reuse then comes from the delegate, the polymorphism comes from an interface both classes implement, and the coupling you did not want never enters the design. This is also why so much library code is marked final or shipped as an abstract skeleton with documented hooks: extension is a feature that has to be built and maintained, not a default that comes for free.
class Ledger {
private double total;
public void record(double amount) {
total += amount;
}
public void recordAll(double... amounts) {
for (double a : amounts) {
record(a); // self-call: a subclass can intercept this
}
}
public double total() {
return total;
}
}
// Inheritance: the subclass has to guess how recordAll is implemented.
class SubclassedCountingLedger extends Ledger {
private int entries;
@Override
public void record(double amount) {
entries++;
super.record(amount);
}
@Override
public void recordAll(double... amounts) {
entries += amounts.length;
super.recordAll(amounts); // which routes back through record()
}
public int entries() {
return entries;
}
}
// Composition: the wrapper counts only what its own methods are asked to do.
class WrappingCountingLedger {
private final Ledger delegate = new Ledger();
private int entries;
public void record(double amount) {
entries++;
delegate.record(amount);
}
public void recordAll(double... amounts) {
for (double a : amounts) {
record(a);
}
}
public double total() {
return delegate.total();
}
public int entries() {
return entries;
}
}
public class Main {
public static void main(String[] args) {
SubclassedCountingLedger inherited = new SubclassedCountingLedger();
inherited.recordAll(10.0, 20.0, 30.0);
System.out.println("inheritance total = " + inherited.total());
System.out.println("inheritance entries = " + inherited.entries());
WrappingCountingLedger wrapped = new WrappingCountingLedger();
wrapped.recordAll(10.0, 20.0, 30.0);
System.out.println("composition total = " + wrapped.total());
System.out.println("composition entries = " + wrapped.entries());
}
}Inheritance permanently couples a subclass to the parent's whole contract and to its internal self-calls, so it is only worth it when the subclass is truly substitutable and the parent invites extension.
Worked examples
Inheriting an API you cannot police
Properties extends Hashtable, so callers can store a non-String value that the class's own accessor then refuses to see.
import java.util.Properties;
public class Main {
public static void main(String[] args) {
Properties config = new Properties();
config.setProperty("port", "8080");
config.put("timeout", 30); // legal, inherited from Hashtable<Object,Object>
System.out.println("getProperty(port) = " + config.getProperty("port"));
System.out.println("getProperty(timeout) = " + config.getProperty("timeout"));
System.out.println("containsKey(timeout) = " + config.containsKey("timeout"));
System.out.println("get(timeout) = " + config.get("timeout"));
}
}Example explained
Line 1setProperty stores a String, so getProperty("port") finds a String and hands it back.
Line 2put comes from Hashtable and accepts Object keys and values, so the boxed Integer 30 is stored with no complaint.
Line 3getProperty("timeout") returns null because it only returns values that are instanceof String.
Line 4containsKey and get both see the entry, so the class ends up with two disagreeing views of its own data, purely because of the extends clause.
Delegation keeps the wrapper independent
A filtering wrapper adds behavior around another implementation without inheriting anything from it.
interface Notifier {
void send(String message);
}
class EmailNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("email -> " + message);
}
}
class DeduplicatingNotifier implements Notifier {
private final Notifier delegate;
private String last;
DeduplicatingNotifier(Notifier delegate) {
this.delegate = delegate;
}
@Override
public void send(String message) {
if (message.equals(last)) {
System.out.println("skipped -> " + message);
return;
}
last = message;
delegate.send(message);
}
}
public class Main {
public static void main(String[] args) {
Notifier notifier = new DeduplicatingNotifier(new EmailNotifier());
notifier.send("disk 80% full");
notifier.send("disk 80% full");
notifier.send("disk 91% full");
}
}Example explained
Line 1DeduplicatingNotifier implements Notifier instead of extending EmailNotifier, so callers still see a single supertype.
Line 2delegate is a field, so the only behavior this class depends on is the one method declared in the interface.
Line 3The repeated message never reaches the delegate: the wrapper controls every entry point, because there is no inherited method that could route around it.
Line 4Any other Notifier, including a test double, can be wrapped without changing a line of this class.
Important notes
Composition costs forwarding code and Java has no built-in delegation keyword, so keep the delegated surface small rather than mirroring a 30-method type by hand.
This is not an argument against ever extending: abstract skeleton classes and documented template methods exist to be extended. The risky bet is extending a concrete class whose source and release schedule you do not control.
Common mistakes
Extending ArrayList and overriding add to enforce a rule: addAll copies straight into the backing array and add(int, E) and set are separate methods, so invalid elements still get in while the code looks guarded.
Overriding both a single-item method and the bulk method that internally calls it, which counts or validates each element twice; removing the super call to fix the number then skips the parent's real work.
Reaching for extends just to borrow helper methods when there is no is-a claim, as in Stack extends Vector: callers can then index into the middle of a stack, and that API can never be taken back.
Try it yourself
Change, predict, then run
In one file, write SizeCappedList extends ArrayList<String> that overrides add to refuse a fourth element, plus a WrappedCappedList that holds a private ArrayList<String> and enforces the same cap in its own add and addAll. Call addAll with four strings on each and print both sizes to see which cap actually holds.
Open the Java workspaceCheck your understanding
A parent's saveAll(List<T>) currently loops and calls its own overridable save(T). Your subclass overrides save to write an audit entry. The next release of the parent rewrites saveAll to send one batch statement directly to the database. What happens to your subclass?
- Nothing changes, because dynamic dispatch guarantees the override still runs for each element
- Audit entries silently stop appearing for saveAll, while the code still compiles and runs
- The subclass stops compiling until it also overrides saveAll
- saveAll throws at runtime because the override has become unreachable
Show answer
Dynamic dispatch only redirects calls that are actually made. Once saveAll batches the write instead of calling save, there is nothing left to dispatch, so the override is simply never reached: no compiler error, no exception, just missing audit rows discovered much later. The first option is tempting because overriding really does win whenever save is invoked, but that is a promise about which method gets chosen, not a promise that the parent will keep calling it.