Loading learning content...
Imagine you're building a drawing application. Users can create shapes—circles, rectangles, triangles—and with a single "Draw All" button, every shape renders itself correctly. Your code simply iterates through a collection of shapes and calls draw() on each. You didn't write separate loops for each shape type. You didn't use giant switch statements. The right drawing method just... runs.
This is the magic of dynamic polymorphism—the ability to determine which method to call at runtime based on the actual type of the object, not the declared type of the variable. It's the mechanism that enables truly flexible, extensible object-oriented systems.
Unlike static polymorphism where the compiler resolves everything before execution, dynamic polymorphism defers the decision until the program runs. This flexibility comes with costs, but it enables patterns impossible with compile-time resolution alone.
By the end of this page, you will understand the complete mechanics of runtime polymorphism: virtual methods, the virtual table (vtable), how dynamic dispatch works internally, the role of inheritance hierarchies, and the design patterns enabled by late binding. You'll also understand the performance implications that inform architectural decisions.
Dynamic polymorphism (also called runtime polymorphism or late binding) is the ability of a program to select among multiple method implementations at runtime, based on the actual type of the object on which the method is invoked. The "dynamic" refers to the timing—the decision happens during program execution, not compilation.
The fundamental mechanism:
In dynamic polymorphism, a variable declared as a base type can hold objects of any derived type. When you call a method on that variable, the runtime system examines the actual object type (not the declared variable type) and dispatches to the appropriate implementation. This is called dynamic dispatch or virtual method invocation.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// Base class with method to be overriddenabstract class Shape { protected String name; public Shape(String name) { this.name = name; } // Method that subclasses will override public abstract void draw(); // Common method for all shapes public String getName() { return name; }} class Circle extends Shape { private double radius; public Circle(double radius) { super("Circle"); this.radius = radius; } @Override public void draw() { System.out.println("Drawing Circle with radius " + radius); }} class Rectangle extends Shape { private double width, height; public Rectangle(double width, double height) { super("Rectangle"); this.width = width; this.height = height; } @Override public void draw() { System.out.println("Drawing Rectangle " + width + "x" + height); }} class Triangle extends Shape { private double base, height; public Triangle(double base, double height) { super("Triangle"); this.base = base; this.height = height; } @Override public void draw() { System.out.println("Drawing Triangle with base " + base); }} // Demonstration of dynamic polymorphismpublic class DrawingDemo { public static void main(String[] args) { // Variable declared as Shape, but holds different actual types List<Shape> shapes = new ArrayList<>(); shapes.add(new Circle(5.0)); // Actual type: Circle shapes.add(new Rectangle(4, 6)); // Actual type: Rectangle shapes.add(new Triangle(3, 4)); // Actual type: Triangle // Dynamic dispatch: correct draw() is called based on ACTUAL type for (Shape shape : shapes) { // Variable type is Shape, but runtime finds actual type shape.draw(); // Which draw()? Decided at RUNTIME! } // Output: // Drawing Circle with radius 5.0 // Drawing Rectangle 4.0x6.0 // Drawing Triangle with base 3.0 }}The key insight:
Notice that the loop iterates over a collection of Shape references. The variable type is Shape. Yet for each element, the runtime correctly invokes Circle.draw(), Rectangle.draw(), or Triangle.draw(). The compiler cannot know which—it depends on what objects were added to the collection at runtime. This runtime decision is the essence of dynamic polymorphism.
Understanding the distinction between declared type (static type, the type in the variable declaration) and actual type (dynamic type, the real type of the object at runtime) is crucial. Static polymorphism uses declared types. Dynamic polymorphism uses actual types. When you see Shape shape = new Circle(), Shape is declared, Circle is actual.
Virtual methods are the language mechanism that enables dynamic polymorphism. A virtual method is one that can be overridden in derived classes, and for which the runtime system performs dynamic dispatch to find the correct implementation based on the object's actual type.
How languages mark methods as virtual:
| Language | Syntax for Virtual Methods | Default Behavior |
|---|---|---|
| Java | All non-static, non-final methods are virtual | Virtual by default |
| C# | virtual keyword required | Non-virtual by default |
| C++ | virtual keyword required | Non-virtual by default |
| Python | All methods are virtual | Virtual by default |
| Kotlin | open keyword required (methods are final by default) | Non-virtual by default |
The choice of default matters. Java's "everything is virtual" simplifies inheritance but adds overhead. C++'s "opt-in virtual" gives control but requires explicit design.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
class Animal {public: // Virtual method - can be overridden, uses dynamic dispatch virtual void speak() const { std::cout << "Some generic sound" << std::endl; } // Non-virtual method - cannot use dynamic dispatch // Calls are resolved at compile time based on declared type void breathe() const { std::cout << "Breathing..." << std::endl; } // Pure virtual method - must be overridden, class is abstract virtual void move() const = 0; // Virtual destructor - ESSENTIAL for polymorphic base classes // Without this, deleting derived object through base pointer is UB virtual ~Animal() = default;}; class Dog : public Animal {public: // Override virtual method - uses 'override' specifier (C++11) void speak() const override { std::cout << "Woof!" << std::endl; } // This shadows breathe(), not overrides it - DANGEROUS! // Calling through Animal* still invokes Animal::breathe() void breathe() const { std::cout << "Dog panting..." << std::endl; } // Must implement pure virtual method void move() const override { std::cout << "Running on four legs" << std::endl; }}; class Bird : public Animal {public: void speak() const override { std::cout << "Chirp!" << std::endl; } void move() const override { std::cout << "Flying through the air" << std::endl; }}; // Demonstrationvoid demonstratePolymorphism() { Dog dog; Bird bird; // Using base class pointers Animal* animalPtr = &dog; animalPtr->speak(); // "Woof!" - virtual, uses actual type (Dog) animalPtr->breathe(); // "Breathing..." - non-virtual, uses declared type (Animal) animalPtr->move(); // "Running on four legs" - virtual animalPtr = &bird; animalPtr->speak(); // "Chirp!" - virtual, now Dog type animalPtr->move(); // "Flying through the air"}When a derived class declares a method with the same signature as a non-virtual base method, it shadows (hides) rather than overrides. Calls through base type use the base version; calls through derived type use the derived version. This inconsistency causes subtle bugs. In C++, shadowing compiles silently. C# requires 'new' keyword, making intent explicit.
The override specifier:
Modern C++ (C++11) and C# provide the override keyword to explicitly mark a method as overriding a virtual method. This enables compiler checks:
Always use override (or @Override in Java) to catch errors early. Without it, typos in method names or subtle signature differences create shadows instead of overrides—bugs that are notoriously hard to debug.
Understanding the virtual method table (vtable) demystifies dynamic polymorphism. The vtable is an implementation technique used by C++, Java, C#, and most object-oriented languages to achieve efficient dynamic dispatch.
The vtable concept:
Every class with virtual methods has an associated vtable—an array of function pointers. Each entry points to the implementation of a virtual method for that class. When a virtual method is called on an object, the runtime:
This indirection is how the runtime "knows" which method to call based on actual type.
Vtable creation and layout:
One vtable per class — The vtable is created once for each class type, stored in static memory. It's shared by all instances of that class.
Vptr per object — Each object contains a hidden pointer (vptr) to its class's vtable. This is typically the first member, added automatically by the compiler.
Consistent indexing — Virtual methods are assigned indices in the vtable. If draw() is at index 0 and area() is at index 1 in the base class, derived classes maintain the same indices. Derived implementations replace the pointers at those indices.
Inheritance handling — When a derived class overrides a virtual method, its vtable has the derived implementation at that index. When it doesn't override, the vtable inherits the base class pointer.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Conceptual representation of vtable mechanism// (Actual implementation is compiler-dependent) class Shape {public: virtual void draw() { } // vtable index 0 virtual double area() { return 0; } // vtable index 1 virtual ~Shape() { } // vtable index 2}; class Circle : public Shape { double radius;public: Circle(double r) : radius(r) {} void draw() override { } // Overrides at index 0 double area() override { return 3.14159 * radius * radius; } // Overrides at index 1 // Destructor inherited at index 2}; class Rectangle : public Shape { double w, h;public: Rectangle(double w, double h) : w(w), h(h) {} void draw() override { } // Overrides at index 0 double area() override { return w * h; } // Overrides at index 1}; // Memory layout (conceptual)://// Circle object:// +------------------+// | vptr ------------> Circle_vtable// | radius: 5.0 |// +------------------+//// Circle_vtable:// +------------------+// | [0]: Circle::draw |// | [1]: Circle::area |// | [2]: Circle::~Circle |// +------------------+//// Rectangle_vtable:// +------------------+// | [0]: Rectangle::draw |// | [1]: Rectangle::area |// | [2]: Rectangle::~Rectangle |// +------------------+ // When calling shape->draw() where shape is Shape*:// 1. Fetch vptr from object: obj->vptr// 2. Index into vtable: vptr[0] (draw is at index 0)// 3. Call the function via pointer: (*vptr[0])() // In assembly (x86-64, conceptual):// mov rax, [rdi] ; Load vptr from object (rdi = this)// call [rax] ; Call first entry (draw) in vtableEach object with virtual methods carries an extra pointer (vptr), typically 8 bytes on 64-bit systems. For millions of small objects, this overhead matters. Each vtable itself is created once per class, so that's minimal. The runtime cost is the indirection: fetching the vptr, then fetching the function pointer—potentially two cache misses in the worst case.
Why virtual destructors are essential:
Consider deleting a derived object through a base pointer:
Shape* shape = new Circle(5.0);
delete shape; // Which destructor runs?
Without a virtual destructor, only Shape::~Shape() runs—the Circle-specific cleanup is skipped. This is undefined behavior that often manifests as memory leaks or corruption.
With a virtual destructor, the vtable ensures Circle::~Circle() is called, which then chains to Shape::~Shape(). Rule: If a class has any virtual methods, its destructor should be virtual.
Dynamic polymorphism is intrinsically linked to inheritance. The ability to substitute derived objects for base types—and have them behave according to their actual type—requires an inheritance relationship. This is the essence of the Liskov Substitution Principle (which we'll explore in depth later).
The polymorphism-enabling chain:
All four elements must be present for dynamic polymorphism to occur.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// Multi-level inheritance hierarchy with polymorphismabstract class Vehicle { protected String brand; public Vehicle(String brand) { this.brand = brand; } // Abstract method - must be overridden public abstract void start(); // Virtual method with default implementation public void stop() { System.out.println(brand + " stopping with standard brakes"); } // Template method pattern: final method using abstract steps public final void drive() { start(); System.out.println("Driving..."); stop(); }} abstract class Car extends Vehicle { protected int doors; public Car(String brand, int doors) { super(brand); this.doors = doors; } // Partial implementation - still abstract @Override public void start() { System.out.println("Turning key in ignition"); startEngine(); // Delegate to abstract method } protected abstract void startEngine();} class ElectricCar extends Car { private int batteryCapacity; public ElectricCar(String brand, int doors, int battery) { super(brand, doors); this.batteryCapacity = battery; } @Override protected void startEngine() { System.out.println(brand + " electric motor silently activating"); } @Override public void stop() { System.out.println(brand + " regenerative braking engaged"); }} class GasCar extends Car { private double engineSize; public GasCar(String brand, int doors, double engine) { super(brand, doors); this.engineSize = engine; } @Override protected void startEngine() { System.out.println(brand + " " + engineSize + "L engine roaring to life"); }} // Usage demonstrating polymorphism at multiple levelspublic class VehicleDemo { public static void main(String[] args) { // All declared as Vehicle, but actual types vary List<Vehicle> fleet = Arrays.asList( new ElectricCar("Tesla", 4, 100), new GasCar("Ford", 4, 5.0), new ElectricCar("Nissan", 4, 60) ); for (Vehicle v : fleet) { System.out.println("--- " + v.brand + " ---"); v.drive(); // Template method invokes polymorphic steps System.out.println(); } }} // Output:// --- Tesla ---// Turning key in ignition// Tesla electric motor silently activating// Driving...// Tesla regenerative braking engaged//// --- Ford ---// Turning key in ignition// Ford 5.0L engine roaring to life// Driving...// Ford stopping with standard brakes//// --- Nissan ---// Turning key in ignition// Nissan electric motor silently activating// Driving...// Nissan regenerative braking engagedMulti-level polymorphism:
In the example above, polymorphism operates at multiple levels:
Vehicle.drive() is a template method that calls start() and stop()start() is overridden in Car to add ignition logic, then delegates to startEngine()startEngine() is abstract in Car, implemented differently in ElectricCar and GasCarstop() has a default in Vehicle, overridden only by ElectricCarEach level of the hierarchy can contribute behavior, and polymorphism ensures the right combination is invoked based on actual type.
Polymorphism doesn't require class inheritance. Interface-based polymorphism (programming to interfaces) achieves the same flexibility without the tight coupling of class hierarchies. Many modern designs prefer interface polymorphism: Drawable interface instead of Shape base class. The mechanics are identical—vtables or equivalent—but the design is more flexible.
Abstract classes and interfaces are the primary mechanisms for defining polymorphic contracts. They establish the "shape" of behavior that derived types must implement, enabling polymorphic code to work with any conforming type.
Abstract classes:
Interfaces:
| Aspect | Abstract Class | Interface |
|---|---|---|
| State (fields) | Yes | No (constants only) |
| Constructors | Yes | No |
| Method implementations | Some or all | Default methods only (modern) |
| Multiple inheritance | No (single) | Yes (multiple interfaces) |
| Access modifiers on methods | Any | Implicitly public |
| Relationship type | IS-A (type hierarchy) | CAN-DO (capability) |
| When to use | Shared implementation exists | Contract only, flexible composition |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// Interface: defines a capabilityinterface Drawable { void draw(); // Abstract by default // Default method (Java 8+): provides common behavior default void drawWithBorder() { System.out.println("Drawing border..."); draw(); }} interface Resizable { void resize(double factor);} // Abstract class: partial implementation with stateabstract class Shape implements Drawable { protected double x, y; // State: position public Shape(double x, double y) { this.x = x; this.y = y; } // Concrete method: shared by all shapes public void moveTo(double newX, double newY) { this.x = newX; this.y = newY; } // Abstract: each shape draws differently public abstract void draw(); // Abstract: each shape calculates area differently public abstract double area();} // Concrete class: implements abstract class + additional interfaceclass Circle extends Shape implements Resizable { private double radius; public Circle(double x, double y, double radius) { super(x, y); this.radius = radius; } @Override public void draw() { System.out.printf("Circle at (%.1f, %.1f) with radius %.1f%n", x, y, radius); } @Override public double area() { return Math.PI * radius * radius; } @Override public void resize(double factor) { radius *= factor; }} // Polymorphic usage through different typesclass Demo { public static void drawAll(List<Drawable> items) { for (Drawable d : items) { d.draw(); // Polymorphic call } } public static void resizeAll(List<Resizable> items, double factor) { for (Resizable r : items) { r.resize(factor); // Polymorphic call } } public static void processShapes(List<Shape> shapes) { for (Shape s : shapes) { s.draw(); // From Drawable via Shape s.area(); // From Shape s.moveTo(0, 0); // Concrete method from Shape } }}Notice how Drawable and Resizable are separate interfaces. A class can implement both, one, or neither. This follows the Interface Segregation Principle: clients shouldn't depend on methods they don't use. If Drawable included resize(), non-resizable drawables would have to provide dummy implementations.
Dynamic polymorphism has real performance costs. Understanding these helps you make informed design decisions, especially in performance-critical systems.
The overhead of virtual calls:
Indirection cost — Every virtual call requires fetching the vptr from the object, then fetching the function pointer from the vtable. This is typically 1-2 additional memory accesses compared to a direct call.
Cache misses — The vtable might not be in CPU cache, causing a cache miss (tens to hundreds of cycles on modern CPUs). If different object types are interleaved, their vtables might evict each other.
Branch prediction failure — CPUs predict which code path will execute next. Direct calls are perfectly predictable. Indirect calls through vtables are harder to predict, potentially causing pipeline stalls.
Inlining prevention — The compiler cannot inline virtual calls (at compile time), because it doesn't know which function will be called. Inlining is one of the most significant optimizations; losing it impacts performance.
| Operation | Cost | When It Matters |
|---|---|---|
| Direct function call | ~1-3 cycles | Baseline |
| Virtual call (cache hit) | ~3-10 cycles | Typical case with warm cache |
| Virtual call (cache miss) | ~50-200 cycles | Cold cache, scattered objects |
| Lost inline optimization | Varies wildly | Small, hot methods |
| Branch misprediction | ~10-20 cycles | Mixed object types in loops |
When it matters:
Hot loops over polymorphic collections — If you're iterating millions of times over mixed-type objects, calling virtual methods each iteration, the overhead accumulates. Consider alternative designs (e.g., separate homogeneous containers).
Small, frequently-called methods — A virtual getX() returning a field incurs overhead greater than the work itself. Prefer direct field access or inline the logic.
Real-time systems — In audio processing, game loops, or trading systems where consistent latency matters, unpredictable cache behavior is problematic.
When it doesn't matter:
Methods doing substantial work — If draw() performs thousands of operations, the overhead of the virtual call is noise.
Non-hot paths — Initialization, configuration, error handling—polymorphism here costs nothing in practice.
Correctness over speed — Premature optimization is the root of all evil. Measure before optimizing.
Modern JVMs and compilers can often devirtualize calls when they can prove the actual type at compile time or through profiling. If a call site always receives Circle objects, the JIT compiler may replace the virtual call with a direct call and even inline it. Profile-guided optimization (PGO) makes this more effective. Don't assume virtual = slow; measure in your specific context.
final classes/methods — Prevents overriding, enables devirtualizationDynamic polymorphism is the foundation of numerous design patterns in object-oriented programming. These patterns leverage late binding to achieve flexibility, extensibility, and separation of concerns.
Core patterns relying on dynamic dispatch:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Strategy Pattern: Polymorphism in actioninterface PaymentStrategy { void pay(double amount); boolean validate();} class CreditCardPayment implements PaymentStrategy { private String cardNumber; private String cvv; public CreditCardPayment(String cardNumber, String cvv) { this.cardNumber = cardNumber; this.cvv = cvv; } @Override public void pay(double amount) { System.out.println("Charging $" + amount + " to card ending " + cardNumber.substring(cardNumber.length() - 4)); } @Override public boolean validate() { return cardNumber.length() == 16 && cvv.length() == 3; }} class PayPalPayment implements PaymentStrategy { private String email; public PayPalPayment(String email) { this.email = email; } @Override public void pay(double amount) { System.out.println("Sending $" + amount + " via PayPal to " + email); } @Override public boolean validate() { return email.contains("@"); }} class CryptoPayment implements PaymentStrategy { private String walletAddress; public CryptoPayment(String walletAddress) { this.walletAddress = walletAddress; } @Override public void pay(double amount) { System.out.println("Transferring " + amount + " BTC to " + walletAddress); } @Override public boolean validate() { return walletAddress.startsWith("0x") || walletAddress.startsWith("bc1"); }} // Context class that uses strategy polymorphicallyclass ShoppingCart { private List<Item> items = new ArrayList<>(); private PaymentStrategy paymentStrategy; // Strategy can be swapped at runtime public void setPaymentStrategy(PaymentStrategy strategy) { this.paymentStrategy = strategy; } public void checkout() { double total = calculateTotal(); if (paymentStrategy == null) { throw new IllegalStateException("No payment method selected"); } if (!paymentStrategy.validate()) { throw new IllegalArgumentException("Invalid payment details"); } // Polymorphic call - actual payment determined at runtime paymentStrategy.pay(total); }} // Usage: Strategy selected at runtime based on user choiceShoppingCart cart = new ShoppingCart();// ... add items ... // User selects payment method at runtimeString userChoice = getUserPaymentChoice(); // "credit", "paypal", "crypto" switch (userChoice) { case "credit": cart.setPaymentStrategy(new CreditCardPayment("1234567890123456", "123")); break; case "paypal": cart.setPaymentStrategy(new PayPalPayment("user@example.com")); break; case "crypto": cart.setPaymentStrategy(new CryptoPayment("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")); break;} cart.checkout(); // Correct payment method invoked via polymorphismEvery pattern above shares a common theme: code depends on an abstraction (interface or base class), and concrete behavior is determined by the actual object at runtime. This decouples clients from implementations, enables extension without modification (Open/Closed Principle), and makes systems testable (inject mocks implementing the same interface).
We've explored dynamic polymorphism comprehensively. Let's consolidate the essential insights:
What's next:
Now that you understand both static and dynamic polymorphism, the next page explores how the compiler and runtime resolve polymorphic calls. We'll dive deeper into the mechanics of overload resolution versus virtual dispatch, and understand the complete picture of method binding in object-oriented systems.
You now have a comprehensive understanding of dynamic polymorphism—its mechanisms (virtual methods, vtables), its relationship with inheritance, its performance characteristics, and the patterns it enables. Next, we'll examine how compilers and runtimes make the actual dispatch decisions.