☕ Java
Runtime Polymorphism
Runtime polymorphism, also called dynamic polymorphism or late binding, is the form of polymorphism where the method implementation to execute is determined at runtime based on the actual type of the object, not the declared type of the reference variable. It is achieved through method overriding combined with superclass or interface references. Runtime polymorphism is the mechanism that enables writing code against abstractions — code written in terms of Shape, Animal, or PaymentProcessor works correctly with any subtype without modification.
How Runtime Polymorphism Works
Runtime polymorphism rests on two pillars: the ability to hold a subclass object in a superclass reference (upcasting), and the guarantee that calling an overridden method on that reference invokes the subclass's implementation (dynamic dispatch). Together these two mechanisms allow the same source code to produce different behaviours at runtime depending on what type of object was assigned to the reference.
Upcasting is always implicit and always safe. A Dog is-an Animal, so any Dog reference can be assigned to an Animal variable without a cast. The compiler allows this because every operation valid on an Animal is also valid on a Dog — the subclass can only add capabilities, not remove them. The Liskov Substitution Principle formalises this: anywhere an Animal is expected, a Dog can be substituted without breaking the program.
Once a subclass object is held in a superclass reference, the reference can only be used to access the methods defined in the superclass type. The additional methods specific to the subclass are not accessible through the superclass reference without casting back down. But for the methods that exist in the superclass, the actual subclass's overriding implementation is what runs — not the superclass's version. This is the essence of runtime polymorphism.
The JVM achieves this through the virtual method table. When a class overrides a method, its vtable entry for that method is updated to point to the overriding implementation. When the JVM encounters a method call on an interface or superclass reference, it looks up the vtable of the actual object's class, finds the entry for the method, and calls whatever it points to. This lookup happens at runtime — the JVM cannot know what type of object will be in the variable when the code was compiled.
Java
// ── Upcasting — subclass reference held in superclass variable: ───────
public abstract class Notification {
private final String recipient;
private final String message;
public Notification(String recipient, String message) {
this.recipient = Objects.requireNonNull(recipient);
this.message = Objects.requireNonNull(message);
}
public String getRecipient() { return recipient; }
public String getMessage() { return message; }
// Abstract method — subclasses must implement:
public abstract void send();
// Concrete method — uses send() polymorphically:
public void sendAndLog() {
System.out.println("Sending to: " + recipient);
send(); // which send()? determined at runtime
System.out.println("Sent via: " + getClass().getSimpleName());
}
}
public class EmailNotification extends Notification {
private final String subject;
public EmailNotification(String to, String subject, String body) {
super(to, body);
this.subject = subject;
}
@Override
public void send() {
System.out.printf("EMAIL → %s | Subject: %s | Body: %s%n",
getRecipient(), subject, getMessage());
}
}
public class SmsNotification extends Notification {
@Override
public void send() {
System.out.printf("SMS → %s | %s%n",
getRecipient(), getMessage());
}
}
public class PushNotification extends Notification {
private final String deviceToken;
public PushNotification(String deviceToken, String message) {
super(deviceToken, message);
this.deviceToken = deviceToken;
}
@Override
public void send() {
System.out.printf("PUSH → token:%s | %s%n",
deviceToken, getMessage());
}
}
// ── Runtime polymorphism — same code, different behaviour: ────────────
List<Notification> notifications = List.of(
new EmailNotification("alice@example.com", "Welcome!", "Hello Alice"),
new SmsNotification("+447700123456", "Your code is 4521"),
new PushNotification("device-abc-123", "New message received")
);
// This loop is written once — works for any Notification subclass:
for (Notification n : notifications) {
n.sendAndLog(); // send() resolved at runtime to each subclass
System.out.println();
}
// Sending to: alice@example.com
// EMAIL → alice@example.com | Subject: Welcome! | Body: Hello Alice
// Sent via: EmailNotification
//
// Sending to: +447700123456
// SMS → +447700123456 | Your code is 4521
// Sent via: SmsNotification
//
// Sending to: device-abc-123
// PUSH → token:device-abc-123 | New message received
// Sent via: PushNotificationRuntime Polymorphism Through Interfaces
Runtime polymorphism works through interfaces exactly as it does through class inheritance. An interface reference can hold any object whose class implements that interface. Method calls through the interface reference are dispatched at runtime to the actual implementing class's method. Interface-based polymorphism is often preferred over class-based polymorphism in professional Java code because interfaces support multiple implementation (a class can implement many interfaces) and because depending on interfaces rather than concrete classes makes code much more flexible and testable.
The principle of programming to an interface rather than to an implementation is one of the most widely cited principles in object-oriented design. It means: declare your variables, parameters, and return types as the most general type (typically an interface) that provides the operations you need. This allows the code to work with any implementing class — current ones and future ones that do not yet exist. Adding a new implementation of the interface requires no changes to any code that uses the interface.
This principle is the foundation of dependency injection, mock testing, and the strategy design pattern. A service that depends on a PaymentProcessor interface can be tested with a mock implementation that records calls and returns predetermined responses, and can be deployed with a Stripe implementation in production — all without changing the service code.
Java
// ── Interface-based runtime polymorphism: ────────────────────────────
public interface Sortable {
void sort(int[] array);
String algorithmName();
}
public class BubbleSort implements Sortable {
@Override
public void sort(int[] array) {
int n = array.length;
for (int i = 0; i < n - 1; i++)
for (int j = 0; j < n - i - 1; j++)
if (array[j] > array[j + 1]) {
int tmp = array[j];
array[j] = array[j + 1];
array[j + 1] = tmp;
}
}
@Override public String algorithmName() { return "Bubble Sort"; }
}
public class QuickSort implements Sortable {
@Override
public void sort(int[] array) {
quickSort(array, 0, array.length - 1);
}
private void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pivot = partition(arr, low, high);
quickSort(arr, low, pivot - 1);
quickSort(arr, pivot + 1, high);
}
}
private int partition(int[] arr, int low, int high) {
int pivot = arr[high], i = low - 1;
for (int j = low; j < high; j++)
if (arr[j] <= pivot) { i++; int t = arr[i]; arr[i]=arr[j]; arr[j]=t; }
int t = arr[i+1]; arr[i+1] = arr[high]; arr[high] = t;
return i + 1;
}
@Override public String algorithmName() { return "Quick Sort"; }
}
// ── Benchmark — same code works for any Sortable: ────────────────────
public class SortBenchmark {
public static long benchmark(Sortable sorter, int[] data) {
// Works for BubbleSort, QuickSort, or any future Sortable:
int[] copy = Arrays.copyOf(data, data.length);
long start = System.nanoTime();
sorter.sort(copy); // dispatched at runtime
long elapsed = System.nanoTime() - start;
System.out.printf("%-15s sorted %d elements in %,d ns%n",
sorter.algorithmName(), data.length, elapsed);
return elapsed;
}
}
int[] data = new Random(42).ints(10_000, 0, 100_000).toArray();
List<Sortable> algorithms = List.of(new BubbleSort(), new QuickSort());
algorithms.forEach(alg -> SortBenchmark.benchmark(alg, data));
// Bubble Sort sorted 10000 elements in 85,234,123 ns
// Quick Sort sorted 10000 elements in 1,456,789 nsUpcasting and Downcasting
Upcasting converts a subclass reference to a superclass or interface reference. It is always implicit and always safe because the subclass object has all the capabilities of the superclass. After upcasting, the reference can only be used to access the methods defined in the superclass or interface type — the subclass-specific methods are hidden. Upcasting is what enables runtime polymorphism: storing a Dog in an Animal variable is upcasting, and calling animal.speak() then dispatches to Dog.speak() at runtime.
Downcasting converts a superclass or interface reference back to a subclass reference. It is explicit (requires a cast) and potentially unsafe: if the actual object at runtime is not of the target type, a ClassCastException is thrown. The compiler allows the cast because it cannot determine the actual type at compile time. The programmer is asserting "I know this object is actually a Dog" and the JVM verifies this claim at runtime.
The instanceof operator tests whether an object is an instance of a particular type before downcasting, preventing ClassCastException. Java 16 introduced pattern matching for instanceof, which combines the type check and the downcast in a single expression: if (animal instanceof Dog dog) — if the check passes, dog is immediately available as a Dog-typed variable in the scope of the if body, eliminating the separate cast.
Frequent downcasting is a code smell. If code repeatedly needs to check what subtype an object is and cast to access subtype-specific methods, it suggests the class hierarchy may not be designed correctly. The solution is usually to move the operation in question into the superclass as an abstract or virtual method and let polymorphism handle the dispatch — eliminating the need to know the specific type at the call site.
Java
// ── Upcasting — implicit and safe: ───────────────────────────────────
public class Animal {
public String speak() { return "..."; }
}
public class Dog extends Animal {
@Override
public String speak() { return "Woof!"; }
public void fetch() { System.out.println("Fetching!"); } // Dog-specific
}
public class Cat extends Animal {
@Override
public String speak() { return "Meow!"; }
public void purr() { System.out.println("Purring..."); } // Cat-specific
}
Animal a = new Dog(); // upcast — implicit and safe
System.out.println(a.speak()); // Woof! — polymorphic dispatch to Dog
// a.fetch(); // COMPILE ERROR — fetch() not in Animal
// ── Downcasting — explicit and potentially unsafe: ────────────────────
Animal animal = new Dog();
// Unsafe downcast without check:
// Cat c = (Cat) animal; // ClassCastException at runtime — not a Cat!
// Safe downcast with instanceof check:
if (animal instanceof Dog) {
Dog dog = (Dog) animal; // safe — we verified the type
dog.fetch(); // Dog-specific method now accessible
}
// ── Java 16+ pattern matching instanceof — cleaner: ──────────────────
if (animal instanceof Dog dog) {
// dog is already typed as Dog — no separate cast:
dog.fetch();
System.out.println("Dog speaks: " + dog.speak());
}
// ── Processing a heterogeneous list — instanceof for type dispatch: ────
List<Animal> animals = List.of(
new Dog(), new Cat(), new Dog(), new Cat()
);
for (Animal a2 : animals) {
if (a2 instanceof Dog d) {
d.fetch();
} else if (a2 instanceof Cat c) {
c.purr();
}
}
// ── Better design — move behaviour into the class: ────────────────────
// Instead of instanceof checks, add a doSpecialThing() method:
public abstract class Animal {
public abstract String speak();
public abstract void doSpecialThing(); // each subclass defines its own
}
// Then: for (Animal a : animals) { a.doSpecialThing(); }
// No instanceof needed — polymorphism handles dispatch.The Open/Closed Principle Through Runtime Polymorphism
The Open/Closed Principle (OCP) is the second of the SOLID principles: software entities should be open for extension but closed for modification. Runtime polymorphism is the mechanism that makes this principle achievable in practice. Code written against an abstraction (superclass or interface) is automatically compatible with any new subtype that is added — the existing code does not need to change.
Without runtime polymorphism, adding a new variant of a type would require finding every piece of code that handles the existing variants and adding a new case. A switch statement or if-else chain that handles every known shape type must be updated whenever a new shape is added. With runtime polymorphism, the handling code calls area() on a Shape reference, and adding a new Hexagon subclass automatically makes the handling code work for hexagons too — no modification required.
This extensibility is the fundamental value proposition of object-oriented design. Systems that rely heavily on runtime polymorphism tend to be resilient to the kinds of changes that arise as requirements evolve: new products, new payment methods, new notification channels, new report formats. Each new variant is a new class implementing an existing interface, with no impact on the code that uses the interface.
The practical discipline is to identify the points of variability in a system early — the things that are most likely to change or be extended — and design interfaces around them. Once an interface is established and code depends on it, new implementations can be added freely. This is easier to do at design time than to retrofit into an existing codebase, which is why thinking in terms of polymorphic interfaces from the start produces more maintainable systems.
Java
// ── OCP in action — add new types without modifying existing code: ────
public interface TaxCalculator {
double calculate(double income);
String jurisdiction();
}
// Existing implementations:
public class UkTax implements TaxCalculator {
@Override
public double calculate(double income) {
if (income <= 12_570) return 0;
if (income <= 50_270) return (income - 12_570) * 0.20;
if (income <= 125_140) return 7_540 + (income - 50_270) * 0.40;
return 37_700 + (income - 125_140) * 0.45;
}
@Override public String jurisdiction() { return "UK"; }
}
public class UsTax implements TaxCalculator {
@Override
public double calculate(double income) {
if (income <= 11_000) return income * 0.10;
if (income <= 44_725) return 1_100 + (income - 11_000) * 0.12;
return 5_147 + (income - 44_725) * 0.22;
}
@Override public String jurisdiction() { return "US"; }
}
// Tax reporting code — depends only on TaxCalculator interface:
public class TaxReport {
private final List<TaxCalculator> calculators;
public TaxReport(List<TaxCalculator> calculators) {
this.calculators = calculators;
}
public void printReport(double income) {
System.out.printf("Tax report for income £%.2f:%n", income);
calculators.forEach(calc ->
System.out.printf(" %-10s tax: £%,.2f%n",
calc.jurisdiction(), calc.calculate(income)));
}
}
TaxReport report = new TaxReport(List.of(new UkTax(), new UsTax()));
report.printReport(60_000);
// Tax report for income £60,000.00:
// UK tax: £15,432.00
// US tax: £10,905.00
// ── Adding a new jurisdiction — ZERO changes to TaxReport: ────────────
public class GermanTax implements TaxCalculator {
@Override
public double calculate(double income) {
if (income <= 10_908) return 0;
if (income <= 62_809) return (income - 10_908) * 0.14;
return 7_239 + (income - 62_809) * 0.42;
}
@Override public String jurisdiction() { return "DE"; }
}
// TaxReport code is unchanged — it just works with the new type:
TaxReport extendedReport = new TaxReport(
List.of(new UkTax(), new UsTax(), new GermanTax()));
extendedReport.printReport(60_000);
// Tax report for income £60,000.00:
// UK tax: £15,432.00
// US tax: £10,905.00
// DE tax: £6,891.00Related Topics in Object-Oriented Programming
OOP Concepts
Object-Oriented Programming (OOP) is a programming paradigm that organises software around objects — self-contained units that combine data (fields) and behaviour (methods). Java is a class-based, object-oriented language where almost everything is an object. OOP provides four foundational principles: encapsulation, inheritance, polymorphism, and abstraction. Together they produce software that is modular, reusable, maintainable, and easier to reason about as systems grow in complexity.
Classes
A class is the fundamental building block of Java. It is a blueprint that defines the structure and behaviour of objects — what data each object holds (fields) and what operations it can perform (methods). Every Java program is composed of classes. Understanding how to design a class well — choosing the right access modifiers, separating state from behaviour, and writing cohesive single-responsibility classes — is the foundation of object-oriented programming in Java. This entry covers class anatomy, fields, methods, access modifiers, static vs instance members, the this keyword, and class design principles.
Objects
An object is a runtime instance of a class. Where a class is a blueprint that exists in source code, an object is a living entity that exists in memory during program execution — it has its own identity, its own state stored in its fields, and the ability to respond to method calls. Every object has three fundamental properties: identity (a unique memory address), state (the current values of its fields), and behaviour (the methods it responds to). This entry covers object identity vs equality, the Object class hierarchy, object state and mutation, method calls, toString, equals and hashCode, and the object lifecycle.
Object Creation
Object creation in Java is the process of allocating memory, initialising fields, and running constructor logic to bring an object into existence. The new keyword is the primary mechanism, but Java also provides factory methods, builder patterns, copy constructors, and object cloning. Constructors are special methods that set up the initial state — their design determines how easy or difficult the class is to use correctly. This entry covers constructors in depth, constructor overloading and chaining, copy constructors, factory methods, the builder pattern, and the difference between shallow and deep copy.