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 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:

  1. Object size increases: sizeof(Sensor) grows by sizeof(void*), breaking stack allocation, arrays, and embedding in other structs
  2. All fields shift: every data member moves to accommodate the vtable pointer, causing every field access to read/write the wrong memory
  3. Construction changes: the constructor now initializes the vtable pointer, adding a hidden write that wasn't there before
  4. Copy semantics change: memcpy of the object must include the vtable pointer
  5. 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

  1. 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
  2. Use the Pimpl idiom: hide the implementation behind a pointer to avoid exposing the class layout
  3. Use C-style opaque handles: typedef struct Sensor Sensor; with factory functions โ€” sizeof is never exposed
  4. 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


Source files

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