Skip to content

Case 80: Pimpl Alias Switched from shared_ptr to unique_ptr

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS
Flags ABI break, API break
Detected ChangeKinds typedef_base_changed, struct_size_changed
Source files examples/case80_pimpl_shared_to_unique/

Category: Pimpl ABI | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

oneDAL-style pimpl alias: detail::pimpl<T> = std::shared_ptr<T>, and every public class holds its implementation through it: detail::pimpl<detail::descriptor_impl> impl_;. v2 rewrites the alias to std::unique_ptr<T>. sizeof(shared_ptr<T>) (16 bytes, refcount control block) versus sizeof(unique_ptr<T>) (8 bytes, no control block) means the containing descriptor class actually shrinks โ€” the break isn't "grew too big", it's a change in ownership model: mangled names of any pimpl-typed inline accessor differ, the destruction model changes (no atomic refcount to share), and copy semantics flip from refcounted-share to move-only.

Old/new diff

v1.h v2.h
template <typename T> using pimpl = std::shared_ptr<T>; template <typename T> using pimpl = std::unique_ptr<T>;
detail::pimpl<detail::descriptor_impl> impl_; (16 bytes) same declaration, now 8 bytes

abicheck command

g++ -std=c++17 -shared -fPIC -g v1.cpp -o libfoo_v1.so
g++ -std=c++17 -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: mylib::descriptor (128 -> 64 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.
- struct_field_type_changed: Field type changed: mylib::descriptor::impl_
  pimpl(16B) -> pimpl(8B)
  > Field type changed in binary; old code misinterprets the field data.
- typedef_removed: Typedef removed: mylib::detail::pimpl
  (shared_ptr<mylib::detail::descriptor_impl>)
  > Old code using the typedef name won't compile; binary impact depends
    on usage.

Without header scoping, DWARF-only mode also surfaces the full unscoped export table, which adds a further ~7 breaking findings and ~110 deployment-risk findings from libstdc++-internal symbols/typedefs pulled in by <memory> (e.g. operator new, size_t, wint_t) โ€” noise unrelated to this case's actual pimpl-alias change. See "Minimum evidence" below for the scoped, noise-free equivalent.

Minimum evidence

min_evidence: L1 โ€” DWARF alone is enough to reach the BREAKING verdict: descriptor's byte size and impl_'s field-type spelling both change in debug info. Because no public header is passed at this floor, the compare also diffs the entire exported symbol table (including libstdc++ internals pulled in transitively), which is where the extra noise above comes from. Adding public headers (-H + --ast-frontend clang, scoping the surface to v1.h/v2.h) sharpens this to exactly the two kinds examples/ground_truth.json records for this case โ€” typedef_base_changed: pimpl (std::shared_ptr<T> -> std::unique_ptr<T>) and struct_size_changed: mylib::descriptor (16 -> 8 bytes) โ€” with the system-header noise gone.

Why abicheck catches it

impl_'s declared spelling (detail::pimpl<detail::descriptor_impl>) is textually identical on both sides โ€” only the using pimpl = ... alias target changed. DWARF still records the underlying type's byte size shrinking (16 โ†’ 8 bytes) on both the field and the containing descriptor struct, so the layout-diff surfaces the change even without seeing the alias declaration itself; header evidence additionally names the alias flip directly (typedef_base_changed).

Runtime failure demonstration

Severity: BREAKING (ABI shape change, not visible in this minimal app)

Scenario: compile app against v1, swap in v2 .so without recompile. This smoke app only default-constructs and destroys one descriptor โ€” it never copies it or shares ownership across the v1/v2 boundary, so nothing visibly crashes.

# Build old library + app
g++ -std=c++17 -shared -fPIC -g v1.cpp -o libfoo.so
g++ -std=c++17 -g app.cpp -L. -lfoo -Wl,-rpath,. -o app
./app
# โ†’ class_count = 7 (expect 7)

# Swap in new library (no recompile)
g++ -std=c++17 -shared -fPIC -g v2.cpp -o libfoo.so
./app
# โ†’ class_count = 7 (expect 7)   -- looks fine here

Why this is still BREAKING: the field that changed size and ownership model just happens not to be exercised by copy/aliasing in this minimal repro. A consumer that copies a descriptor, or that aliased into the shared control block via a shared_ptr aliasing constructor, would double-free or leak once the field underneath is a unique_ptr.

Safe redesign

Never change a public pimpl alias's target smart-pointer type after release โ€” pick shared_ptr or unique_ptr once and keep it. If ownership semantics genuinely need to change, introduce a new, differently-named alias/type and deprecate the old one across a full release cycle instead of redefining the existing alias in place.

Real-world example: this mirrors oneDAL's detail::pimpl<T> alias convention (namespace oneapi::dal::detail { template <typename T> using pimpl = std::shared_ptr<T>; }) โ€” the kind of "modernize to unique_ptr" cleanup a maintainer might propose without realizing it's binary-incompatible.


Source files

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

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