Inheritance, Polymorphism & Abstraction — Done Right
This is post 5 of the Core Java track. It assumes you can write classes, objects, constructors, and getters/setters (posts 1–4). Next up: interfaces.
The real question: when is a "is-a" relationship worth encoding?
Inheritance is Java's mechanism for saying one class is a more specific version of another. A Dog is an Animal. An EmergencyAlert is an Alert. The keyword is extends, and it hands the child class everything the parent has — fields, methods — for free.
The decision rule that matters: use inheritance when the subclass genuinely is a more specific version of the parent, and when you want code that works with the parent to work unchanged with the child. If you're reaching for extends just to reuse a couple of methods, stop — that's composition's job, and we'll cover it at the end of this post.
extends and overriding
A parent class defines shared behavior; a subclass overrides it when it has a more specific version:
class Animal {
void speak() {
System.out.println("Some sound");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("Woof");
}
}
Why @Override is not optional in practice
The @Override annotation looks like decoration. It is a compile-time contract: "the compiler must verify that this method actually overrides a parent method." Without it, this happens:
class Animal {
void speak() {
System.out.println("Some sound");
}
}
class Cat extends Animal {
// Oops: parameter added by accident. This is an OVERLOAD, not an override.
void speak(String mood) {
System.out.println("Meow (" + mood + ")");
}
}
Without @Override, that compiles silently. Cat inherits speak() unchanged and adds a new speak(String) — your "override" never runs, and nothing warns you. With @Override on the method, the compiler fails the build: "method does not override or implement a method from a supertype." That failure is the whole point — it converts a silent runtime surprise into an immediate compile error.
Rule: put @Override on every overriding method, every time. It costs four characters and catches an entire class of bugs.
super: reaching the parent's version
super lets a subclass use its parent's implementation. Two uses matter:
1. Calling the parent constructor
class Animal {
private final String name;
Animal(String name) {
this.name = name;
}
}
class Dog extends Animal {
private final String breed;
Dog(String name, String breed) {
super(name); // parent constructor runs FIRST — always the first line
this.breed = breed;
}
}
super(...) must be the first statement in the constructor. If the parent has no no-arg constructor, the compiler does not invent one for you — you must call super(...) explicitly, or the subclass won't compile. This ordering guarantee is important: parent state is fully initialized before the child's constructor runs.
2. Calling an overridden method
class Dog extends Animal {
@Override
void speak() {
super.speak(); // keep the parent behavior...
System.out.println("Woof"); // ...then add more
}
}
Use super.speak() when you want to extend behavior rather than replace it — logging wrappers, lifecycle hooks, and "do what the parent does, plus this" are the typical cases.
Dynamic dispatch: the method called depends on the object, not the reference
This is the single most important idea in the post. When you call an overridden method, Java decides at runtime which version to run based on the object's actual type, not the type of the reference variable pointing at it:
Animal pet = new Dog(); // reference type: Animal, object type: Dog
pet.speak(); // prints "Woof" — the DOG's version
Prove it to yourself with this snippet — one reference type, three object types:
class Animal {
void speak() { System.out.println("Some sound"); }
}
class Dog extends Animal {
@Override
void speak() { System.out.println("Woof"); }
}
class Cat extends Animal {
@Override
void speak() { System.out.println("Meow"); }
}
public class DispatchDemo {
public static void main(String[] args) {
Animal[] pets = { new Animal(), new Dog(), new Cat() };
for (Animal pet : pets) {
pet.speak(); // same call site, different method each iteration
}
}
}
Output:
Some sound
Woof
Meow
The call pet.speak() is written once, but it resolves to a different method per iteration. That is runtime polymorphism (dynamic dispatch): the JVM looks up the method on the object's class at the moment of the call. This is what makes polymorphic code reusable — write the loop once, and it works with any future Animal subclass you invent.
One nuance worth filing away: fields are not polymorphic. Only methods dispatch dynamically. (Also, static methods belong to the class, not the object — they bind at compile time and cannot be overridden in the dispatch sense. Modern Java tends to keep static for pure utility functions; behavior lives on instances.)
Abstract classes: shared state + an unfinished contract
An abstract class is a class you cannot instantiate directly. It earns its keep when two conditions hold at once:
- The children share state or implemented behavior (fields, concrete methods).
- Some behavior is genuinely unfinished — only each concrete subclass knows how to do it.
abstract class Alert {
private final String source; // shared state
private boolean acknowledged; // shared state
Alert(String source) {
this.source = source;
}
// Shared, concrete behavior: every alert gets this for free
public void acknowledge() {
acknowledged = true;
System.out.println("Acknowledged by " + source);
}
// Unfinished contract: each alert type decides how to deliver
public abstract void send(String message);
}
class EmailAlert extends Alert {
EmailAlert(String source) { super(source); }
@Override
public void send(String message) {
System.out.println("Emailing: " + message);
}
}
The template method pattern is abstract classes at their best: a concrete method in the parent defines the skeleton of an algorithm, calling abstract methods that subclasses fill in:
abstract class Alert {
// ...
public final void fire(String message) { // final: subclasses can't change the skeleton
logAttempt(message); // shared step
send(message); // subclass's step — dynamic dispatch picks it
recordDelivery(); // shared step
}
public abstract void send(String message);
private void logAttempt(String message) { /* ... */ }
private void recordDelivery() { /* ... */ }
}
fire() cannot be overridden (final), but send() resolves via dynamic dispatch. The parent owns the process; each child owns only its variation. That's the decision rule for abstract classes: reach for one when there's shared state plus a workflow skeleton with swappable steps. If there's no shared state — only behavior — an interface (post 6) is usually the cleaner tool.
Upcasting is safe; downcasting needs proof
Assigning a subclass object to a parent reference is called upcasting. It's implicit and always safe — a Dog genuinely is an Animal:
Animal pet = new Dog(); // upcast — implicit, always safe
The reverse — downcasting, treating an Animal reference as a Dog — can fail at runtime. If the object isn't actually a Dog, you get a ClassCastException. So never downcast blind; check with instanceof first. Modern Java (16+) gives you pattern-matching instanceof, which checks and casts in one step:
if (pet instanceof Dog d) {
// 'd' is already a Dog here — no explicit cast needed
d.fetch();
} else {
System.out.println("Not a dog");
}
Pattern matching declares the variable only when the check passes, which removes both the boilerplate cast and the risk of the check and cast drifting apart. Use this form by default; the old two-step instanceof-then-cast is legacy style.
Rule: if your code downcasts frequently, something is off in the design. Either the parent API is missing a method (push behavior up and let dynamic dispatch handle it), or you're modeling the wrong hierarchy.
The Liskov idea, in plain language
The Liskov Substitution Principle says: a subclass must be usable everywhere its parent is, without breaking the caller's expectations.
Concrete violation:
class Rectangle {
void setWidth(int w) { /* ... */ }
void setHeight(int h) { /* ... */ }
}
class Square extends Rectangle {
@Override
void setWidth(int w) {
super.setWidth(w);
super.setHeight(w); // keeps sides equal...
}
// ...but now code that sets width and height independently breaks
}
Code written against Rectangle assumes width and height are independent. Square silently changes that contract — so a Square cannot be safely used wherever a Rectangle is expected, even though "a square is a rectangle" sounds right in math. Real-world version: overriding a method to throw UnsupportedOperationException, or silently ignoring a parameter the parent honors.
Rule: an override must respect the parent's contract, not just its signature. If the subclass can't behave like the parent, don't inherit — use composition or a sibling class.
The trap: calling an overridable method from a constructor
This is the single most dangerous inheritance pattern in Java, and it compiles without a whisper:
class Animal {
Animal() {
speak(); // DANGER: calls the SUBCLASS's override
}
void speak() { System.out.println("Some sound"); }
}
class Dog extends Animal {
private String trick = "roll over"; // not yet assigned when super() runs
Dog() { super(); }
@Override
void speak() {
System.out.println("Woof, I can " + trick);
}
}
Run new Dog() and you get Woof, I can null — or worse, a NullPointerException if the method dereferences the field. Why? super() runs the parent constructor before the subclass's field initializers execute. Dynamic dispatch doesn't care that construction is in progress: speak() resolves to Dog.speak(), which reads trick before it's assigned.
Rule: never call a non-final, non-private method from a constructor. (Calling final or private methods is safe — they can't be overridden.) If a constructor needs shared setup logic, use a private or final helper.
The fragile base class problem — and why composition is the default instinct
Inheritance creates a subtle dependency: the subclass depends on implementation details of the parent, not just its public contract. If a library author changes how a parent method works internally — adds a call to another overridable method, changes iteration order — subclasses can break without touching a line of their own code. The parent's authors can't safely evolve the class without knowing every subclass. That's the fragile base class problem.
Composition sidesteps it: instead of inheriting behavior, the class holds a helper object and delegates to it. The helper's internals can change freely — the contract between the two is explicit:
// Instead of: class EmailAlert extends Alert { ... } for one reusable method...
class AlertService {
private final Notifier notifier; // composed, not inherited
AlertService(Notifier notifier) {
this.notifier = notifier;
}
void fire(String message) {
notifier.send(message); // delegates — no parent internals to depend on
}
}
With inheritance you get the parent's whole machinery, wanted or not; with composition you pick exactly the capabilities you need, and the "parent" can evolve without dragging you along.
Rule of thumb: prefer composition as your default instinct; use inheritance when you genuinely need a polymorphic is-a hierarchy that other code will treat interchangeably (the Animal/Dog loop above), plus shared state or a template-method skeleton. Deep inheritance chains (three, four levels) are almost always a smell — favor one shallow level or switch to composition. A deeper treatment of composition patterns lands in a later track.
Field checklist
- Use
extendsfor true is-a relationships where polymorphic use is the goal. - Annotate every override with
@Override— the compiler becomes your reviewer. super(...)first in the constructor;super.method()to extend rather than replace.- Dynamic dispatch: the object's runtime type picks the method. Write polymorphic call sites once; add subclasses freely.
- Abstract classes earn their keep with shared state + a template-method skeleton. No shared state? Wait for interfaces (post 6).
- Upcast implicitly; downcast only behind pattern-matching
instanceof. - Honor the parent's contract (Liskov) — an override must keep every promise the parent's signature makes.
- Never call overridable methods from a constructor.
- Default to composition; inherit when polymorphism plus shared machinery justifies it.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment