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 | browse on GitHub |
Category: Class Layout / Vtable | Verdict: BREAKING
What breaks¶
The Sensor class gains a virtual destructor and read() becomes virtual. This
introduces a vtable pointer (vptr) as a hidden member at the beginning of
the object. On x86-64, this adds 8 bytes to the object size and shifts every
data member to a higher offset:
| Member | v1 offset | v2 offset | Shift |
|---|---|---|---|
| (vptr) | โ | 0 | new |
value_ |
0 | 8 | +8 |
id_ |
8 | 16 | +8 |
| sizeof | 16 | 24 | +8 |
Any consumer compiled against v1 that accesses value_ at offset 0 will instead
read the vtable pointer โ interpreting a memory address as a double, producing
astronomically wrong values.
Why this matters¶
Adding the first virtual method to a class is one of the most destructive ABI changes possible, because it fundamentally alters the object layout:
- Object size increases:
sizeof(Sensor)grows bysizeof(void*), breaking stack allocation, arrays, and embedding in other structs - All fields shift: every data member moves to accommodate the vtable pointer, causing every field access to read/write the wrong memory
- Construction changes: the constructor now initializes the vtable pointer, adding a hidden write that wasn't there before
- Copy semantics change: memcpy of the object must include the vtable pointer
- It's a one-way door: once virtual, removing virtuality is equally breaking
This is especially common when adding: - A virtual destructor (for proper cleanup through base pointers) - A virtual method for plugin/extension APIs - A virtual method for testing/mocking
Code diff¶
// v1: non-virtual class (no vtable pointer)
class Sensor {
public:
double value_; // offset 0
int id_; // offset 8
Sensor(int id, double initial);
double read() const; // non-virtual
};
// sizeof(Sensor) = 16
// v2: virtual methods added (vtable pointer inserted!)
class Sensor {
public:
double value_; // offset 8 (shifted!)
int id_; // offset 16 (shifted!)
Sensor(int id, double initial);
virtual ~Sensor(); // NEW virtual destructor
virtual double read() const; // NOW virtual
};
// sizeof(Sensor) = 24
Real Failure Demo¶
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. The app accesses s->id_ at v1 offset 8, but v2 placed value_
(98.6) there โ interpreting the double's bytes as an int yields 1717986918.
For heap-allocated objects the library created a 24-byte object correctly, but
the app's direct field access uses v1 offsets and reads the wrong data.
How to fix¶
- Design for virtuality from the start: if a class might ever need virtual methods, add a virtual destructor in v1 to reserve the vtable pointer slot
- Use the Pimpl idiom: hide the implementation behind a pointer to avoid exposing the class layout
- Use C-style opaque handles:
typedef struct Sensor Sensor;with factory functions โ sizeof is never exposed - SONAME bump: if virtual methods must be added, bump the major version
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 documents this as one of the "cardinal sins" of ABI breakage, requiring a SONAME bump if violated.
The Chromium project's Blink engine has encountered this when refactoring DOM
classes โ adding virtuality to Node subclasses required rebuilding all
downstream components.
abicheck detection¶
abicheck detects this primarily as func_virtual_added (BREAKING) โ a virtual
method was introduced to a class that previously had none. Depending on the
analysis depth (DWARF-aware, header-aware), additional change kinds such as
type_size_changed and type_vtable_changed may also be reported, providing
further evidence of the vtable-insertion break.
References¶
- Itanium C++ ABI ยง2.4 โ Virtual Table Layout
- KDE ABI Policy โ Binary Compatibility Issues
- Qt ABI Stability Guidelines
Source files¶
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.