Skip to main content
Java beginner Lesson 15 of 58

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

TypeMechanismResolved
Runtime polymorphismMethod overriding + @OverrideAt runtime (dynamic dispatch)
Compile-time polymorphismMethod overloadingAt compile time
Interface polymorphismimplementsAt runtime

Frequently Asked Questions

What is the difference between method overriding and method overloading?
Overriding replaces a parent class method in a subclass (same name, same signature, different class). Overloading defines multiple methods with the same name but different parameter lists in the same class. Overriding is runtime polymorphism; overloading is compile-time polymorphism.
What is dynamic dispatch?
Dynamic dispatch is Java's mechanism for choosing which overridden method to call at runtime based on the actual object type, not the declared reference type. It is what makes polymorphism work.
Can static methods be polymorphic?
No. Static methods are resolved at compile time based on the reference type, not the object type. Only instance methods participate in dynamic dispatch.