Case 117: [[no_unique_address]] Layout Overlay (no dedicated ChangeKind)¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux |
| Flags | ABI break |
Detected ChangeKinds |
type_size_changed, type_field_offset_changed |
| Source files | examples/case117_no_unique_address/ |
Category: Modern C/C++ Contract | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
v1 stores an empty, stateless EmptyPolicy as an ordinary member of
Widget, so it occupies at least one byte and forces the following long
value member to be padded out to offset 8 (sizeof(Widget) == 16). v2
marks that same member [[no_unique_address]] (C++20), letting the compiler
overlay it with value โ Widget shrinks to 8 bytes and value moves to
offset 0. A consumer compiled against the v1 layout reads/writes value at
the wrong offset once linked against v2; recompilation is mandatory.
Old/new diff¶
| v1.cpp | v2.cpp |
|---|---|
struct Widget { EmptyPolicy policy; long value; }; |
struct Widget { [[no_unique_address]] EmptyPolicy policy; long value; }; |
abicheck command¶
g++ -std=c++20 -shared -fPIC -g v1.cpp -o libfoo_v1.so
g++ -std=c++20 -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_size_changed: Size changed: Widget (128 -> 64 bits)
> Old code allocates or copies the type with the old size;
heap/stack corruption, out-of-bounds access.
Affected symbols: widget_value(Widget const*)
- type_field_offset_changed: Field offset changed: Widget::value (64 -> 0 bits)
> Old code reads/writes fields at stale offsets; silent data corruption.
Affected symbols: widget_value(Widget const*)
There is no [[no_unique_address]]-specific ChangeKind โ that's the point
of this case. The overlay manifests as a plain layout change abicheck
already catches with the existing structural kinds above; no dedicated
detector is needed for this C++20 feature.
Minimum evidence¶
min_evidence: L1 โ DWARF's struct-size and member-offset facts
(DW_TAG_structure_type size + each member's DW_AT_data_member_location)
directly show Widget shrinking and value moving; no public headers
required. This case ships no header at all (v1.cpp/v2.cpp are
self-contained) โ the snapshot is taken purely from the compiled library's
DWARF, which is also why it's Linux-only.
Why abicheck catches it¶
abicheck's layout diff (diff_types.py) compares each struct's total byte
size and each member's offset directly from DWARF, independent of why the
layout changed. [[no_unique_address]] is a source-level attribute the
compiler translates into an ordinary offset/size difference in the emitted
debug info, so the general-purpose type_size_changed /
type_field_offset_changed detectors surface it without needing to know
anything about the attribute itself.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile a consumer against the v1 Widget layout, swap in v2
.so without recompile.
This case's own app.cpp is a minimal stub (int main() { return 0; }) โ
it links against the library but never touches Widget, so swapping the
.so produces no observable difference by itself:
g++ -std=c++20 -shared -fPIC -g v1.cpp -o libfoo.so
g++ -std=c++20 -g app.cpp -L. -lfoo -Wl,-rpath,. -o app
./app; echo "exit: $?"
# โ exit: 0
g++ -std=c++20 -shared -fPIC -g v2.cpp -o libfoo.so
./app; echo "exit: $?"
# โ exit: 0 (app.cpp never constructs a Widget)
To actually exercise the layout mismatch, a small illustrative program (not
part of the committed case files) that constructs a v1-layout Widget and
calls the real widget_value() shows the corruption directly:
# demo.cpp declares the v1 (16-byte) Widget layout and sets value = 42
g++ -std=c++20 -shared -fPIC -g v1.cpp -o libfoo.so
g++ -std=c++20 -g demo.cpp -L. -lfoo -Wl,-rpath,. -o demo
./demo
# โ widget_value = 42 (sizeof(Widget)=16)
g++ -std=c++20 -shared -fPIC -g v2.cpp -o libfoo.so # swap, no recompile
./demo
# โ widget_value = 0 (sizeof(Widget)=16) โ WRONG, silently reads 0
Why CRITICAL: the caller's Widget still has value at offset 8 (v1
layout), but v2's widget_value() reads offset 0 (where EmptyPolicy used
to sit before the overlay) โ a silent, wrong result with no crash.
Safe redesign¶
Don't retrofit [[no_unique_address]] onto a member of an already-public
struct โ it's exactly as disruptive as manually removing padding. Apply it
only to members of new types, or hide the struct behind an opaque pointer
(PIMPL, see case07) so the concrete layout is never part of the ABI.
Real-world example: libraries adopting [[no_unique_address]] for
empty-base-style policy/allocator/comparator members during a "modernize to
C++20" pass are the classic trigger โ it looks like a pure size-reduction
optimization but silently renumbers every subsequent member's offset.
Cross-tool comparison¶
abidw/abidiff are not installed in this environment, so no cross-tool
output is reproduced here. For reference, the equivalent libabigail
invocation would be:
Source files¶
CMakeLists.txtapp.cppv1.cppv2.cpp
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.