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¶
References¶
- Itanium C++ ABI ยง2.5.3: Virtual Covariant Thunks
- KDE: Binary Compatibility โ Do not add base classes
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.