Skip to content

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

abidw --out-file v1.xml libfoo_v1.so
abidw --out-file v2.xml libfoo_v2.so
abidiff v1.xml v2.xml

References


Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.