Case 168: Virtual Method Devirtualized (flush() leaves the vtable)¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
func_virtual_removed |
| Source files | examples/case168_virtual_method_devirtualized/ |
Category: Class Layout / Vtable | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
v2 removes the virtual keyword from Codec::flush() โ a tempting
"optimization" ("nobody overrides it, and non-virtual calls are faster").
The trap: the exported symbol _ZN5Codec5flushEv survives with an
identical signature, so linking and loading succeed. But flush() left
the vtable, and every slot after it shifts up one position. An app compiled
against v1 calls c->flush() virtually โ through the slot index frozen
at its compile time โ and silently lands in reset() instead. No loader
error, no crash: just the wrong method, silently, and any consumer override
of flush() stops being reached at all. Recompilation is mandatory.
Old/new diff¶
| v1.h | v2.h |
|---|---|
virtual int flush(); |
int flush(); /* was virtual */ |
vtable: [D1][D0][encode][flush][reset] |
vtable: [D1][D0][encode][reset] |
abicheck command¶
g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -shared -fPIC -g v2.cpp -o libv2.so
abicheck compare libv1.so libv2.so --header old=v1.h --header new=v2.h --ast-frontend clang
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_vtable_changed: vtable changed: Codec
(~Codec, encode(int), flush(), reset() -> ~Codec, encode(int), reset())
> Vtable slot reordering; virtual dispatch calls wrong method.
- vtable_slot_count_changed: Vtable for 'Codec' changed size: 56 -> 48 bytes
(~5 -> ~4 virtual slots)
- func_virtual_removed: Function is no longer virtual: flush
> Vtable entry removed; old binaries that dispatch through the vtable
call the wrong slot.
The mangled name (_ZN5Codec5flushEv) is unchanged, so abicheck matches
flush across snapshots by symbol and reports the virtuality flip directly
as func_virtual_removed, rather than leaving it as an unexplained vtable
size change.
Minimum evidence¶
min_evidence: L2 โ DWARF alone shows the vtable shrank and slot layout
changed (type_vtable_changed, vtable_slot_count_changed), but naming
which method was devirtualized needs the public header AST's
virtual/non-virtual declaration for flush() on both sides โ DWARF's
DW_AT_virtuality on the surviving flush symbol is not reliably present
once it drops out of the vtable. castxml is the documented default backend
for this evidence layer; clang (--ast-frontend clang, used above) is a
supported alternative AST frontend for hosts without castxml installed.
Why abicheck catches it¶
abicheck matches flush across the two snapshots by its unchanged mangled
symbol, then compares its virtuality flag from each side's header AST.
Losing the virtual keyword โ while the symbol itself survives โ is
reported directly as func_virtual_removed; the vtable's shrunken
_ZTV5Codec size and slot-membership diff independently corroborate the
same break even without headers, though at that tier they can only say the
vtable changed, not name the devirtualized method.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile app against v1, link to v2 .so without recompiling.
# Build old library + app
g++ -shared -fPIC -g v1.cpp -o liblib.so
g++ -g app.cpp -L. -llib -Wl,-rpath,. -o app
./app
# โ encode(21) = 42, 42 (expected 42, 42)
# โ flush() = 2 (expected 2)
# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o liblib.so
./app
# โ encode(21) = 42, 42 (expected 42, 42)
# โ flush() = -1 (expected 2)
# โ CORRUPTION: flush() dispatched to a different vtable slot โ
# the pending count was silently discarded!
Why CRITICAL: the app's virtual call lands in reset() (returns -1,
zeroes the pending counter) instead of flush(). Data is lost while every
call "succeeds" โ no loader error, no crash, no diagnostic anywhere.
Safe redesign¶
- Don't devirtualize shipped methods. If the cost of virtual dispatch
matters, add a non-virtual fast-path alongside (e.g.
flush_fast()), or devirtualize internally while keeping the vtable entry as a forwarding definition. - Let the compiler do it invisibly:
finalon the class/method enables whole-program devirtualization at call sites without changing the vtable layout โ though addingfinalis itself a source-level API event. - SONAME bump if the slot must really go.
Real-world example: the KDE binary-compatibility policy lists "unoverride a virtual function" and any change to the virtual-function order among its forbidden changes: vtable slot indexes are compiled into every caller, so this is exactly as one-way as adding a new virtual method.
Cross-tool comparison¶
References¶
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.