Skip to content

Case 185: Inherited override reuses the base's vtable slot

Field Value
Verdict 🟢 COMPATIBLE
Category Addition (Compatible)
Classification Rule
Platforms Linux
Flags —
Detected ChangeKinds func_added
Source files catalog/cases/case185_inherited_override_reuses_slot/
Rule family inherited-override-reuses-slot
Subject Vtable and virtual dispatch changes

Category: Addition | Verdict: ✅ COMPATIBLE (exit 0)

What changes

Base::paint(int) is a virtual method. In v1, Derived does not override it — calls dispatch straight through to Base::paint(). In v2, Derived adds int paint(int x) override; with the exact same signature as Base::paint(int). Derived also keeps its unrelated, non-virtual helper() method.

Why this is not a new vtable slot

A naive "did a new virtual method appear on this class?" scan would see Derived::paint show up where it didn't exist before and flag it as VIRTUAL_METHOD_ADDED — normally a BREAKING finding, since inserting a slot shifts every subsequent entry in the vtable and desyncs old callers.

But overriding an already-virtual base method with a matching signature doesn't insert anything — it replaces the function pointer at the same slot index Base::paint already occupied. Derived's vtable is the same size before and after:

vtable for Derived: 40 bytes (5 pointers: offset-to-top, RTTI, ~Derived,
                    ~Derived (deleting), paint) -- unchanged in both versions

abicheck/diff_cxx_rules.py builds old_virtual_signatures() — a per-class set of leaf-name(params)cv-ref identities — from the old snapshot's class hierarchy, walking transitive bases. When virtual_method_addition() sees Derived::paint(int) in the new snapshot, it checks whether any base in Derived's inheritance chain already had a virtual with that exact signature key. Here Base::paint(int) matches, so the new function is recognized as an override reusing an inherited slot — no VIRTUAL_METHOD_ADDED/vtable-layout finding is emitted for it at all. What is reported is a plain, additive func_added for the newly-materialized Derived::paint(int) symbol (it didn't have its own mangled symbol before, since calls went through Base::paint directly) — a COMPATIBLE addition, not a break.

abicheck compare libv1.so libv2.so --header old=v1.hpp --header new=v2.hpp
# verdict: COMPATIBLE (exit 0)
# func_added: New public function: paint

Negative twin: same name, different signature does add a slot

If Derived instead declared virtual int paint(double x); — same method name, but a signature that does not match any inherited virtual — there is no existing slot to reuse. This genuinely grows Derived's vtable by one entry (40 → 48 bytes) and is correctly reported as BREAKING:

vtable_slot_count_changed: Vtable for 'Derived' changed size: 40 -> 48 bytes
virtual_method_added: New virtual method added to existing class Derived: paint

See tests/test_kde_compat_detectors.py::test_inherited_override_is_not_virtual_method_added and ::test_same_name_different_signature_virtual_is_new_slot for the unit-level pair this compiled example mirrors.

Two detectors, one exemption

diff_types.py's _diff_type_vtable() independently compares each class's list of vtable entries. Left on its own, it would just see Derived's vtable entry list textually change (Base::paint's slot now names Derived::paint), even though its length and order are identical, and unconditionally emit type_vtable_changed — disagreeing with virtual_method_addition()'s slot-reuse exemption above and reporting BREAKING for a case that is genuinely COMPATIBLE.

_diff_type_vtable() calls diff_cxx_rules.vtable_slot_is_override_reuse() — the same signature-key comparison virtual_method_addition() uses — to recognize when a differing vtable slot is exactly this override-reuse relationship, and withholds TYPE_VTABLE_CHANGED for it too. The two detectors now agree:

abicheck compare libv1.so libv2.so --header old=v1.hpp --header new=v2.hpp
# verdict: COMPATIBLE
# func_added: New public function: paint

Reaching that agreement at the castxml layer, on real compiled binaries, also needed a fix one level lower: the castxml backend's build_vtable() (extract/headers/castxml/records.py) previously deduplicated an override against the base slot it reuses only via castxml's vtable_index attribute. Not every castxml/Clang build emits that attribute, and without it the reused slot was never deduplicated at all — Derived's reconstructed vtable listed both Base::paint and Derived::paint, one entry longer than the real vtable, which vtable_slot_is_override_reuse() (a same-length, positional check) cannot see through. build_vtable() now falls back to castxml's overrides attribute (resolved through any multi-level override chain) to collapse the reused slot in place when vtable_index is absent.

How to reproduce

cmake -S examples -B /tmp/abicheck-examples-build
cmake --build /tmp/abicheck-examples-build --target \
    case185_inherited_override_reuses_slot_v1 case185_inherited_override_reuses_slot_v2

cat > .abicheck.yml <<'EOF'
compile:
  lang: c++
EOF
python3 -m abicheck.cli compare \
    /tmp/abicheck-examples-build/case185_inherited_override_reuses_slot/libv1.so \
    /tmp/abicheck-examples-build/case185_inherited_override_reuses_slot/libv2.so \
    --header old=v1.hpp --header new=v2.hpp --config .abicheck.yml

Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.hpp
  • v2.cpp
  • v2.hpp

See also: Compatibility Catalog · All COMPATIBLE cases · Category: Addition (Compatible) · Rule: Override reuses an inherited vtable slot · Subject: Vtable and virtual dispatch changes.