Polymorphism in Java
Understand runtime and compile-time polymorphism in Java — method overriding, dynamic dispatch, and the power of programming to interfaces.
Polymorphism means one interface, many implementations. A single variable or method can work with objects of different types, and Java automatically calls the right behaviour for each. This is what allows you to write generic code that works correctly without knowing the concrete type at compile time — and it is the mechanism that makes large codebases extensible without constant rewrites.
Runtime Polymorphism — Method Overriding
When a subclass overrides a parent method, Java chooses which version to call based on the actual object type at runtime, not the declared reference type. This is called dynamic dispatch. The power it gives you is enormous: you can write one loop that handles a dozen different types correctly, just by designing a shared interface.
public class Shape {
public double area() { return 0; }
public double perimeter() { return 0; }
public void printInfo() {
// Calls the correct area() and perimeter() for whichever subtype this actually is
System.out.printf("%s: area=%.2f, perimeter=%.2f%n",
getClass().getSimpleName(), area(), perimeter());
}
}
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 double perimeter() { return 2 * Math.PI * radius; }
}
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 double perimeter() { return 2 * (w + h); }
}
public class Triangle extends Shape {
private final double a, b, c;
public Triangle(double a, double b, double c) {
this.a = a; this.b = b; this.c = c;
}
@Override public double area() {
double s = (a + b + c) / 2;
return Math.sqrt(s * (s - a) * (s - b) * (s - c)); // Heron's formula
}
@Override public double perimeter() { return a + b + c; }
}
Using polymorphism — one loop handles all shapes without a single instanceof check:
List<Shape> shapes = List.of(
new Circle(5),
new Rectangle(4, 6),
new Triangle(3, 4, 5)
);
for (Shape s : shapes) {
s.printInfo(); // Java calls the correct override each time — no if/else needed
}
// Circle: area=78.54, perimeter=31.42
// Rectangle: area=24.00, perimeter=20.00
// Triangle: area=6.00, perimeter=12.00
Programming to a Supertype
Declare variables using a parent type or interface rather than the concrete type. This decouples your code from specific implementations — you can swap one concrete type for another without changing any of the calling code, as long as both honour the same contract.
// BAD — coupled to a specific implementation, hard to swap
Circle c = new Circle(5);
// GOOD — works with any Shape, swap freely
Shape s = new Circle(5);
s = new Rectangle(4, 6); // reassign to any Shape without changing the code that uses s
Polymorphic Methods
Write methods that accept a supertype so they work with any current or future subclass. This is the key to writing code that does not need to change every time a new type is added.
public class ShapeAnalyzer {
// Works with Circle, Rectangle, Triangle — and any new Shape added in the future
public double totalArea(List<Shape> shapes) {
double total = 0;
for (Shape s : shapes) total += s.area();
return total;
}
public Shape largest(List<Shape> shapes) {
return shapes.stream()
.max(Comparator.comparingDouble(Shape::area))
.orElseThrow();
}
}
ShapeAnalyzer analyzer = new ShapeAnalyzer();
List<Shape> shapes = List.of(new Circle(3), new Rectangle(10, 2), new Triangle(5, 5, 5));
System.out.printf("Total area: %.2f%n", analyzer.totalArea(shapes)); // Total area: 49.71
System.out.println("Largest: " + analyzer.largest(shapes).getClass().getSimpleName()); // Rectangle
Interface Polymorphism
Interfaces are the most flexible form of polymorphism because there is no inheritance constraint — any unrelated class can implement the same interface. This lets you write code that works with a Drawable without caring whether the object is a Canvas, a Button, or a Sprite.
public interface Drawable {
void draw();
default String getLayer() { return "default"; }
}
public interface Resizable {
void resize(double factor);
}
// A class can implement multiple interfaces — gets capabilities from both
public class Canvas implements Drawable, Resizable {
private double size;
public Canvas(double size) { this.size = size; }
@Override public void draw() { System.out.println("Drawing canvas at size " + size); }
@Override public void resize(double factor){ size *= factor; }
}
// Work with Drawable without knowing the concrete type
Drawable d = new Canvas(100);
d.draw(); // Drawing canvas at size 100.0
Compile-Time Polymorphism — Method Overloading
Overloading lets you define multiple methods with the same name but different parameter types or counts. The compiler resolves which version to call at compile time based on the argument types. This gives you a clean API where callers use one intuitive name for related operations rather than remembering different names for each variant.
public class Printer {
public void print(String text) {
System.out.println("[STRING] " + text);
}
public void print(int number) {
System.out.println("[INT] " + number);
}
public void print(String text, int copies) {
// Print the same text multiple times
for (int i = 0; i < copies; i++) System.out.println(text);
}
public void print(double number) {
System.out.printf("[DOUBLE] %.2f%n", number);
}
}
Printer printer = new Printer();
printer.print("Hello"); // [STRING] Hello
printer.print(42); // [INT] 42
printer.print(3.14); // [DOUBLE] 3.14
printer.print("Hi", 3); // Hi / Hi / Hi
instanceof and Pattern Matching
Sometimes you genuinely need to know the concrete type at runtime — for example, when integrating with a system that passes a heterogeneous Object. Java 16+ pattern matching for instanceof combines the type check and cast into one concise step, eliminating the redundant explicit cast.
public class PaymentProcessor {
public void process(Object payment) {
// Pattern matching: check type AND bind to typed variable in one expression
if (payment instanceof CreditCard card) {
System.out.println("Charging credit card: " + card.getLast4Digits());
} else if (payment instanceof BankTransfer transfer) {
System.out.println("Initiating transfer from: " + transfer.getAccountNumber());
} else if (payment instanceof Crypto crypto) {
System.out.println("Sending " + crypto.getAmount() + " " + crypto.getCoin());
} else {
throw new IllegalArgumentException("Unknown payment type.");
}
}
}
Covariant Return Types
An overriding method can return a more specific type than the parent method declares — this is called a covariant return type. It allows subclasses to be more precise about what they return without breaking any code that holds the object through a parent reference.
public class Animal {
public Animal create() {
return new Animal();
}
}
public class Dog extends Animal {
@Override
public Dog create() { // Dog is a subtype of Animal — narrowing the return type is allowed
return new Dog();
}
}
Summary
| Type | Mechanism | Resolved |
|---|---|---|
| Runtime polymorphism | Method overriding + @Override | At runtime (dynamic dispatch) |
| Compile-time polymorphism | Method overloading | At compile time |
| Interface polymorphism | implements | At runtime |