// project overview
Inheritance-Based vs Interface-Based Polymorphism (and Monomorphism)
This project asks one question in four languages: when a program calls speak(), how does the machine decide which code actually runs — and when does it stop having to decide at all?
Each language implementation (C, C++, java-implimentation, and rust-implimentation) walks through the same four scenarios using the same cast of characters (Dog, Cat, PureBreedDog). The scenarios are:
- Inheritance-Based Polymorphism — a base class declares an overridable method; subclasses override it; a call through a base-typed reference is resolved at runtime.
- Inheritance-Based Polymorphism w/ Defaults — a base class provides a concrete method that subclasses inherit unchanged, alongside an abstract method they must override. Demonstrates that inheritance carries both a dispatch contract and shared, non-overridden implementation.
- Interface-Based Polymorphism — a contract (interface / pure abstract class / trait) declares a method with zero shared implementation or state; multiple unrelated types implement it; a call through the interface-typed reference is resolved at runtime.
- Interface-Based Monomorphism — the compiler/runtime can prove there is only one possible target for a call (a
final/sealed type, or a generic instantiated for one concrete type), so the indirection collapses to a direct call — or disappears into an inlined instruction stream entirely.
The throughline across all four languages: polymorphism is dispatch through an indirection (a pointer to a function or a table of them); monomorphism is the compiler/runtime proving that indirection is unnecessary and removing it. The four implementations differ mainly in where that indirection lives (a class vtable slot vs. an interface itable vs. a hand-rolled function pointer vs. a trait object's fat pointer) and who is able to eliminate it (a JIT devirtualizing a final call, or a compiler monomorphizing a generic before it ever emits code).
Why four languages
| Language | Native inheritance? | Native interfaces? | Dispatch mechanism | Source |
|---|---|---|---|---|
| C | No — simulated with structs | No — simulated with structs | Hand-rolled function-pointer table, called through a struct field | C Implimentation.c |
| C++ | Yes (class/virtual) |
Simulated via pure abstract classes | Compiler-generated vtable, dispatched through a hidden vptr | C++ Implimentation.cpp |
| Java | Yes (extends) |
Yes (interface) |
JVM vtable (classes) vs. itable (interfaces), selected by distinct bytecode instructions | java-implimentation/src |
| Rust | No — no struct/class inheritance | Yes (trait) |
Trait objects (dyn Trait, vtable via fat pointer) for polymorphism; generics for monomorphism |
rust-implimentation/src/main.rs |
Java implementation files
Java is the only language here split across four files instead of one — there's no single "main" source file to link, so each part gets its own row:
| Part | File |
|---|---|
| 1 — Inheritance-Based Polymorphism | Part1_InheritancePolymorphism.java |
| 2 — Inheritance-Based Polymorphism w/ Defaults | Part2_InheritanceWithDefaults.java |
| 3 — Interface-Based Polymorphism | Part3_InterfacePolymorphism.java |
| 4 — Interface-Based Monomorphism | Part4_InterfaceMonomorphism.java |
The full java-implimentation/src directory (including the shared Animal/Dog/Cat types) is also browsable on GitHub.
C and Rust are the two edge cases, and both are informative for opposite reasons:
- C has no language-level classes or interfaces at all, so both Part 1 (inheritance) and Part 3 (interface) are simulated with the exact same mechanism — a
structwhose first field is a function pointer. In C, the difference between "inheritance-based" and "interface-based" polymorphism is purely a naming/documentation convention; the machine code is identical in shape. This is useful precisely because it strips away the compiler and shows what Java's and C++'s vtables/itables are doing under the hood. - Rust has interfaces (
trait) but no inheritance — there is noextends-equivalent for structs, so Parts 1 and 2 are not implemented inrust-implimentation; only Parts 3 and 4 exist there. This demonstrates that inheritance-based and interface-based polymorphism are not the same axis: a language can drop one (Rust drops inheritance) while keeping the other (interfaces, astraits) fully expressive, including monomorphization via generics.
Part 1 — Inheritance-Based Polymorphism
A base type declares speak(); Dog and Cat override it; a function takes the base type and calls speak() without knowing the concrete type.
- C++:
Animaldeclaresvirtual void speak(); the compiler installs a vtable pointer in everyAnimal-derived object. Callinga.speak()through anAnimal&loads the vptr and calls through it. - Java:
Animaldeclaresvoid speak();Dog/Cat@Overrideit. The JVM resolves the call viainvokevirtual, walking the class's method table. - C: simulated —
Animalis astructwhose only field is a function pointerspeak.Dog_init/Cat_initwire that pointer todogSpeak/catSpeak.makeSpeakjust calls through the pointer:a->speak(a). - Rust: not applicable — no struct inheritance exists, so this scenario has no Rust analogue (see above).
Bytecode/ASM evidence
Java's javap -c output for makeSpeak(Animal) shows the dispatch instruction directly:
public static void makeSpeak(Animal);
Code:
0: aload_0
1: invokevirtual #7 Method Animal.speak:()V
4: return
invokevirtual is the JVM's vtable-based dispatch opcode — the concrete speak() invoked depends on the runtime type of the object on the stack, not the compile-time type of the reference. See the full listing in Part1_InheritancePolymorphism.java.
C's makeSpeak compiles to an explicit indirect call through a struct field — the same shape as invokevirtual, just without a JVM to hide it:
"makeSpeak":
mov rax, QWORD PTR [rbp-8] ; rax = Animal*
mov rdx, QWORD PTR [rax] ; rdx = a->speak (load vtable-slot fn ptr)
mov rax, QWORD PTR [rbp-8]
mov rdi, rax ; arg0 = self
call rdx ; indirect call through the pointer
That call rdx is what invokevirtual and a C++ vptr call compile down to at the machine level: load a function pointer from memory, then call through the register. Nothing in Java or C++ is doing anything more fundamental than this — they just generate the pointer table and the load for you.
Part 2 — Inheritance-Based Polymorphism w/ Defaults
AbstractAnimal provides a concrete, non-overridden method (sleep()) alongside an abstract method (speak()) that DogWithDefault must implement. This isolates what inheritance adds beyond dispatch: shared implementation and shared state (age), which a bare interface cannot carry.
- Java:
abstract class AbstractAnimal { int age; void sleep() {...} abstract void speak(); }. Callingsleep()on anAbstractAnimalreference still compiles toinvokevirtual— Java doesn't special-case "this override doesn't exist" — but there is only ever one possible target because no subclass overrides it. - C++: same structure with
AbstractAnimalas an abstract base (speak()is= 0),sleep()is an ordinary non-virtual member function. - C:
AbstractAnimalis astruct { int age; void (*speak)(...); };Animal_sleepis an ordinary function (not a pointer field) called directly bymakeSleep— because it is never overridden, C doesn't even bother routing it through a function pointer, which is the same optimization a compiler makes for a non-virtual method. - Rust: not applicable, same reason as Part 1.
Bytecode evidence — the contrast with Part 1
public static void makeSleep(AbstractAnimal);
Code:
0: aload_0
1: invokevirtual #7 // Method AbstractAnimal.sleep:()V
4: return
Compare this to Part 1's invokevirtual Animal.speak. The bytecode instruction is identical in kind — the JVM does not know or care that sleep() happens to have only one implementation in this program. That knowledge is exactly what Part 4 (monomorphism) is about: a JIT that observes there is only one live implementation of a virtual method can devirtualize the call at runtime, replacing the vtable load with a direct call — something javap's static bytecode view cannot show, since it happens after JIT profiling. Full listing: Part2_InheritanceWithDefaults.java.
Part 3 — Interface-Based Polymorphism
ISpeaker/Speaker declares speak() with no shared state and no default implementation — a pure contract. InterfaceDog and InterfaceCat (unrelated types, not siblings in a class hierarchy) both implement it.
-
Java:
interface ISpeaker { void speak(); }. The call compiles toinvokeinterface, a distinct opcode frominvokevirtual:public static void makeSpeak(ISpeaker); Code: 0: aload_0 1: invokeinterface #7, 1 // InterfaceMethod ISpeaker.speak:()V 6: returninvokeinterfaceis slower to resolve thaninvokevirtualin the naive case, because the callee's concrete class might implement many unrelated interfaces, so the JVM can't reuse a fixed vtable slot index the way it can for single-inheritance class hierarchies — it walks an itable (interface method table) instead. This is the concrete evidence that "inheritance-based" and "interface-based" polymorphism are dispatched through different lookup structures in a real runtime, not just different syntax. Full listing: Part3_InterfacePolymorphism.java. - C++: interfaces are simulated as pure abstract classes (
class ISpeaker { virtual void speak() = 0; virtual ~ISpeaker() = default; }). C++ has only one dispatch mechanism (the vtable), so — unlike Java — there is no separate "itable" instruction; a pure-abstract-class call and a virtual-base-class call both compile to the same vptr-indirectcallpattern in the ASM (e.g.call qword ptr [rax]for bothAnimal::speakdispatch andISpeaker::speakdispatch). This is a real, observable difference from Java: C++ collapses inheritance-dispatch and interface-dispatch into one mechanism at the machine level, while the JVM keeps them distinct all the way to bytecode. - C:
ISpeakerisstruct { void (*speak)(struct ISpeaker*); }— note it is structurally identical to Part 1'sAnimalstruct.makeSpeakInterfacecompiles to the same indirect-call pattern as Part 1'smakeSpeak. In C, Part 1 and Part 3 are indistinguishable at the machine level; the only difference is which one the programmer chose to call "interface" versus "base class" in a comment, which underscores that inheritance vs. interface is a language/type-system distinction, not a hardware one. - Rust:
trait Speaker { fn speak(&self); }, called through&dyn Speaker, a fat pointer — one word points at the data, the other at a vtable generated per-type. The ASM shows each impl compiled as an independent, directly-addressable symbol (<InterfaceDog as Speaker>::speak); the indirection lives in howmaincalls through the trait object, not in the impl itself. See rust-implimentation.asm and main.rs.
Part 4 — Interface-Based Monomorphism
The whole point of this section: dispatch collapses to a direct call once the compiler/runtime can prove only one implementation is reachable.
-
Java:
PureBreedDogis declaredfinaland implementsISingleSpeaker. The bytecode fordirectSpeakstill showsinvokevirtual—javapreflects only what's provable statically from the class file:
But because the parameter typepublic static void directSpeak(PureBreedDog); Code: 0: aload_0 1: invokevirtual #7 // Method PureBreedDog.speak:()V 4: returnPureBreedDogisfinal, the JIT can (and does, once the method is hot) devirtualize this call — resolve it once, inlinespeak(), and skip the method-table lookup entirely at the machine-code level the JVM actually executes.javap's bytecode is the source the JIT compiles from, not the code that ultimately runs; this is exactly why the bytecode still saysinvokevirtualwhile the underlying dispatch cost is eliminated at runtime. See Part4_InterfaceMonomorphism.java. - C++:
class PureBreedDog final : public ISingleSpeaker. Becausefinalguarantees no further override is possible, the compiler is free to devirtualizeexplicitDog.speak()at compile time — the ASM showscall PureBreedDog::speak()as a direct symbol call, not an indirectcall qword ptr [reg]through a vtable slot (contrast with Part 1/3'scall qword ptr [rax]). This is a case where C++'s ahead-of-time compiler achieves statically what Java's JIT achieves dynamically for the samefinal-driven guarantee. - C:
PureBreedDogis a plainstruct { int dummy; }with no function pointer at all —directSpeakcallsPureBreedDog_speakdirectly by symbol. There is nothing to devirtualize because nothing was virtualized in the first place; C makes the "no indirection" case explicit by construction rather than by optimization. - Rust:
direct_speak<T: SingleSpeaker>(explicit_dog: &T)is generic, not a trait object. The compiler monomorphizes it — generating a distinct, fully-specialized copy ofdirect_speakforT = PureBreedDog— so the call tospeak()is resolved at compile time, no vtable involved. This is the same end state as C++'sfinaldevirtualization and C's plain struct, reached by a third mechanism: instead of proving an existing indirect call can be removed, Rust never emits an indirect call for this path to begin with. See main.rs and rust-implimentation.asm (<PureBreedDog as SingleSpeaker>::speakcompiles to a plain, independently callable symbol, just like theInterfaceDog/InterfaceCatimpls in Part 3 — the difference is howmainreaches it, not how the impl itself is compiled).
Cross-language summary
| Inheritance polymorphism | Inheritance w/ defaults | Interface polymorphism | Monomorphism | |
|---|---|---|---|---|
| C | Simulated: struct + fn-ptr field, indirect call rdx |
Shared fn called directly; only speak ptr is indirect |
Structurally identical simulation to inheritance case | Plain struct, direct call by symbol — nothing to remove |
| C++ | virtual, vtable, indirect call qword ptr [reg] |
Non-virtual method = direct call; virtual speak still indirect |
Pure abstract class → same vptr mechanism as inheritance | final → compiler devirtualizes to direct call Type::method() |
| Java | invokevirtual against class vtable |
invokevirtual, but only one implementation exists |
invokeinterface against itable — distinct opcode |
final → still invokevirtual in bytecode, but JIT devirtualizes/inlines at runtime |
| Rust | N/A — no struct inheritance | N/A | &dyn Trait fat pointer → vtable call |
Generics → compiler monomorphizes, no vtable ever emitted |
Key takeaways
- Polymorphism always costs an indirection — a function-pointer field, a vtable slot, an itable entry, or a fat pointer's vtable half. Every language here expresses that indirection differently, but it's present in every Part 1/2/3 case.
- Java is the only language that distinguishes inheritance-dispatch from interface-dispatch at the instruction level (
invokevirtualvs.invokeinterface); C++ and hand-rolled C collapse both into the same mechanism, and Rust doesn't have inheritance-dispatch to compare against at all. - Monomorphization is reachable by three different routes: C++'s
finallets the compiler prove it ahead of time; Java's JIT proves it at runtime from observed call-site behavior (speculative devirtualization), and it can deoptimize if that assumption is later violated; Rust's generics never create the indirection to begin with, proving it at monomorphization time during compilation. C sidesteps the question entirely by never introducing a function pointer forPureBreedDog. - "Interface" and "inheritance" are type-system concepts, not hardware ones. At the machine-code level, the C implementation shows that both collapse to "a struct holding a function pointer, called indirectly." The distinctions each higher-level language draws (separate opcodes, separate keywords,
final/sealedannotations) exist to give the compiler and JIT more information about which indirections are safe to eliminate — that's the entire practical payoff of the distinction.
Where to look
- Generated API docs (Doxygen/javadoc/rustdoc) for each language live under
docs/C docs,docs/C++ docs,docs/java docs,docs/Rust docs. - Full disassembly referenced above:
- C Implimentation/C Implimentation.asm
- C++ Implimentation/C++ implimentation.asm
- rust-implimentation/rust-implimentation.asm
- Java bytecode is embedded directly in the Javadoc comments of each
Part*.javasource file (viajavap -c), since the JVM doesn't ship a standalone.asm-equivalent artifact the way native compilers do.