Java Polymorphism

Inheritance lets child classes override parent methods. Polymorphism takes this further. It lets you write code that works with a parent type but automatically uses the correct child behavior at runtime. This makes code flexible and extensible without constant modification.

The word polymorphism comes from Greek, meaning “many forms.” In Java, it means an object can be treated as its parent type while retaining its actual behavior.

The Core Concept

A variable of a parent type can hold a reference to any child object.

public class Animal {
    void makeSound() {
        System.out.println("Some sound");
    }
}

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

public class Cat extends Animal {
    @Override
    void makeSound() {
        System.out.println("Meow!");
    }
}
Animal myPet;

myPet = new Dog();
myPet.makeSound();  // Woof!

myPet = new Cat();
myPet.makeSound();  // Meow!

The variable myPet is declared as Animal, but it holds Dog and Cat objects. When you call makeSound(), Java runs the version defined by the actual object, not the declared type. The Dog barks. The Cat meows.

Why This Matters

Consider a method that makes any animal speak:

public static void makeAnimalSpeak(Animal animal) {
    animal.makeSound();
}

This single method works with any Animal subclass:

Dog dog = new Dog();
Cat cat = new Cat();

makeAnimalSpeak(dog);  // Woof!
makeAnimalSpeak(cat);  // Meow!

Without polymorphism, you’d need separate methods: makeDogSpeak(), makeCatSpeak(), and so on. Adding a Bird class would require another method. With polymorphism, the existing method handles any new Animal subclass automatically.

Arrays and Collections of Parent Types

Polymorphism shines when working with collections.

Animal[] animals = new Animal[3];
animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Dog();

for (Animal animal : animals) {
    animal.makeSound();
}

Output:

Woof!
Meow!
Woof!

One loop handles all animal types. Each object responds with its own behavior.

The same works with ArrayList:

ArrayList<Animal> pets = new ArrayList<>();
pets.add(new Dog());
pets.add(new Cat());
pets.add(new Cat());

for (Animal pet : pets) {
    pet.makeSound();
}

Compile Time vs Runtime

The compiler only knows the declared type. It checks that the method exists on that type.

Animal myPet = new Dog();
myPet.makeSound();  // Works: Animal has makeSound()
// myPet.bark();    // Error: Animal doesn't have bark()

Even though myPet actually holds a Dog, the compiler only sees Animal. You can call makeSound() because Animal defines it. You can’t call bark() because Animal doesn’t have that method.

At runtime, Java looks at the actual object and calls the appropriate version. This is called dynamic dispatch or late binding.

Casting

Sometimes you need to access child-specific methods. Casting tells the compiler to treat an object as a more specific type.

Animal myPet = new Dog();

// Cast to access Dog-specific method
Dog myDog = (Dog) myPet;
myDog.bark();  // Now we can call bark()

Casting is risky. If the object isn’t actually that type, you get a ClassCastException:

Animal myPet = new Cat();
Dog myDog = (Dog) myPet;  // ClassCastException! It's a Cat, not a Dog

The instanceof Operator

Check an object’s type before casting with instanceof:

Animal myPet = new Dog();

if (myPet instanceof Dog) {
    Dog myDog = (Dog) myPet;
    myDog.bark();
}

if (myPet instanceof Cat) {
    Cat myCat = (Cat) myPet;
    myCat.meow();
}

The instanceof check prevents ClassCastException by verifying the type first.

Java 16 introduced pattern matching to simplify this:

if (myPet instanceof Dog dog) {
    dog.bark();  // dog is already cast
}

The variable dog is automatically cast if the check passes.

Polymorphism in Practice

Here’s a practical example with a payment system:

public class Payment {
    double amount;
    
    public Payment(double amount) {
        this.amount = amount;
    }
    
    public void processPayment() {
        System.out.println("Processing payment of $" + amount);
    }
    
    public String getReceipt() {
        return "Payment: $" + amount;
    }
}

public class CreditCardPayment extends Payment {
    String cardNumber;
    
    public CreditCardPayment(double amount, String cardNumber) {
        super(amount);
        this.cardNumber = cardNumber;
    }
    
    @Override
    public void processPayment() {
        String lastFour = cardNumber.substring(cardNumber.length() - 4);
        System.out.println("Charging $" + amount + " to card ending in " + lastFour);
    }
    
    @Override
    public String getReceipt() {
        return "Credit Card Payment: $" + amount;
    }
}

public class PayPalPayment extends Payment {
    String email;
    
    public PayPalPayment(double amount, String email) {
        super(amount);
        this.email = email;
    }
    
    @Override
    public void processPayment() {
        System.out.println("Sending $" + amount + " via PayPal to " + email);
    }
    
    @Override
    public String getReceipt() {
        return "PayPal Payment: $" + amount + " (" + email + ")";
    }
}

public class CashPayment extends Payment {
    public CashPayment(double amount) {
        super(amount);
    }
    
    @Override
    public void processPayment() {
        System.out.println("Received $" + amount + " in cash");
    }
    
    @Override
    public String getReceipt() {
        return "Cash Payment: $" + amount;
    }
}

A checkout system handles all payment types uniformly:

import java.util.ArrayList;

public class Checkout {
    public static void main(String[] args) {
        ArrayList<Payment> payments = new ArrayList<>();
        
        payments.add(new CreditCardPayment(99.99, "4111111111111234"));
        payments.add(new PayPalPayment(49.50, "user@email.com"));
        payments.add(new CashPayment(25.00));
        payments.add(new CreditCardPayment(199.99, "5500000000005678"));
        
        double total = 0;
        
        System.out.println("Processing payments...\n");
        
        for (Payment payment : payments) {
            payment.processPayment();
            total += payment.amount;
        }
        
        System.out.println("\n--- Receipts ---");
        for (Payment payment : payments) {
            System.out.println(payment.getReceipt());
        }
        
        System.out.println("\nTotal collected: $" + total);
    }
}

Output:

Processing payments...

Charging $99.99 to card ending in 1234
Sending $49.5 via PayPal to user@email.com
Received $25.0 in cash
Charging $199.99 to card ending in 5678

--- Receipts ---
Credit Card Payment: $99.99
PayPal Payment: $49.5 (user@email.com)
Cash Payment: $25.0
Credit Card Payment: $199.99

Total collected: $374.48

Adding a new payment type (CryptoPayment, BankTransfer) requires no changes to the Checkout class. Create the new class extending Payment, override the methods, and it works with existing code.

Method Overloading vs Overriding

Don’t confuse these two concepts:

Overloading: Same method name, different parameters. Happens in the same class. Resolved at compile time.

public class Calculator {
    int add(int a, int b) { return a + b; }
    double add(double a, double b) { return a + b; }
    int add(int a, int b, int c) { return a + b + c; }
}

Overriding: Same method name and parameters. Happens in child class. Resolved at runtime (polymorphism).

public class Animal {
    void makeSound() { System.out.println("Sound"); }
}

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

Benefits of Polymorphism

Extensibility. Add new classes without modifying existing code. The payment example shows this clearly.

Flexibility. Write methods and collections that work with any subtype.

Maintainability. Changes to child behavior don’t require changes to code using the parent type.

Reduced conditionals. Instead of checking types with if statements, let polymorphism dispatch to the right behavior.

Common Mistakes

Casting without checking. Always use instanceof before casting, or use pattern matching.

Overusing instanceof. If you constantly check types and cast, you might be fighting polymorphism instead of using it. Reconsider your design.

Expecting static methods to be polymorphic. Static methods belong to the class, not instances. They’re not overridden in the polymorphic sense.

public class Parent {
    static void staticMethod() { System.out.println("Parent"); }
}

public class Child extends Parent {
    static void staticMethod() { System.out.println("Child"); }
}

Parent p = new Child();
p.staticMethod();  // Prints "Parent", not "Child"

Confusing the declared type with actual type. The declared type determines what methods you can call. The actual type determines which implementation runs.

What’s Next

Polymorphism works through inheritance hierarchies. Encapsulation protects object data by controlling access to fields. The next tutorial covers access modifiers and how to hide implementation details while exposing clean public interfaces.


Related: Java Inheritance | Java Encapsulation

Sources

  • Oracle. “Polymorphism.” The Java Tutorials. docs.oracle.com
  • Bloch, Joshua. “Effective Java.” 3rd Edition. Addison-Wesley, 2018.
Scroll to Top