Part 7 — Designing for Stability¶
Series navigation: 0. Product Contract · 1. Foundations · 2. Symbol Contracts · 3. Type Layout · 4. C++ ABI · 5. Linker & ELF · 6. Transitive Breaks · 7. Designing for Stability
What you'll learn on this page
- The handful of design patterns that make a library evolvable, each with copy-pasteable code: opaque handles, Pimpl, reserved padding, version scripts, visibility control, inline-namespace generations.
- Five top-level rules that subsume every mechanism in the series.
- How to wire
abicheckinto CI as a release gate, and how to read its verdict.
This is the capstone. Each previous page ended with a "how to fix" box pointing here; this page gives the full pattern.
The one idea behind every pattern¶
Every break in this series came from the same root: a fact the library publishes (a size, an offset, a symbol name, a register choice, a vtable slot) got baked into a consumer's binary and then changed.
So every fix is a variation on a single move:
Stop publishing the fact. If consumers can't see a struct's layout, you can change the layout. If they can't see a symbol's mangling, you can re-mangle it. If they only ever hold a pointer, the size on the other end is yours forever.
The patterns below are concrete ways to not publish a fact you'd otherwise be committed to for the lifetime of a SONAME.
Pattern 1 — Opaque handles (the strongest C firewall)¶
Expose only a pointer to an incomplete type. Define the struct in the .c file
so callers can never take sizeof or offsetof.
// foo.h — the public header
typedef struct foo foo_t; // incomplete: callers know only that foo_t exists
foo_t *foo_create(void);
void foo_destroy(foo_t *);
int foo_get_version(foo_t *);
void foo_set_name(foo_t *, const char *);
// foo.c — the implementation; layout lives here and ONLY here
struct foo {
int version;
char name[64];
void *anything_you_want_to_add_later; // free to change forever
};
foo_t *foo_create(void) { return calloc(1, sizeof(struct foo)); }
void foo_destroy(foo_t *f) { free(f); }
int foo_get_version(foo_t *f) { return f->version; }
Why it works: the only thing crossing the ABI boundary is foo_t * — pointer
size is fixed per ABI. You can add, remove, and reorder fields in struct foo
across releases and no consumer is affected. This is exactly how FILE*,
sqlite3*, and git_repository* stay stable for decades. It neutralizes the
entire Part 3 family in one move.
Pattern 2 — Pimpl (the C++ equivalent)¶
The public class holds a single pointer to a privately-defined Impl. All state
lives in Impl, so sizeof of the public class never changes and no field
offset is ever visible.
// widget.hpp — public
class Widget {
public:
Widget();
~Widget();
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
void resize(int w, int h);
int area() const;
private:
struct Impl; // forward declaration only
std::unique_ptr<Impl> d_; // sizeof(Widget) == sizeof(void*), forever
};
// widget.cpp — private
struct Widget::Impl {
int w = 0, h = 0;
std::string label; // safe to add: never crosses the boundary
};
Widget::Widget() : d_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // defined here, where Impl is complete
void Widget::resize(int w, int h) { d_->w = w; d_->h = h; }
int Widget::area() const { return d_->w * d_->h; }
Why it works: sizeof(Widget) is one pointer regardless of how Impl grows.
Qt enforces this across every public class, which is why Qt 5.x held ABI for
years through deep internal refactors. It also closes off
trivial→non-trivial surprises, because the public class's
special members are declared once and pinned.
Pimpl gotcha
std::unique_ptr<Impl> is not an automatic ABI firewall. Three things
must hold:
- Out-of-line special members. Declare and define in the
.cppthe destructor, move constructor, and move assignment (whereImplis complete). A defaulted destructor in the header forces the compiler to seeImpl's definition there — defeating the firewall. - No custom deleter / completeness leak. The default
unique_ptrdeleter requires a complete type at the point of destruction; keep that point out-of-line. - Don't leak standard-library ABI. If the public class still exposes
std::string,std::vector, etc. by value in its interface, you've re-exported the stdlib's ABI (and its dual-ABI flips — see case104) through the firewall. Keep standard-library types behind theImplboundary too. - The wrapper itself is still a commitment. Pimpl keeps
Impl's layout out of the public ABI — that's the whole point, soImplis free to change. What stays frozen isWidget's own size/alignment and the representation of its pointer member. Switchingstd::unique_ptr<Impl>to a raw pointer, ashared_ptr, or a different deleter, or adding an inline member function / changing a special member, is itself ABI-relevant (case80 shows ashared_ptr→unique_ptrflip detected as breaking).
Stricter variant: hide even the smart pointer
For the most conservative C++ ABI, hold a raw Impl* so the public
class layout doesn't depend on the standard library's smart-pointer ABI at
all — at the cost of writing lifetime management by hand:
Pattern 3 — Reserved padding (evolve a value type in place)¶
When you must expose a struct by value (a C plain-old-data DTO), pre-reserve
space so you can add fields later without changing sizeof or moving offsets.
// v1
typedef struct {
int priority;
int timeout_ms;
uint64_t _reserved[6]; // 48 bytes held in reserve
} job_config_t;
// v2 — consumes two reserved slots; sizeof and all prior offsets UNCHANGED
typedef struct {
int priority;
int timeout_ms;
int max_retries; // was _reserved[0]'s low word
int _pad;
uint64_t _reserved[5];
} job_config_t;
The contract you must document and consumers must honor: zero-initialize the
whole struct and never read or write the reserved bytes. Linux struct stat,
glibc pthread_attr_t, and Wayland protocol structs all rely on this. The hazard
(Part 6, reserved-field misuse) is that you cannot
prove a consumer obeyed it — so reserve generously and document loudly.
Pattern 4 — Version scripts + visibility (own your export surface)¶
Compile with hidden visibility and enumerate exactly what you export. Everything else stays internal and never becomes an ABI commitment.
// foo_export.h
#define FOO_API __attribute__((visibility("default")))
FOO_API int foo_compute(int);
/* internal helpers carry no macro → hidden under -fvisibility=hidden */
# libfoo.map — the canonical list of intentional exports
LIBFOO_1.0 {
global:
foo_compute;
foo_create;
foo_destroy;
local:
*; # everything else is hidden
};
gcc -fvisibility=hidden -shared -fPIC *.c \
-Wl,--version-script=libfoo.map \
-Wl,-soname,libfoo.so.1 -o libfoo.so.1
Why it works: -fvisibility=hidden plus the local: * rule means only the
names you listed enter .dynsym — closing off the accidental-leak hazard from
Part 5. When you genuinely need a breaking change, add a new
node (LIBFOO_2.0 { ... } LIBFOO_1.0;) and keep the old symbols exported, so old
binaries keep resolving the old implementation.
The same surface control on Windows and macOS¶
The principle — export exactly what you intend, version your identity — is universal; only the spelling changes. (Loader-level details are in Part 5 §PE/COFF and Mach-O parallels.)
| Goal | Linux / ELF | Windows / PE | macOS / Mach-O |
|---|---|---|---|
| Hide everything by default | -fvisibility=hidden |
nothing is exported unless marked | -fvisibility=hidden |
| Mark a public export | __attribute__((visibility("default"))) |
__declspec(dllexport) (and dllimport in consumers) |
__attribute__((visibility("default"))) |
| Authoritative export list | version script (--version-script) |
a .def file (EXPORTS) — and pin ordinals so a rebuild can't renumber |
-exported_symbols_list file.txt |
| Library identity / epoch | -Wl,-soname,libfoo.so.1 |
the DLL file name + its import library | install name + -compatibility_version / -current_version |
| Generational ABI (incompatible, must coexist) | new version node, keep old symbols | side-by-side DLL (new name) | new install name / path (e.g. libfoo.2.dylib) — the install name is the epoch¹ |
; libfoo.def — stable Windows export surface (pin ordinals!)
LIBRARY libfoo
EXPORTS
foo_compute @1
foo_create @2
foo_destroy @3
# macOS — explicit export list + versioned install name
clang -dynamiclib -fvisibility=hidden *.c \
-exported_symbols_list exports.txt \
-install_name @rpath/libfoo.1.dylib \
-compatibility_version 1.0 -current_version 1.2 -o libfoo.1.dylib
¹ macOS: compatibility_version is a floor, not an epoch
Clients select a dylib by its install name, and compatibility_version
is only a minimum check — the loader rejects a runtime dylib whose
compatibility version is lower than what the client recorded at link time,
but a higher one still loads. So bumping compatibility_version under the
same install name does not let an old and a new ABI coexist: an old
client will happily load the new, incompatible dylib. For a breaking change
where both must coexist, ship under a new install name / path (e.g.
@rpath/libfoo.2.dylib) — that change of identity is the real epoch bump,
the Mach-O analog of an ELF SONAME-major or a side-by-side Windows DLL.
Reserve compatibility_version for backward-compatible additions within
one generation.
Windows: the CRT allocation boundary
A DLL with its own statically-linked CRT must not hand out memory the caller
frees (or vice versa) — malloc/free and new/delete must pair within
the same module. Expose explicit foo_create()/foo_destroy() instead
of letting callers delete your objects; this has no ELF equivalent but is a
hard rule on Windows.
Pattern 5 — Inline namespaces for generational C++ ABI¶
Wrap public declarations in an inline namespace. Source ignores it; the symbol encodes it. When you need a breaking change, ship a new generation alongside the old.
namespace mylib {
inline namespace abi_v1 {
class Codec { /* v1 layout & vtable */ };
void process(Codec&);
}
}
// consumers write mylib::Codec / mylib::process — unaware of abi_v1
To ship an incompatible Codec, add inline namespace abi_v2 { ... } and demote
abi_v1 to a non-inline namespace that still compiles for old TUs. New builds
bind abi_v2 symbols; old binaries keep resolving abi_v1. This is libstdc++'s
__cxx11 mechanism, used deliberately.
Pattern 6 — Pure-virtual interface + factory¶
For polymorphic C++ APIs, never expose a concrete class with data members. Expose an abstract interface and a C-linkage factory.
// codec.hpp
struct ICodec {
virtual ~ICodec() = default;
virtual int encode(const Frame&) = 0;
virtual void reset() = 0;
};
extern "C" ICodec *codec_create(); // stable C symbol; hides the concrete type
Why it works: consumers hold only ICodec* and call through the vtable. The
concrete implementation class — its data members, its sizeof, its non-virtual
helpers — lives entirely in your .so and can change freely. The rule you
inherit from Part 4: never insert or reorder virtual methods on
ICodec. Appending is safe only if your library owns every implementation
of ICodec. The moment downstreams are allowed to derive from it — the usual
case for plugin or callback interfaces — even appending is breaking: an old
plugin's vtable was compiled with the old shape, so a host call into the new slot
dispatches past the end of that vtable. For a downstream-implementable interface,
treat any vtable change as a SONAME-bump-worthy break and version the interface
itself (a new ICodec2, or the inline-namespace generation pattern above).
Pattern 7 — Let the checker see the pattern¶
Every pattern above leaves a trace in the declarations: an opaque pointer
type that is only ever forward-declared, a class whose one data member is a
pointer to an incomplete type, a create/destroy pair around a handle, a
C-linkage factory returning an interface, a callback typedef. abicheck
recognises those idioms from the header facts, and the anti-patterns beside
them — an STL container passed by value across the boundary, a polymorphic
type with no virtual destructor. It automatically uses that evidence to
modulate a verdict: a layout change inside a type the header proves
opaque is demoted with a stated reason rather than reported as a break, and
it is never deleted — the finding stays in the report with its modulation
recorded in a ledger. The reverse holds too: losing an opacity or handle
guarantee, so that a hidden layout becomes reachable, is raised as
opaque_invariant_broken or handle_type_changed.
Modulation itself is unconditional (there is no flag to enable or disable
it); the idiom evidence behind each modulation is always printed. Two
cases show the two directions: a Pimpl switching its pointer type, which
the checker judges hidden (case80),
and a detail:: Pimpl whose vtable change does reach consumers
(case76).
Flag semantics and the ledger are owned by
API Surface Intelligence.
The five rules that subsume everything¶
- Treat public headers as ABI contracts. Anything reachable from a public header — type layout, enum values, vtable shape, exported globals — is part of the binary contract whether or not you intended it to be.
- Govern release identity. Use SONAME + symbol versioning + a
-fvisibility=hiddenexport policy on every release; bump the SONAME major on any binary-incompatible change. abicheck surfaces the signals assoname_bump_recommended,symbol_version_node_removed,symbol_moved_version_node, andversion_script_missing. - Prefer opaque handles and Pimpl over exposing mutable layouts, so the library — not the consumer — owns size, offsets, and the kind-tag of a type.
- Evolve additively, never in place. Append new symbols, enum members, and
struct fields (where no embedded
sizeofassumption exists); ship breaking changes under a new inline-namespace or a new symbol rather than mutating an existing one. - Gate every PR with abicheck in CI. Dump the last released artifact and
compare the candidate; block anything above
COMPATIBLE_WITH_RISKthat isn't paired with a deliberate SONAME bump.
Wiring abicheck into CI¶
The minimal gate compares the candidate against the last release with the
public headers, and fails on any BREAKING or API_BREAK finding. Which
moment to run it at, what each costs, and how to roll it out without a flag
day are the subject of Where in the Pipeline
and Rollout and Governance. Reading the
result: ✅ NO_CHANGE · 🟢 COMPATIBLE · 🟡 COMPATIBLE_WITH_RISK · 🟠
API_BREAK · 🔴 BREAKING — what each means and the exit code it maps to
are owned by Verdicts.
You've finished the series¶
You now have the full picture: how a library becomes a running process (Part 1), the four families of break (Parts 2–5), the ones that hide from code review (Part 6), and the patterns that make a library evolvable (this page).
The ABI Cheat Sheet is the two-minute card of everything above. From here the series turns from mechanism to your own library: which promise you are making, to whom, and how to check it.
Further reading (external, authoritative)¶
The canonical primary sources behind this series. When a claim here matters for a real release decision, these are where to verify it:
- KDE — Binary Compatibility Issues With C++ — the most widely-cited practitioner checklist of what is and isn't binary- compatible in C++. Pairs directly with Parts 3–4.
- Itanium C++ ABI specification — the authority for vtable layout, name mangling, and the trivially-copyable passing rules discussed in Part 4 (GCC/Clang on Linux/macOS/BSD).
- GCC / libstdc++ ABI policy and guidelines
— symbol versioning, the
_GLIBCXX_USE_CXX11_ABIdual-ABI, and library versioning practice behind Parts 5–6. - Ulrich Drepper — How To Write Shared Libraries — the definitive treatment of ELF symbol resolution, visibility, versioning, and TLS that underpins Parts 1, 2, and 5.
- Martin Reddy — C++ API Design (Morgan Kaufmann, 2011) — book-length treatment of the opaque-handle / Pimpl / versioning patterns in this page.
- “20 ABI-breaking changes every C++ developer should know” — a concise lay summary (MSVC/DLL-flavored); this series is a strict superset of its checklist and additionally covers enums, unions, bitfields, alignment, TLS, and transitive/dependency leaks.
Next on the ladder: Compatibility Direction opens Step 5, Define Your Contract — or go back to the series overview.
Ladder: ← Part 6 — Subtle & Transitive Breaks · Step 4 · Designing for Stability · Compatibility Direction →