Case 37: Base Class Changes¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
type_base_changed |
| Source files | examples/case37_base_class/ |
Category: Type Layout | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
Three independent base-class changes land in one release: ReorderDemo
swaps its base order, VirtualDemo's base becomes virtually inherited, and
AddBaseDemo gains a second base class. Each one changes the derived
object's layout โ which sub-object sits at offset 0, where this-pointer
adjustments land, and the object's total size. Any binary built against v1
that casts through a base pointer, calls a virtual method, or allocates one
of these types is working with stale offsets once the new library loads;
recompilation is mandatory.
Old/new diff¶
| v1.hpp | v2.hpp |
|---|---|
class ReorderDemo : public Logger, public Serializer |
class ReorderDemo : public Serializer, public Logger (order swapped) |
class VirtualDemo : public Logger |
class VirtualDemo : public virtual Logger (became virtual) |
class AddBaseDemo : public Logger |
class AddBaseDemo : public Logger, public Serializer (base added) |
abicheck command¶
g++ -shared -fPIC -g -femit-class-debug-always v1.cpp -o libfoo_v1.so
g++ -shared -fPIC -g -femit-class-debug-always v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so
-femit-class-debug-always matters here: none of ReorderDemo,
VirtualDemo, or AddBaseDemo declares its own virtual function (their
virtuals are all inherited), so GCC has no "key function" to anchor a
complete class DIE to and โ by default โ emits only a forward-declaration
stub for them in this translation unit, dropping the DW_TAG_inheritance
entries abicheck needs. The flag forces GCC to always emit the complete
class layout in DWARF.
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_size_changed: Size changed: VirtualDemo (128 -> 192 bits)
> Old code allocates or copies the type with the old size;
heap/stack corruption, out-of-bounds access.
- type_base_changed: Base classes changed: VirtualDemo (['Logger'] -> [])
> Base class layout change shifts derived member offsets and vtable
pointers; this-pointer arithmetic breaks.
- type_size_changed: Size changed: AddBaseDemo (128 -> 256 bits)
- type_base_changed: Base classes changed: AddBaseDemo
(['Logger'] -> ['Logger', 'Serializer'])
- base_class_offset_changed: Base class 'Serializer' moved within
'ReorderDemo' (128 -> 0 bits)
> A base-class subobject moved to a different offset within the derived
object. The this-pointer adjustment for that base and every field
after it shifts; old binaries read the wrong addresses.
- base_class_offset_changed: Base class 'Logger' moved within 'ReorderDemo'
(0 -> 128 bits)
- base_class_position_changed: Base class order reordered: ReorderDemo โ
this-pointer adjustments changed (['Logger', 'Serializer'] ->
['Serializer', 'Logger'])
- base_class_virtual_changed: Base class virtual inheritance changed:
VirtualDemo โ became virtual: ['Logger'] ([] -> ['Logger'])
Minimum evidence¶
min_evidence: L1 โ DWARF's DW_TAG_inheritance entries (base type,
offset, and virtual-ness) plus each class's DW_AT_byte_size are enough to
detect all three changes; no public headers required. The caveat above
(-femit-class-debug-always) is about getting complete DWARF out of GCC
for classes with no class-local key function, not about needing a higher
evidence tier โ headers are still not required.
Why abicheck catches it¶
DWARF encodes each base class as a DW_TAG_inheritance child of the
derived DW_TAG_class_type, carrying the base's type, its
DW_AT_data_member_location offset, and a DW_AT_virtuality flag for
virtual bases; abicheck compares each derived type's base list, per-base
offsets, and virtual-ness directly from debug info in both versions.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: app builds ReorderDemo/VirtualDemo/AddBaseDemo against
v1's layout, calls their methods through base pointers and direct calls,
then the library is swapped for v2 without recompiling the app.
# Build old library + app
g++ -shared -fPIC -g -femit-class-debug-always v1.cpp -o libfoo.so
g++ -g app.cpp -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# โ ReorderDemo: log_level=11 format=21
# โ VirtualDemo: log_level=77
# โ AddBaseDemo: log_level=33
# โ exit 0
# Swap in new library (no recompile)
g++ -shared -fPIC -g -femit-class-debug-always v2.cpp -o libfoo.so
./app
# โ Segmentation fault
# โ exit 139
Why CRITICAL: the app's this-pointer adjustments, vtable slot
assumptions, and object-size allocations are all baked in for v1's layout.
Against v2's reordered/virtual/widened bases those assumptions are wrong
from the first virtual call, crashing the process outright rather than
producing recoverable bad output.
Safe redesign¶
Never reorder, virtualize, or add a base class to a public class after release โ each is a layout change with no source-compatible mitigation short of a SONAME bump. If a type genuinely needs a new capability, add it as a non-base member or a separate interface obtained through a factory function, and keep object layout behind an opaque pointer (PIMPL) for anything callers allocate directly.
Real-world example: Qt's public classes went through this exact
problem historically, which is why Qt adopted the d-pointer (PIMPL)
idiom project-wide โ base-class and member layout of QObject-derived
classes can never change without breaking every compiled plugin.
Cross-tool comparison¶
abidiff/abi-compliance-checker also detect base-class layout changes
(reordering, virtual-inheritance conversion, added base) via their own
DWARF-based inheritance-graph comparison:
References¶
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hppv2.cppv2.hpp
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.