CMP-220 · Lesson 6 of 12 · 21 min
Polymorphism and dynamic dispatch
With the node hierarchy from lesson 5 in place, Jonah deletes his first big if statement. The old silence monitor in Tally.java decided what to show for each node with a chain of conditions on the model number, repeated in the alert text, the maintenance report and the dashboard. Now each of those places simply calls node.describe() and node.estimateLength(d), and each object answers in its own way. One call in the source, several possible behaviors at run time, chosen by the actual object: that is polymorphism, the most powerful idea in object-oriented programming and the subject of this lesson. We also look under the hood at how a processor actually finds the right method, because computer engineers should know what a method call costs.
Static type and dynamic type
Every Java variable has a static type, the type written in its declaration, which the compiler uses to check what you may do with it. Every object has a dynamic type, the class it was actually created from. After CounterNode n = new LoopNode(311, lanes, 400), the static type of n is CounterNode and the dynamic type of the object it refers to is LoopNode. The compiler allows n.describe() because CounterNode declares describe; at run time, the virtual machine calls LoopNode's version, because that is what the object really is. The compiler would reject n.loopSpacingCm(), even though the object has that method, because the static type does not promise it.
This split is what makes polymorphic code possible. The silence monitor is written once, against CounterNode. It compiles without knowing that LoopNode, RadarNode or CameraNode exist, and it will work unchanged next year when someone adds an NS-6 class. The ability to add new kinds of objects without modifying the code that uses them is the open-closed principle of lesson 10, and polymorphism is the mechanism that delivers it.
// Before: the model number decides, in every place that cares
String describe(int i) {
switch (model[i]) {
case 3: return "NS-3 loop node " + nodeId[i];
case 4: return "NS-4 radar node " + nodeId[i];
case 5: return "NS-5 camera node " + nodeId[i];
default: return "unknown model"; // and the next model needs edits here
}
}
// After: each class answers for itself; the monitor never mentions models
void checkSilence(List<CounterNode> nodes, Instant now) {
for (CounterNode n : nodes)
if (n.isSilent(now, Duration.ofMinutes(30)))
alerts.raise(n.describe() + " silent since " + n.lastUpload());
}Dynamic dispatch and the vtable
Choosing which method body to run at run time, based on the object's dynamic type, is called dynamic dispatch or late binding. How does the machine do it quickly? The usual implementation, used by C++ compilers and in essence by the Java virtual machine, is the virtual method table, or vtable. For each class with overridable methods, the compiler builds one table of function pointers, one entry per virtual method, in a fixed order shared down the hierarchy. Every object carries a hidden pointer, the vptr, to its class's table. A call such as n.describe() compiles to: load the object's vptr, load the entry at describe's fixed offset in the table, and make an indirect call through that pointer.
The cost is two memory loads and an indirect branch instead of a direct call, plus one pointer of memory per object. On a modern processor, if the branch predictor guesses the target correctly, the call costs about the same as a direct call; if the same call site sees many different classes in quick succession, mispredictions add a few nanoseconds each. More significant is that a compiler cannot easily inline a call whose target it does not know. Java's just-in-time compiler watches which classes actually reach each call site and, when only one or two do, inlines them speculatively with a quick type check, so polymorphic Java code often runs as fast as code with no dispatch at all. For the Hub, which spends its time waiting on networks and disks, dispatch cost is irrelevant; in a tight signal-processing loop on a microcontroller it might matter, which is one reason such loops are written without virtual calls.
Worked problem
Does dispatch cost matter for the Hub?
The Hub receives about 30 million detections a day. Each one passes through about four dynamically dispatched calls (classify, estimateLength, describe for logging, and a formatter row call). Suppose each virtual call costs 5 ns more than a direct call in the worst case, when the branch predictor misses. How much processor time per day does dispatch add, and how does it compare with the time spent parsing uploads, measured at about 2 µs per detection?
Show solution
- Total dispatched calls per day:
- \[3.0 \times 10^{7} \times 4 = 1.2 \times 10^{8}\]
- Worst-case extra time:
- \[1.2 \times 10^{8} \times 5\ \text{ns} = 0.6\ \text{s}\]
- Parsing time per day:
- \[3.0 \times 10^{7} \times 2\ \mu\text{s} = 60\ \text{s}\]
Answer: Dispatch adds at most about 0.6 s of processor time per day, one percent of the parsing time alone and a negligible fraction of a server's capacity. The clarity of polymorphic code is free in practice for the Hub.
C++: virtual is opt-in
In Java every non-static, non-private, non-final method is dynamically dispatched. C++ makes the opposite choice: a member function is dispatched dynamically only if it is declared virtual in the base class; otherwise calls are bound at compile time by the static type, which is faster but surprising if you forget. The override specifier, like Java's @Override, asks the compiler to confirm that a function really overrides a virtual one. Two C++ traps follow from value semantics. First, a base class used through pointers needs a virtual destructor; otherwise delete basePtr destroys only the base part and leaks or corrupts the rest. Second, copying a derived object into a variable of base type by value slices it: only the base part is copied, and the copy behaves as a base object. Polymorphism in C++ therefore works through references and pointers, usually smart pointers.
// C++ gateway code: virtual functions, a virtual destructor and the slicing trap
struct CounterNode {
virtual ~CounterNode() = default; // required for deletion through a base pointer
virtual std::string describe() const { return "node " + std::to_string(id); }
int id = 0;
};
struct LoopNode : CounterNode {
std::string describe() const override { return "NS-3 loop " + CounterNode::describe(); }
};
void show(const CounterNode& n) { std::cout << n.describe() << '\n'; } // reference: dynamic
int main() {
std::vector<std::unique_ptr<CounterNode>> nodes;
nodes.push_back(std::make_unique<LoopNode>());
for (const auto& n : nodes) show(*n); // prints "NS-3 loop node 0"
LoopNode loop;
CounterNode copy = loop; // slicing: only the base part is copied
show(copy); // prints "node 0"
}Overloading is not overriding
Overloading means several methods in the same class share a name but have different parameter lists, such as count(List<Detection>) and count(List<Detection>, int lane). The compiler chooses among overloads at compile time, using the static types of the arguments. Overriding replaces an inherited method with the same signature, and the choice happens at run time, using the dynamic type of the receiver. Mixing the two causes a classic puzzle. If a class has report(CounterNode n) and report(LoopNode n), then calling report(x) where x is declared as CounterNode always picks the first overload, even when x refers to a LoopNode, because overloads are resolved by static type. Java dispatches dynamically on the receiver only, which is called single dispatch.
Asking an object its type
Sometimes code must treat one kind of object specially. Java's instanceof operator tests an object's dynamic type, and since Java 16 it can bind a variable in the same step: if (n instanceof CameraNode cam) uses cam with its full type. Java 21 adds pattern matching in switch, and combined with sealed classes, which list exactly which subclasses are permitted, the compiler can check that a switch handles every case. Even so, a long chain of instanceof tests is usually a sign that behavior belongs in a method on the classes themselves. A reasonable rule: use polymorphism when the set of operations is open and the set of types is stable, and pattern matching over a sealed hierarchy when the types are a closed, stable set and new operations are added often, such as different report formats over a fixed set of record types.
public sealed interface Upload permits CountUpload, DetectionUpload, StatusUpload {}
public record CountUpload(int nodeId, Instant start, int[] counts) implements Upload {}
public record DetectionUpload(int nodeId, List<Detection> detections) implements Upload {}
public record StatusUpload(int nodeId, double volts, int tempC) implements Upload {}
String summarize(Upload u) {
return switch (u) { // the compiler checks that every case is covered
case CountUpload c -> "counts from " + c.nodeId();
case DetectionUpload d -> d.detections().size() + " detections from " + d.nodeId();
case StatusUpload s -> "status " + s.volts() + " V from " + s.nodeId();
};
}Duck typing in Python
Python looks up every method at run time by name on the actual object, so polymorphism does not require a common superclass at all: any object with a describe method can be passed to code that calls describe. This is called duck typing, from the saying that if it walks like a duck and quacks like a duck, it is a duck. It is flexible and concise, but mistakes appear only when the code runs, so Python projects lean on tests and on optional type hints checked by tools. Python's functools.singledispatch offers overloading-like dispatch on the type of the first argument.
from functools import singledispatch
class LoopNode:
def __init__(self, i): self.i = i
def describe(self): return f"NS-3 loop {self.i}"
class RadarNode:
def __init__(self, i): self.i = i
def describe(self): return f"NS-4 radar {self.i}"
class CameraNode:
def __init__(self, i): self.i = i
def describe(self): return f"NS-5 camera {self.i}"
class PortableCounter: # not a node subclass at all, but it quacks the same way
def describe(self): return "rented pneumatic tube counter"
def silence_report(things): # one call site, many outcomes
return [t.describe() for t in things]
fleet = [LoopNode(311), RadarNode(17), CameraNode(902), PortableCounter()]
for line in silence_report(fleet):
print(line)
@singledispatch
def icon(node): # dispatch on the argument's type
return "?"
@icon.register
def _(node: LoopNode): return "[loop]"
@icon.register
def _(node: RadarNode): return "[radar]"
print([icon(n) for n in fleet])
# How often is a call site monomorphic? Count dynamic types seen at one site
from collections import Counter
seen = Counter(type(n).__name__ for n in [RadarNode(k) for k in range(4800)] + [LoopNode(k) for k in range(600)])
share = seen["RadarNode"] / sum(seen.values())
print(f"{len(seen)} classes reach the call site; {share:.0%} of calls go to RadarNode")NS-3 loop 311 NS-4 radar 17 NS-5 camera 902 rented pneumatic tube counter ['[loop]', '[radar]', '?', '?'] 2 classes reach the call site; 89% of calls go to RadarNode
The last lines make a point about performance. In the Hub, about 89 percent of nodes are NS-4 radar nodes, so a call site such as describe sees one class most of the time. Just-in-time compilers in Java and in fast JavaScript engines exploit exactly this kind of skew, inlining the common case behind a cheap type check and keeping the general dispatch only for the rest.
Seeing calls over time: sequence diagrams
Class diagrams show structure; UML sequence diagrams show how objects interact over time for one scenario. Each participating object has a lifeline running down the page, and horizontal arrows show messages, that is, method calls, with their return values drawn as dashed arrows. Sequence diagrams are the natural way to explain polymorphic behavior, because they show which object actually received each call.
Common mistakes
MistakeExpecting overload resolution to use the run-time type of an argument.
InsteadJava chooses overloads by static type at compile time; only the receiver is dispatched dynamically.
MistakeForgetting virtual (or a virtual destructor) in a C++ base class.
InsteadDeclare overridable functions virtual, mark overrides with override, and give polymorphic bases a virtual destructor.
MistakePassing C++ objects to polymorphic code by value.
InsteadPass by reference or pointer to avoid slicing.
MistakeWriting long instanceof chains or switches on a type code.
InsteadMove the varying behavior into methods on the classes; use sealed hierarchies with exhaustive switches only for closed sets of types.
MistakeWorrying about virtual call overhead in I/O-bound code.
InsteadMeasure first; dispatch costs nanoseconds and JIT compilers often remove it. Reserve dispatch-free code for proven hot loops.
Summary and key terms
- Polymorphism lets one call in the source run different code depending on the actual object.
- The static type controls what the compiler allows; the dynamic type decides which override runs.
- Dynamic dispatch is usually implemented with vtables: a hidden pointer per object and a table of method pointers per class.
- C++ dispatches dynamically only for virtual functions through references or pointers; beware slicing and missing virtual destructors.
- Overloading is resolved at compile time by static types; overriding at run time by the receiver's dynamic type. Python uses duck typing.
- Polymorphism
- The ability of one interface or call to work with objects of different types, each responding in its own way.
- Dynamic dispatch
- Choosing at run time which method body to execute, based on the object's actual class.
- Static type
- The declared type of a variable or expression, checked by the compiler.
- Vtable
- A per-class table of pointers to the class's virtual method implementations.
- Object slicing
- In C++, copying a derived object into a base-class value so that only the base part is kept.
- Duck typing
- Treating any object with the required methods as suitable, regardless of its class.
Practice problems
Work each problem on paper first, then reveal the answer to check.
CounterNode n = new CameraNode(...); Which describe runs for n.describe()? Can you call n.modelVersion()?
Show answer
CameraNode.describe runs. n.modelVersion() does not compile, because CounterNode does not declare it.
A class has log(Object o) and log(String s). Variable x is declared as Object but refers to a String. Which runs for log(x)?
Show answer
log(Object), because overloads are chosen by the static type of the argument.
How many memory loads does a typical virtual call need before the indirect jump?
Show answer
Two: the object's vptr, then the method pointer at a fixed offset in the vtable.
Why must a C++ base class used through pointers have a virtual destructor?
Show answer
So delete through a base pointer runs the derived destructor too; otherwise the derived part is not destroyed properly.
When is a switch over a sealed hierarchy preferable to adding a polymorphic method?
Show answer
When the set of types is closed and stable but new operations are added often, such as new report formats.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.