Skip to content

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:

abidw --out-file v1.xml libfoo_v1.so
abidw --out-file v2.xml libfoo_v2.so
abidiff v1.xml v2.xml

Source files

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

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.