Polymorphism
Treat different classes through one type and let each object run its own code, and know which calls are decided at runtime and which at compile time.
Interface ref = new Impl();
ref.method(); // runs Impl's version (decided at runtime)Polymorphism means one variable type can hold objects of many classes, and a method call runs the version that belongs to the actual object. A List<Discount> can contain a percentage discount, a fixed discount and a free shipping rule; the loop that applies them calls apply and never asks which is which.
Two types for every reference
A variable has a declared type, fixed at compile time, and points at an object with a runtime class. The compiler uses the declared type to decide which methods you may call. The JVM uses the runtime class to decide which override actually runs. This is called dynamic dispatch.
Discount d = new PercentOff(10); // declared Discount, runtime PercentOff
d.apply(5000); // runs PercentOff.apply
d.percent(); // compile error: Discount has no percent()Overloading is not polymorphism at runtime
Overriding is resolved at runtime. Overloading, choosing between methods with the same name but different parameter types, is resolved at compile time from the declared types of the arguments. Passing an Event variable that happens to hold an OrderPlaced calls the Event overload. People expect otherwise all the time.
Replacing type checks
A chain of if (type.equals("card")) ... else if (type.equals("paypal")) spread across a codebase is a sign that behaviour belongs on the types. Give each payment method its own class behind one interface and the chains collapse into one call. Adding a new method of payment then means adding a class, not editing every if.
The opposite style also has its place. When the set of types is closed and the operations change more often than the types, a sealed interface with a switch over its records keeps the logic in one place and the compiler checks every case is covered. Both are polymorphism; choose based on what changes more.
Downcasting
Sometimes you need the specific type back. Use a pattern: if (d instanceof PercentOff p). A plain cast to the wrong class throws ClassCastException. Frequent downcasts usually mean a method is missing from the interface.
On the server
Route tables mapping a path to a handler interface, strategy maps keyed by provider name, a list of validators or filters run in order: all are polymorphism. Frameworks rely on it constantly, calling your implementation through their interface.
Example
When to use it
- Apply a configurable list of discount, tax and shipping rules to a basket through one interface.
- Map each route to a handler object so the dispatcher never needs an if/else per endpoint.
- Pick a payment provider implementation by name at runtime and call it through a shared PaymentGateway type.
More examples
Overload chosen at compile time, override at runtime
Inside the Event overload, e.name() still prints OrderPlaced because that call is an override. If you need behaviour chosen by the event's real type, put it on the event or switch over a sealed type.
class Event {
String name() { return "Event"; }
}
class OrderPlaced extends Event {
@Override String name() { return "OrderPlaced"; }
}
class Auditor {
String record(Event e) { return "generic record of " + e.name(); }
String record(OrderPlaced e) { return "order record of " + e.name(); }
}
void main() {
Auditor auditor = new Auditor();
Event event = new OrderPlaced();
// Declared type Event picks the overload; the runtime class picks name().
IO.println(auditor.record(event));
IO.println(auditor.record(new OrderPlaced()));
}
// Output:
// generic record of OrderPlaced
// order record of OrderPlacedA route table of handlers
The dispatcher never checks which handler it has; it looks one up and calls it. This is the core of every web framework's routing, with annotations or a DSL on top.
interface Handler {
String handle(String body);
}
class Health implements Handler {
public String handle(String body) { return "200 ok"; }
}
class CreateUser implements Handler {
public String handle(String body) {
return body.contains("@") ? "201 created" : "400 invalid email";
}
}
void main() {
Map<String, Handler> routes = new LinkedHashMap<>();
routes.put("GET /health", new Health());
routes.put("POST /users", new CreateUser());
routes.put("DELETE /users", body -> "204 deleted"); // a lambda works too
for (String request : List.of("GET /health", "POST /users", "DELETE /users", "PUT /users")) {
Handler h = routes.getOrDefault(request, body -> "404 not found");
IO.println(request + " => " + h.handle("[email protected]"));
}
}
// Output:
// GET /health => 200 ok
// POST /users => 201 created
// DELETE /users => 204 deleted
// PUT /users => 404 not foundGetting the specific type back safely
instanceof with a binding variable cannot throw, while a plain cast fails at runtime when the object is a different class. If many callers need this, add the method to Payment instead.
interface Payment { long cents(); }
record Card(long cents, String last4) implements Payment { }
record BankTransfer(long cents, String iban) implements Payment { }
String receipt(Payment p) {
// Pattern matching checks and casts in one step.
if (p instanceof Card card) {
return "card ending " + card.last4() + ": " + card.cents();
}
return "payment: " + p.cents();
}
void main() {
IO.println(receipt(new Card(2500, "4242")));
IO.println(receipt(new BankTransfer(9000, "GB00TEST")));
Payment p = new BankTransfer(100, "GB00TEST");
try {
Card c = (Card) p; // wrong class at runtime
IO.println(c.last4());
} catch (ClassCastException e) {
IO.println("cast failed: " + e.getClass().getSimpleName());
}
}
// Output:
// card ending 4242: 2500
// payment: 9000
// cast failed: ClassCastException
Discussion