Case 38: Virtual Method Changes¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
โ |
| Source files | browse on GitHub |
Category: C++ Virtual / Deleted | Verdict: BREAKING
What changes¶
| Version | Definition |
|---|---|
| v1 | transform() is non-virtual; validate() is virtual; execute() is virtual; copy ctor is user-defined |
| v2 | transform() becomes virtual; validate() loses virtual; execute() becomes pure virtual (= 0); copy ctor is = delete |
Why this is a binary ABI break¶
Each change corrupts the vtable layout that existing binaries were compiled against:
transform()became virtual โ a new vtable slot is inserted. The class gains a vptr if it didn't already have one at that offset, and existing vtable indices shift.validate()lost virtual โ the vtable slot is removed. Old binaries dispatching through the vtable at the old index now call the wrong function or dereference garbage.execute()became pure virtual โ the vtable slot now points to__cxa_pure_virtual. Any old binary that instantiatesProcessordirectly (which was legal in v1) will trigger__cxa_pure_virtual, which callsstd::abort()(SIGABRT), not a segmentation fault. A segfault would only occur if the vtable slot were null or corrupted.- Copy ctor deleted โ old binaries that were linked against the copy constructor symbol will get an undefined symbol error. With the default ELF lazy binding, the error occurs when the copy constructor is first called (not at process startup). To force a deterministic startup-time failure, link with
-Wl,-z,nowor setLD_BIND_NOW=1at runtime.
Code diff¶
class Processor {
public:
- void transform(int data);
+ virtual void transform(int data);
- virtual void validate(int data);
+ void validate(int data);
- virtual void execute();
+ virtual void execute() = 0;
- Processor(const Processor &other);
+ Processor(const Processor &other) = delete;
Processor() = default;
virtual ~Processor() = default;
};
Real Failure Demo¶
Severity: CRITICAL
Scenario: compile app against v1, swap in v2 .so without recompile.
# Build v1 lib + app (calls dispatch through Processor& to force vtable use)
g++ -shared -fPIC -g v1.cpp -o libprocessor.so
g++ -g app.cpp -I. -L. -lprocessor -Wl,-rpath,. -o app
./app
# โ Calling transform(42)...
# โ Calling validate(10)...
# โ Calling execute()...
# โ MyProcessor::execute() called
# Swap to v2 (no recompile of app)
g++ -shared -fPIC -g v2.cpp -o libprocessor.so
./app
# โ vtable corruption: validate() dispatches to the wrong function,
# โ and execute() may call __cxa_pure_virtual โ abort (SIGABRT).
Copy constructor scenario (separate test โ copy_ctor_demo.cpp):
g++ -g copy_ctor_demo.cpp -I. -L. -lprocessor -Wl,-rpath,. -o copy_ctor_demo
./copy_ctor_demo # works with v1
# Swap to v2:
g++ -shared -fPIC -g v2.cpp -o libprocessor.so
./copy_ctor_demo
# โ undefined symbol error for Processor copy ctor when the
# constructor is called (lazy binding resolves at call time).
# To fail at startup instead: LD_BIND_NOW=1 ./copy_ctor_demo
Why CRITICAL: Vtable layout is baked into the calling binary at compile time. Any
change to the number or order of virtual methods silently corrupts dispatch. The deleted
copy constructor removes a symbol entirely, causing immediate load failure. These two
scenarios are exercised independently (app.cpp for vtable, copy_ctor_demo.cpp for
the deleted copy constructor).
Reproduce manually¶
g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -shared -fPIC -g v2.cpp -o libv2.so
abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml
echo "exit: $?" # โ 12 (ABI change + breaking)
How to fix¶
Never change the virtual-ness of existing methods in a stable ABI. To add new virtual methods, append them (do not reorder), and bump the SONAME. Pure virtual additions require a major version bump since they break all existing concrete subclasses.
Runtime note¶
Scenario B exits with SIGABRT (signal 6, shell exit 134) when libv2.so is swapped in. __cxa_pure_virtual calls std::abort(). This is a detected break (non-zero exit), even though exit code is 134, not 2. Any non-zero exit in the runtime validator = incompatible.
This app may still run after swap because it does not exercise all affected ABI surfaces (for example deleted-copy-constructor call paths) on every toolchain. The ABI contract is still BREAKING due to class/vtable changes.
References¶
Source files¶
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.