Polymorphism
Polymorphism ("many forms") is the ability of one piece of code to work with objects of different types, with each object responding in its own way. A method that accepts a Shape can be given a Circle, a Rectangle, or a Triangle; calling area() runs the correct calculation for each. Polymorphism is what makes object-oriented programs extensible: new types can be added without changing the code that uses them.
A First Example
public abstract class Notification {
protected final String recipient;
protected Notification(String recipient) {
this.recipient = recipient;
}
public abstract void send(String message);
}
public class EmailNotification extends Notification {
public EmailNotification(String address) { super(address); }
@Override
public void send(String message) {
System.out.println("Email to " + recipient + ": " + message);
}
}
public class SmsNotification extends Notification {
public SmsNotification(String phone) { super(phone); }
@Override
public void send(String message) {
String shortMsg = message.length() > 40 ? message.substring(0, 37) + "..." : message;
System.out.println("SMS to " + recipient + ": " + shortMsg);
}
}
List<Notification> channels = List.of(
new EmailNotification("ada@example.com"),
new SmsNotification("+919000000000")
);
for (Notification n : channels) {
n.send("Your Java course certificate is ready to download.");
}
Output:
Email to ada@example.com: Your Java course certificate is ready to download.
SMS to +919000000000: Your Java course certificate is ready...
The loop calls n.send(...) on a variable of type Notification and never checks which kind it is. Each object runs its own send: the email version sends the full text, while the SMS version keeps the first 37 characters and adds an ellipsis. Adding a WhatsAppNotification class later requires no change to the loop.
Declared Type Versus Actual Type
Every reference involves two types:
Notification n = new SmsNotification("+919000000000");
// declared type ▲ ▲ actual (runtime) type
| Determined | Controls | |
|---|---|---|
Declared type (Notification) | At compile time, from the variable's declaration | Which methods you are allowed to call |
Actual type (SmsNotification) | At runtime, from the object that was created | Which implementation of an overridden method runs |
Notification n = new SmsNotification("+919000000000");
n.send("Hi"); // allowed (Notification declares send); runs SmsNotification.send
n.shorten("Hi"); // compile error, even if SmsNotification had such a method
The compiler only knows that n is some Notification, so it only allows calls that every Notification supports. At runtime, the JVM looks at the real object and dispatches to its override. This runtime selection is called dynamic dispatch or late binding.
Runtime Polymorphism: Method Overriding
Runtime polymorphism comes from overriding: subclasses provide their own implementations of methods declared in a superclass or interface.
public class Shape {
public double area() { return 0; }
}
public class Circle extends Shape {
private final double r;
public Circle(double r) { this.r = r; }
@Override public double area() { return Math.PI * r * r; }
}
public class Square extends Shape {
private final double side;
public Square(double side) { this.side = side; }
@Override public double area() { return side * side; }
}
static double totalArea(List<Shape> shapes) {
double sum = 0;
for (Shape s : shapes) {
sum += s.area(); // which area()? decided per object at runtime
}
return sum;
}
System.out.printf("%.2f%n", totalArea(List.of(new Circle(1), new Square(2)))); // 7.14
totalArea is written once against Shape. It handles every present and future subclass correctly.
What is and is not polymorphic
Only instance methods are dispatched dynamically. Other members are resolved from the declared type:
| Member | Resolved by |
|---|---|
| Overridden instance methods | Actual (runtime) type |
| Fields | Declared type |
static methods | Declared type |
private methods | Not inherited, so never overridden |
class Parent {
String label = "parent";
static String kind() { return "Parent.kind"; }
String name() { return "Parent.name"; }
}
class Child extends Parent {
String label = "child"; // hides the field, does not override it
static String kind() { return "Child.kind"; }
@Override String name() { return "Child.name"; }
}
Parent p = new Child();
System.out.println(p.label); // parent (field: declared type)
System.out.println(p.kind()); // Parent.kind (static: declared type)
System.out.println(p.name()); // Child.name (instance method: runtime type)
This is one more reason to keep fields private and access them through methods: methods behave polymorphically, fields do not.
Polymorphism Through Interfaces
Interfaces provide polymorphism without requiring a common parent class. Any class can implement an interface, regardless of its position in a class hierarchy:
public interface Discount {
double apply(double amount);
}
public class PercentageDiscount implements Discount {
private final double percent;
public PercentageDiscount(double percent) { this.percent = percent; }
public double apply(double amount) { return amount * (1 - percent / 100); }
}
public class FlatDiscount implements Discount {
private final double off;
public FlatDiscount(double off) { this.off = off; }
public double apply(double amount) { return Math.max(0, amount - off); }
}
public class NoDiscount implements Discount {
public double apply(double amount) { return amount; }
}
static double checkout(double cartTotal, Discount discount) {
return discount.apply(cartTotal);
}
System.out.println(checkout(2000, new PercentageDiscount(10))); // 1800.0
System.out.println(checkout(2000, new FlatDiscount(250))); // 1750.0
System.out.println(checkout(2000, new NoDiscount())); // 2000.0
checkout depends only on the Discount interface. The concrete discount can be chosen at runtime (from a coupon code, a promotion calendar, or a database), and a new kind of discount is just another class. Because Discount has a single abstract method, a lambda can implement it too:
System.out.println(checkout(2000, amount -> amount * 0.5)); // 1000.0
Programming against interfaces like this ("program to an interface, not an implementation") is the foundation of testable, loosely coupled designs. In tests, a fake implementation can replace a real payment gateway or email sender without changing the code under test.
Compile-Time Polymorphism: Method Overloading
Method overloading (several methods with the same name but different parameter lists) is sometimes called compile-time or static polymorphism:
public class Printer {
void print(int value) { System.out.println("int: " + value); }
void print(double value) { System.out.println("double: " + value); }
void print(String value) { System.out.println("String: " + value); }
}
Printer pr = new Printer();
pr.print(5); // int: 5
pr.print(5.0); // double: 5.0
pr.print("five"); // String: five
The compiler chooses the overload from the declared types of the arguments, before the program runs. This differs from overriding:
void handle(Object o) { System.out.println("Object"); }
void handle(String s) { System.out.println("String"); }
Object x = "hello";
handle(x); // prints "Object": chosen from the declared type of x
Even though x refers to a String at runtime, the overload was fixed at compile time from the type Object. Overloading and overriding each have their own lessons.
| Overloading | Overriding | |
|---|---|---|
| Also called | Compile-time / static polymorphism | Runtime / dynamic polymorphism |
| Methods involved | Same name, different parameters, usually in one class | Same signature, in parent and child classes |
| Chosen by | Declared argument types, at compile time | Actual object type, at runtime |
Upcasting and Downcasting
Assigning a subclass object to a superclass variable is upcasting. It is always safe and automatic:
Shape s = new Circle(3); // upcast
Getting the specific type back is downcasting. It requires an explicit cast and can fail at runtime:
Circle c = (Circle) s; // fine: s really is a Circle
Square q = (Square) s; // ClassCastException at runtime
Pattern matching with instanceof tests and casts safely in one step:
if (s instanceof Circle circle) {
System.out.println("Radius-based shape with area " + circle.area());
}
Frequent downcasting is a sign that polymorphism is not being used. If code repeatedly asks "which subclass is this?" and then branches, the behavior usually belongs in an overridden method instead:
// Fragile: must be updated for every new shape
if (s instanceof Circle c) { ... } else if (s instanceof Square q) { ... }
// Polymorphic: each shape knows how to do it
s.area();
One modern exception is sealed hierarchies with pattern-matching switch, where the compiler checks that every permitted subtype is handled. That approach suits data-like types whose set of variants is fixed and known.
Benefits in Real Systems
- Extensibility. New types plug into existing code. A payment system written against a
PaymentMethodinterface supports a new provider by adding one class. - Reduced conditionals. Replacing long
if/switchchains on a "type" field with overridden methods keeps each variant's logic in its own class. - Testability. Code that depends on interfaces can be tested with fake implementations.
- Frameworks. Java frameworks rely on polymorphism everywhere:
Listis implemented byArrayListandLinkedList; Spring injects implementations of interfaces; servlets overridedoGet; JUnit runs any class following its conventions.
Practical Example: Payment Processing
import java.util.List;
public class Checkout {
interface PaymentMethod {
String name();
double fee(double amount);
boolean pay(double amount);
}
static class Card implements PaymentMethod {
public String name() { return "Card"; }
public double fee(double amount) { return amount * 0.02; }
public boolean pay(double amount) {
System.out.printf(" Charging card %.2f%n", amount);
return true;
}
}
static class Upi implements PaymentMethod {
public String name() { return "UPI"; }
public double fee(double amount) { return 0; }
public boolean pay(double amount) {
System.out.printf(" Sending UPI request for %.2f%n", amount);
return true;
}
}
static class Wallet implements PaymentMethod {
private double balance;
Wallet(double balance) { this.balance = balance; }
public String name() { return "Wallet"; }
public double fee(double amount) { return 5; }
public boolean pay(double amount) {
if (amount > balance) {
System.out.println(" Wallet balance too low");
return false;
}
balance -= amount;
System.out.printf(" Wallet debited %.2f, left %.2f%n", amount, balance);
return true;
}
}
static boolean process(double orderTotal, PaymentMethod method) {
double payable = orderTotal + method.fee(orderTotal);
System.out.printf("%s: payable %.2f%n", method.name(), payable);
return method.pay(payable);
}
public static void main(String[] args) {
List<PaymentMethod> methods = List.of(new Card(), new Upi(), new Wallet(1000));
for (PaymentMethod m : methods) {
boolean ok = process(1200, m);
System.out.println(" Result: " + (ok ? "success" : "failed"));
}
}
}
Output:
Card: payable 1224.00
Charging card 1224.00
Result: success
UPI: payable 1200.00
Sending UPI request for 1200.00
Result: success
Wallet: payable 1205.00
Wallet balance too low
Result: failed
process is written once against the PaymentMethod interface. It asks each method for its name and fee and then calls pay, and each implementation supplies its own rules: a percentage fee for cards, no fee for UPI, a flat fee and a balance check for the wallet. Supporting a new payment provider means writing one more class that implements PaymentMethod; process and every other piece of checkout code stay exactly as they are.