Case 68: Virtual Method Added to Non-Virtual Class¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
func_virtual_added |
| Source files | examples/case68_virtual_method_added/ |
Category: Class Layout / Vtable | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
Sensor gains a virtual destructor and read() becomes virtual. The
compiler prepends a vtable pointer to the object, growing sizeof(Sensor)
from 16 to 24 bytes and shifting every data member 8 bytes higher. A
consumer compiled against v1 that reads value_ at offset 0 instead reads
the vtable pointer; reading id_ at offset 8 instead reads value_.
Recompilation is mandatory.
Old/new diff¶
| Member | v1 offset | v2 offset |
|---|---|---|
| (vptr) | โ | 0 (new) |
value_ |
0 | 8 |
id_ |
8 | 16 |
| sizeof | 16 | 24 |
// v1: non-virtual
double read() const;
// v2: virtual destructor + virtual read()
virtual ~Sensor();
virtual double read() const;
abicheck command¶
g++ -shared -fPIC -g v1.cpp -o libfoo_v1.so
g++ -shared -fPIC -g v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_size_changed: Size changed: Sensor (128 -> 192 bits)
> Old code allocates or copies the type with the old size; heap/stack
corruption, out-of-bounds access.
- type_field_offset_changed: Field offset changed: Sensor::value_ (0 -> 64 bits)
> Old code reads/writes fields at stale offsets; silent data corruption.
- type_field_offset_changed: Field offset changed: Sensor::id_ (64 -> 128 bits)
> Old code reads/writes fields at stale offsets; silent data corruption.
- type_vtable_changed: vtable changed: Sensor ('' -> 'Sensor::~Sensor(), Sensor::read() const')
> Vtable slot reordering; virtual dispatch calls wrong method.
- vptr_introduced: 'Sensor' gained a vtable pointer (became polymorphic).
sizeof grows and every data member's offset shifts by a pointer width;
binaries that embed or derive from the type are laid out incompatibly.
(non-polymorphic -> vptr@0)
Deployment risk:
- imported_symbol_added: New imported symbol:
vtable for __cxxabiv1::__class_type_info@CXXABI_1.3
Minimum evidence¶
min_evidence: L1 โ DWARF's DW_TAG_structure_type layout for Sensor
already carries the vtable pointer and every member's shifted offset in
both binaries; abicheck compares them directly from debug info (-g), no
public headers required.
Why abicheck catches it¶
DWARF records Sensor's total size and each member's byte offset for both
versions, plus the presence of a vtable-pointer member and the vtable's
virtual-function-slot list; abicheck's layout diff spots the introduced
vptr, the size growth, and every field's offset shift directly from that
debug info.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile app against v1 (non-virtual, 16 bytes), link to v2 .so.
# Build old library + app
g++ -shared -fPIC -g v1.cpp -o libsensor.so
g++ -g app.cpp -L. -lsensor -Wl,-rpath,. -o app
./app
# โ sizeof(Sensor) = 16 (v1=16, v2=24)
# โ id = 7 (expected 7)
# โ value = 98.6 (expected 98.6)
# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o libsensor.so
./app
# โ sizeof(Sensor) = 16 (v1=16, v2=24) โ app thinks 16, reality is 24
# โ id = 1717986918 (expected 7) โ reads value_ bytes as int!
# โ value = 0.0 (expected 98.6) โ reads vtable pointer as double!
# โ CORRUPTION: id_ at v1 offset 8 reads v2's value_ field!
# โ CORRUPTION: value_ at v1 offset 0 reads v2's vtable pointer!
Why CRITICAL: the app accesses s->value_ at v1 offset 0, but v2 placed
the vtable pointer there โ the app reads an address as a double, getting
0.0 or garbage. s->id_ at v1 offset 8 now lands on value_ (98.6),
whose bytes reinterpreted as int yield 1717986918. The library itself
allocates the correct 24-byte object; it's the app's direct field access
using v1 offsets that reads the wrong data.
Safe redesign¶
If a class might ever need virtual methods, add a virtual destructor from the start to reserve the vtable slot, or hide the layout entirely behind the Pimpl idiom / a C-style opaque handle. If virtuality must be added later, bump the SONAME.
Real-world example: Qt's QObject has been virtual since Qt 1.0
specifically to avoid this problem โ the Qt ABI guidelines explicitly
state "never add a virtual function to a class that previously had none."
The KDE Frameworks ABI policy lists this as one of the "cardinal sins" of
ABI breakage, requiring a SONAME bump if violated.
Cross-tool comparison¶
References¶
- Itanium C++ ABI ยง2.4 โ Virtual Table Layout
- KDE ABI Policy โ Binary Compatibility Issues
- Qt ABI Stability Guidelines
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.