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.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.