Skip to content

Case 72: Covariant Return Type Changed

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS, Windows
Flags ABI break, API break
Detected ChangeKinds type_base_changed, type_vtable_changed, func_return_changed
Source files examples/case72_covariant_return_changed/

Category: VTable / Inheritance | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

v2 inserts a new intermediate class Drawable between Shape and Circle. Circle::clone()'s covariant return type changes from Circle* to Drawable*, Circle's vtable grows (new Drawable slots plus RTTI), and Circle::radius_ moves from offset 64 to offset 96 bits to make room for the Drawable subobject. A binary compiled against v1 has the old vtable slot count and field offsets baked in โ€” virtual dispatch and direct field access both land on the wrong memory. Recompilation is mandatory.

Old/new diff

v1.h v2.h
class Circle : public Shape class Drawable : public Shape { ... };
Circle *clone() const override; class Circle : public Drawable
Drawable *clone() const override; (covariant return changed)

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_field_offset_changed: Field offset changed: Circle::radius_ (64 -> 96 bits)
  > Old code reads/writes fields at stale offsets; silent data corruption.
- type_base_changed: Base classes changed: Circle (['Shape'] -> ['Drawable'])
  > Base class layout change shifts derived member offsets and vtable
    pointers; this-pointer arithmetic breaks.
- vtable_slot_count_changed: Vtable for 'Circle' changed size: 48 -> 56 bytes
  (~4 -> ~5 virtual slots). A virtual method was added, removed, or
  reordered; existing binaries dispatch through fixed vtable offsets and
  will call the wrong slot.

Additions:
- func_added / var_added / type_added for the new Drawable class and its
  vtable/RTTI (Drawable::Drawable(), Drawable::color(), typeinfo/vtable
  for Drawable, ...)

Note: on this build, Circle::clone()'s covariant-return change (Circle* โ†’ Drawable*) is subsumed by the type_base_changed / vtable_slot_count_changed findings above rather than surfacing as its own func_return_changed entry โ€” the hierarchy-level break already captures the same underlying layout shift.

Minimum evidence

min_evidence: L1 โ€” DWARF's DW_TAG_inheritance entries record Circle's base class list and DW_TAG_structure_type records its member offsets and vtable size for both versions; abicheck compares them directly from debug info (-g), no public headers required.

Why abicheck catches it

abicheck reads each class's DWARF base-class list and compares it between versions (type_base_changed); it also compares the _ZTV-backed vtable's symbol size to catch the added Drawable slots, and the struct's member-offset list to catch radius_'s shift โ€” all from debug info, no header parsing needed.

Runtime failure demonstration

Severity: CRITICAL

Scenario: compile app against v1, swap in v2 .so without recompile.

# Build v1 and app
g++ -shared -fPIC -g v1.cpp -o libshape.so
g++ -g app.cpp -L. -lshape -Wl,-rpath,. -o app
./app
# โ†’ clone radius() = 5 (expected 5)
# โ†’ clone area()   = 75 (expected 75)
# โ†’ raw radius(v1 layout) = 5 (expected 5)

# Swap in v2 (hierarchy changed, no recompile)
g++ -shared -fPIC -g v2.cpp -o libshape.so
./app
# โ†’ ./app: Symbol `_ZTV6Circle' has different size in shared object,
#   consider re-linking
# โ†’ clone radius() = 5 (expected 5)
# โ†’ clone area()   = 75 (expected 75)
# โ†’ raw radius(v1 layout) = 0 (expected 5)
# โ†’ WRONG RESULT: covariant return/hierarchy change broke old layout assumptions

Why CRITICAL: the dynamic linker itself warns about the vtable size mismatch. Virtual dispatch (radius()/area()) happens to still resolve correctly here because those slots didn't move, but the app's raw v1-layout struct cast reads radius_ at the old offset 64 bits โ€” which in v2 is no longer where radius_ lives โ€” silently returning 0 instead of 5.

Safe redesign

Never insert a class into an existing public hierarchy without bumping the SONAME. Prefer composition over hierarchy insertion:

/* Safe: composition instead of hierarchy insertion */
class Circle : public Shape {
    Drawable drawable_;              /* has-a instead of is-a */
    Circle *clone() const override;  /* covariant return unchanged */
};

Real-world example: the KDE Binary Compatibility policy explicitly forbids inserting intermediate classes into a public hierarchy for exactly this reason โ€” LLVM's RTTI system (isa<>/dyn_cast<>) would similarly break if a class hierarchy shifted, since its classof() chain encodes the exact hierarchy.

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.