Subtype Polymorphism
What Is Subtype Polymorphism?
Subtype polymorphism (also called inclusion polymorphism or dynamic polymorphism) is the ability for code written against a supertype to operate uniformly over any of its subtypes, with the actual behaviour determined by the object’s true runtime type.
The word “polymorphism” comes from Greek: poly (many) + morph (form). A variable of type T can take on many forms — any object whose class is a subtype of T.
The Mechanism
Two things must happen for subtype polymorphism to work:
- Compile-time check: the compiler verifies that the method being called exists on the declared type of the reference. If it does not, the code does not compile.
- Runtime dispatch: the JVM finds the correct implementation to execute by looking at the runtime type of the object, using its virtual method table (vtable).
This is why you can write:
List<Shape> shapes = Arrays.asList(new Circle(5), new Square(3), new Triangle(4, 6));
for (Shape s : shapes) {
System.out.println(s.area());
}
The loop body is written against Shape. The compiler checks that area() exists on Shape (it does). At runtime, s.area() dispatches to Circle.area(), Square.area(), and Triangle.area() respectively — each shape computes its own area.
Reference Type vs Object Type
Every expression in Java has two types:
| Type | Determines | Set by |
|---|---|---|
| Declared type (compile-time type, static type) | Which methods can be called | The type written in the variable declaration |
| Runtime type (dynamic type, actual type) | Which implementation runs | The class passed to new when the object was created |
Object o = new String("hello");
o.toString(); // OK — toString() is on Object (declared type allows it)
o.length(); // Compile error — length() is on String, not Object (declared type forbids it)
The declared type is an over-approximation of the possible runtime types. The runtime type is always a subtype (same or more specific) of the declared type.
The Open-Closed Principle Connection
Subtype polymorphism is the primary mechanism OOP provides for satisfying the Open-Closed Principle (OCP). OCP states that code should be open for extension but closed for modification. With subtype polymorphism:
- A method written against a supertype (
Shape) is closed for modification — you do not need to edit it when new shapes are added. - But it is open for extension — you can add a new
Hexagonclass and pass it to the same method, and it works without changing the method.
void printAreas(Shape[] shapes) {
for (Shape s : shapes) {
System.out.println(s.area());
}
}
This method will work with any new Shape subclass — present or future — as long as it correctly implements area(). No instanceof checks, no switch statements, no modification required.
The Violation Pattern
The OCP violation that polymorphism fixes:
void printAreas(Object[] shapes) {
for (Object o : shapes) {
if (o instanceof Circle c) {
System.out.println(Math.PI * c.radius * c.radius);
} else if (o instanceof Square s) {
System.out.println(s.side * s.side);
} else if (o instanceof Triangle t) {
System.out.println(0.5 * t.base * t.height);
}
// MUST BE EDITED every time a new shape is added
}
}
Every new shape requires finding and editing this method (and every similar method). This is a maintenance disaster and a bug magnet.
Polymorphism Through Arrays and Collections
Polymorphism works with arrays and generics. The key point: an array of a supertype can hold objects of any subtype:
Animal[] zoo = new Animal[3];
zoo[0] = new Dog();
zoo[1] = new Cat();
zoo[2] = new Bird();
for (Animal a : zoo) {
a.speak(); // dynamic dispatch: each animal speaks in its own way
}
With generics, use the bounded wildcard or interface type:
List<Animal> zoo = new ArrayList<>();
zoo.add(new Dog());
zoo.add(new Cat());
Polymorphism and Method Parameters
A method parameter typed to a supertype accepts any subtype:
void feed(Animal a) {
a.eat();
}
feed(new Dog());
feed(new Cat());
feed(new Bird());
This is the fundamental technique for writing generic, extensible code. The feed method does not know or care what specific animal it receives — it only cares that the animal can eat().
The Cost: Runtime Overhead
Dynamic dispatch adds a small runtime overhead compared to a direct function call. The JVM must:
- Follow the reference to the object.
- Read the object’s class pointer.
- Look up the method in the class’s vtable.
- Jump to the method implementation.
Modern JVMs mitigate this cost with inline caching (remembering the most recent target type and inlining the common case) and speculative optimisation. In practice, the overhead is negligible for most applications, and the design benefits of polymorphism far outweigh the cost.