Polymorphism

Polymorphism ("many forms") means code written for a general type works with any of its subtypes, and each object responds in its own way. A list of Shapes can hold circles, squares and triangles; calling area() on each runs the right calculation. It's what makes object-oriented code extensible.

One variable type, many object types

JAVA
class Notification {
    String send(String message) {
        return "Generic: " + message;
    }
}

class EmailNotification extends Notification {
    @Override
    String send(String message) {
        return "Email: " + message;
    }
}

class SmsNotification extends Notification {
    @Override
    String send(String message) {
        return "SMS: " + message.substring(0, Math.min(20, message.length()));
    }
}

public class Main {
    public static void main(String[] args) {
        Notification[] channels = {new EmailNotification(), new SmsNotification(), new Notification()};
        for (Notification n : channels) {
            System.out.println(n.send("Your order has shipped today"));
        }
    }
}
Output
Email: Your order has shipped today
SMS: Your order has shipp
Generic: Your order has shipped today

The array's type is Notification, but each element is a specific subclass. When send is called, Java looks at the actual object at run time and runs its version. This is dynamic dispatch. Adding a new channel needs a new class, not changes to this loop.

Static type vs runtime type

JAVA
class Animal {
    void speak() { System.out.println("..."); }
}

class Cat extends Animal {
    @Override void speak() { System.out.println("Meow"); }
    void purr() { System.out.println("Purr"); }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Cat();
        a.speak();
        a.purr();
    }
}
Compiler error
Main.java:14: error: cannot find symbol

The declared type (Animal) decides which methods you may call; the runtime type (Cat) decides which implementation runs. a.speak() prints "Meow", but a.purr() doesn't compile, because not every Animal can purr.

Upcasting and downcasting

JAVA
class Animal {
    void speak() { System.out.println("..."); }
}

class Cat extends Animal {
    @Override void speak() { System.out.println("Meow"); }
    void purr() { System.out.println("Purr"); }
}

class Dog extends Animal {
    @Override void speak() { System.out.println("Woof"); }
}

public class Main {
    public static void main(String[] args) {
        Animal[] pets = {new Cat(), new Dog()};
        for (Animal pet : pets) {
            pet.speak();
            if (pet instanceof Cat cat) {
                cat.purr();
            }
        }
    }
}
Output
Meow
Purr
Woof

Assigning a Cat to an Animal variable (upcasting) is automatic. Going back down needs a check: pet instanceof Cat cat tests the type and, if it matches, gives you a variable of the narrower type (pattern matching, Java 16+). A plain cast (Cat) pet on a Dog would throw ClassCastException.

Design tip: lots of instanceof checks often mean behaviour should be a method on the classes instead. Sealed types and pattern-matching switches (lesson 49) are the modern exception, when you want to handle a fixed set of types in one place.

Polymorphic parameters

JAVA
class Shape {
    double area() { return 0; }
}

class Circle extends Shape {
    double r;
    Circle(double r) { this.r = r; }
    @Override double area() { return Math.PI * r * r; }
}

class Rect extends Shape {
    double w, h;
    Rect(double w, double h) { this.w = w; this.h = h; }
    @Override double area() { return w * h; }
}

public class Main {
    static double totalArea(Shape... shapes) {
        double sum = 0;
        for (Shape s : shapes) sum += s.area();
        return sum;
    }

    public static void main(String[] args) {
        System.out.println("%.2f".formatted(totalArea(new Circle(1), new Rect(2, 3), new Rect(1, 1))));
    }
}
Output
10.14

totalArea works with every current and future kind of Shape. Writing methods against the most general type that offers what you need is a key principle of flexible code.

Overloading is not polymorphism

JAVA
class Animal {}

class Cat extends Animal {}

public class Main {
    static String identify(Animal a) { return "an animal"; }

    static String identify(Cat c) { return "a cat"; }

    public static void main(String[] args) {
        Animal pet = new Cat();
        System.out.println(identify(pet));
        System.out.println(identify(new Cat()));
    }
}
Output
an animal
a cat

Overloads are chosen at compile time from the declared type, so identify(pet) picks the Animal version even though the object is a cat. Only overridden methods dispatch on the runtime type.

Try it yourself

Calculate pay for different kinds of employees through one loop.

Try it yourself · Main.java
Edit the code, copy it into Main.java and run java Main.java (Java 25+). How to set up Java
Output
Mei      8166.67
Lucas    2220.00