Java Inheritance and Polymorphism
Use extends, super, and method overriding without painting yourself into a corner. When to inherit, when to compose, and how sealed types help.
What you'll learn
- ✓Extend a class and override methods correctly
- ✓Use super to call parent behavior
- ✓Apply final, abstract, and sealed to control hierarchies
- ✓Recognize when composition beats inheritance
- ✓Understand dynamic dispatch and covariant returns
Prerequisites
Inheritance is the most overused tool in Java. Used well, it expresses “is a” relationships and lets one method work with many concrete types. Used badly, it couples your code to a hierarchy you cannot evolve. This article shows the mechanics and the discipline.
extends
A subclass declares its parent with extends. It inherits all non private members.
public class Animal {
protected final String name;
public Animal(String name) {
this.name = name;
}
public String speak() {
return name + " makes a sound";
}
}
public class Dog extends Animal {
public Dog(String name) {
super(name);
}
@Override
public String speak() {
return name + " barks";
}
}
super(name) calls the parent constructor. It must be the first statement of the child constructor. If you do not call it, Java inserts a super() for you, which fails to compile if the parent has no no-arg constructor.
Polymorphism in action
A reference to Animal can hold any subclass instance. The actual method called is decided at runtime based on the object, not the reference type. That is dynamic dispatch.
Animal a = new Dog("Rex");
System.out.println(a.speak()); // Rex barks
This is what lets a single method handle many shapes:
void announce(List<Animal> animals) {
for (Animal a : animals) {
System.out.println(a.speak());
}
}
Pass a list of dogs, cats, and parrots; each prints its own version.
Override rules
The @Override annotation is optional but always worth using. It tells the compiler “I intend to override a parent method”, and you get an error if the signature does not actually match.
Override rules:
- Same name and parameter types.
- Return type can be the same or a subtype (covariant return).
- Visibility cannot shrink (cannot override
publicwithprotected). - Cannot throw new checked exceptions broader than the parent declared.
class Parent {
public Number getValue() { return 1; }
}
class Child extends Parent {
@Override
public Integer getValue() { return 42; } // covariant return
}
super for partial reuse
Call the parent implementation when you want to extend, not replace, its behavior.
class LoggingDog extends Dog {
public LoggingDog(String name) {
super(name);
}
@Override
public String speak() {
String s = super.speak();
System.out.println("[log] " + s);
return s;
}
}
abstract classes
Declare a class abstract when it should never be instantiated on its own, only extended. Abstract methods have no body; subclasses must implement them.
public abstract class Shape {
public abstract double area();
public String describe() {
return "Area is " + area();
}
}
public class Circle extends Shape {
private final double r;
public Circle(double r) { this.r = r; }
@Override
public double area() {
return Math.PI * r * r;
}
}
describe is shared concrete behavior. area is the hole each subclass fills.
final to close the door
Mark a class final to prevent further extension. Mark a method final to prevent subclasses from overriding it.
public final class ImmutablePoint { /* ... */ }
This is a design statement: “Do not extend this; if you need different behavior, compose.” String, Integer, and LocalDate are all final.
sealed: a controlled hierarchy
Java 17 introduced sealed classes, which let you list exactly who can extend a type.
public sealed interface Vehicle permits Car, Truck, Motorcycle {}
public final class Car implements Vehicle {}
public final class Truck implements Vehicle {}
public non-sealed class Motorcycle implements Vehicle {}
Combined with pattern matching switch, this gives you Kotlin or Rust style closed sums with full exhaustiveness checking. See the control flow article for an example.
Object: the root of all classes
Every class implicitly extends java.lang.Object. That is where equals, hashCode, toString, and getClass come from. Override them when your class represents a value (see Java Classes and Objects).
Composition over inheritance
A classic rule: prefer composition. Instead of saying “a Stack is a List” (then inheriting all of List’s API, including methods that break the stack invariant), say “a Stack has a List”.
public class Stack<E> {
private final List<E> items = new ArrayList<>();
public void push(E e) { items.add(e); }
public E pop() { return items.remove(items.size() - 1); }
public int size() { return items.size(); }
}
The stack only exposes stack operations. Callers cannot reach in and call remove(0) to corrupt it.
Use inheritance when you genuinely have an “is a” relationship and you control both classes. Use composition everywhere else.
The fragile base class problem
When you inherit, you depend on the parent’s implementation details. If the parent later calls one of its own methods in a different order, your override might break in subtle ways. This is why frameworks usually expose abstract classes with a stable, narrow set of template methods rather than inviting you to override anything.
instanceof and casting
Down casting is sometimes necessary at the edges. Use the pattern form to avoid a manual cast:
Animal a = getAnimal();
if (a instanceof Dog d) {
d.fetch();
}
If you find yourself writing long chains of instanceof, model the hierarchy as a sealed type and switch on it instead.
Wrap up
Inheritance gives you polymorphism, but it also binds you to a base class forever. Reach for sealed plus pattern matching when you can, and lean on composition when you cannot. Up next is the cleaner half of the picture: Java Interfaces vs Abstract Classes.