Skip to content

Static & Header-Only Contracts

Everything else in this documentation set defaults to one library shape: a dynamically-loaded shared library (.so/.dll/.dylib) that a consumer links against and loads separately from its own binary. Two other common shapes — a static library and a header-only library — don't have a dynamic ABI boundary in the same sense, but they are not exempt from compatibility concerns; the concerns just move.

Static libraries: no dynamic ABI, but not "no compatibility contract"

A static library (.a/.lib) is linked into the consumer's own binary at build time — there is no runtime symbol resolution, no SONAME, no loader involved, and the classic "upgrade the library, keep the old consumer binary" scenario Part 1 builds the whole series around simply doesn't apply: every consumer must be relinked against a new static library, and most practically must be recompiled against it too.

That does not mean nothing can go wrong. What remains:

  • Source compatibility — a consumer's #includes and call sites still need to compile against the new headers. Every mechanism in Part 2 — Symbol Contracts and Part 6's source-only section still applies at the source level, unchanged by the fact that the eventual binary is statically linked.
  • Object-file/archive compatibility — a consumer that links a precompiled static archive (rather than building it from source as part of the same build) needs that archive's object format, symbol names, and compiler-ABI-relevant conventions to match its own build. This is effectively a compile-time version of the ordinary binary-compatibility question, checked once at link time instead of continuously at load time.
  • LTO and bitcode compatibility — a static library distributed as LTO bitcode (rather than native object code) additionally needs the consumer's toolchain version/configuration to be compatible with the bitcode format itself — a much narrower, toolchain-version-pinned contract than ordinary object-code linking.
  • Compiler and runtime compatibility — see Dependency & Runtime Floors and the build-profile-comparability row in Product Contract §2 (the exit-code/--diagnostic-comparison mechanics on the same theme apply here too) for why "the same compiler family/version, standard library, and ABI flags on both sides" still matters even with no dynamic loader involved.
  • Inline/template body behavior — see the header-only section below; a static library's headers carry the same inline/template-body concerns a header-only library's full API does, just for whatever subset of the API is exposed inline rather than compiled into the archive.

abicheck itself is built around comparing dynamic-loading artifacts (.so/.dll/.dylib) directly; a static library's .a/.lib archive isn't a first-class input the way a shared object is, and compare positionally requires two such artifacts (or JSON snapshots) — there's no -H-alone, no-operand invocation, and (see the header-only section below for why) no fully-supported way to compare two no-binary source snapshots against each other either. Unlike a header-only library, though, a static library's implementation is real, compilable .cpp code — so the practical way to validate it today is to build an ordinary shared object from those same sources (a purpose-built .so consumers never ship, existing purely to give the archive's own API a real binary to attach evidence to) and run the standard binary+headers comparison against that binary, the same way you would for a genuine dynamic library. Compare that built .so directly — never the static archive itself, and never two no-binary source-only snapshots against each other (see the header-only section below for why that path isn't fully supported).

This is only a limited proxy, not equivalent to checking the archive itself, and the two builds' export visibility has to genuinely match for the comparison to mean anything. A static archive's own linkable symbols follow the archive's own visibility rules at compile time (which, for common patterns like -fvisibility=hidden with no explicit DLL/export macro, may not match what a shared object built from identical sources ends up exporting in its dynamic symbol table). Verified directly: a minimal library built both ways, with foo(int) changed to foo(long) in the header, produced a real linkable symbol in libfoo.a but an empty export table in the purpose-built .so under -fvisibility=hidden with no export macro — compare reported NO_CHANGE even with both headers supplied, because there was nothing in either side's export table to diff. Build the surrogate .so with export visibility that matches how the archive's symbols are actually meant to be consumed (an explicit export macro, or -fvisibility=default for a library that doesn't use one), and keep a static consumer link/source test as a second, independent check for the archive itself — don't treat the .so surrogate's clean result alone as proof the archive is unchanged. Validating the compiled archive's object-format/compiler-ABI compatibility from its .a/.lib file directly isn't something abicheck does today.

Header-only libraries: the whole surface is the inline-body concern

A header-only library has no separate compiled artifact at all — a declaration a consumer actually uses is compiled into their own binary at their own build time, not shipped to them from a prebuilt library. That removes source/binary compatibility as separate questions only for the library's own relationship to a consumer — there's no separately-shipped provider .so for a consumer's binary to diverge from. It does not mean a header-only type or function can't produce a real ABI break: when a header-only declaration is used across a boundary between two independently compiled components — a host and a plugin, two libraries in the same process, anything not rebuilt together from the same header at the same time — its layout, calling convention, and inline definitions are baked into each component's own binary, and a change to any of that is exactly the ordinary cross-boundary ABI question this whole site is about, just with the header-only library itself never being one of the compiled artifacts under comparison. The single-consumer, everything-rebuilt-together case is what makes the concern Part 4 and Unified Impact Assessment describe for a single public inline function apply to the entire public API a consumer actually reaches, not unconditionally to every declaration the header happens to contain — an unused inline function ordinarily emits no code at all, and a class/function template is compiled only for the argument combinations a consumer actually instantiates (std::vector<int> vs. std::vector<Widget> are two independent instantiations, not "the template"). What follows applies to whatever subset of the header a given consumer's own code actually reaches:

  • Every function body a consumer reaches is compiled fresh into their own binary at their build time, against their own compiler, flags, and standard-library implementation — not something the header-only library ships them.
  • A change to a function body's behavior reaches a consumer only the next time that consumer rebuilds — an existing, already-compiled consumer binary keeps running the old inlined behavior indefinitely, with no equivalent of a dynamic library's "drop in a new .so, every consumer picks up the change immediately" propagation. A header-only library's versioning is effectively always at the granularity of "whatever headers were included at each consumer's most recent build" — which cuts both ways: a fix doesn't reach anyone until they rebuild, and a break is silently absent from every consumer that hasn't rebuilt either, right up until the moment they do.
  • Because there's no separate binary artifact, checking a header-only library needs declared-surface (or deeper, source-level) evidence rather than binary evidence. dump -H api.h with no SO_PATH (and no --sources/--build-info) now runs a real header-AST parse and produces a real, comparable snapshotcompare old.json new.json against two such snapshots is a supported path, not a workaround: it goes through the identical typed dump/compare pipeline a binary+headers comparison uses, just with no ELF/PE/Mach-O metadata at all. What it structurally cannot provide — because there was never a binary to observe — shows up as explicit, reasoned rows in the report's disposition_audit.not_evaluated_detectors rather than a silently clean result: real symbol presence/versioning, ABI layout (field offsets, padding), vtable/RTTI linkage identity, and mangled-name linkage-level churn are all unconfirmed for a header-only snapshot (a mangled name it reports is the header frontend's own guessed spelling, never a linker-observed export). Deeper macro/inline/template-instantiation evidence beyond an ordinary L2 header-AST parse is also still open — see docs/contribute/plans/vision-api-abi-evolution.md's "F. Header-only comparison" section for exactly what is and isn't covered yet. It does not replace the "does this actually compile/link/behave correctly for a real consumer" question either: the stub-translation-unit approach below remains the way to get binary evidence (real export-table/DWARF confirmation, or a consumer-model behavioral check) for a header-only library's compiled surface — the two techniques answer different questions and are not mutually exclusive.
abicheck dump -H old/api.h --version 1.0 -o old.json
abicheck dump -H new/api.h --version 2.0 -o new.json
abicheck compare old.json new.json

The closest practical route to binary evidence remains compiling a small stub translation unit that #includes and instantiates the public API into an actual .so, and dumping/comparing that — an ordinary binary+headers comparison, on an artifact that exists only to give the header-only API something to attach binary-level evidence to. The stub carries the same export-visibility caveat as the static-library surrogate above, and one extra: it must both force emission of and default-export every representative entry point (explicit instantiation plus an export annotation; note -fvisibility-inlines-hidden, common in real builds, hides instantiated inline/template functions by default). Once a binary carries any ELF exports at all, the header-declared surface is narrowed to that export set — so a stub whose instantiations stay hidden yields a clean comparison of almost nothing. Keep source/consumer compile tests as an independent second check either way. See Producing Source Facts and Dump/Compare Flags for how the supported binary+headers path works; every mechanism in Evidence & Detectability applies once you have a real snapshot to apply it to. - ODR (One Definition Rule) violations are a risk here too, as they are for any inline/template declaration in a dynamic library's public headers (the same declarations that make a dynamic library's own public inline functions "public inline" in the dispatcher scenario on Unified Impact Assessment) — this isn't unique to the header-only shape, just proportionally larger: a header-only library's entire public surface is inline/template declarations, so the exposure to "two translation units silently seeing different versions of the same header" (a partial update, a vendored copy alongside a system-installed one) is much wider than for a dynamic library, where only the inline/template subset of the public headers carries this risk.

Practical guidance

  • For a static library, treat header/source compatibility with the same rigor as a dynamic library's — build the purpose-built shared object described above and run the standard binary+headers comparison against that artifact, not against the .a/.lib archive or a pair of source-only snapshots — and additionally pin/document the supported compiler and standard-library ABI range explicitly, since there's no runtime check equivalent to a loader refusing an incompatible SONAME.
  • For a header-only library, be explicit that every function a consumer actually uses or instantiates is effectively "public inline" in the sense the hub's dispatcher scenario describes — a behavior change in such a function reaches that consumer on their next rebuild, with no version boundary to soften it. (An unused inline function or an uninstantiated template emits nothing into a consumer that never reaches it, so changing it doesn't affect them — but you can't know which subset any given consumer reaches, so design as if every declaration is reached by someone.) Document behavior changes as prominently as you would a binary-breaking ABI change in a dynamic library, because from the consumer's perspective, that's what they are.
  • If your project ships both a compiled fallback and a header-only fast path (a common pattern for numerics/SIMD dispatch libraries), the two paths need independent compatibility review — a change safe for the compiled path (hidden behind a stable ABI boundary) can still be a behavior change for any consumer using the header-only path.

The header-only comparison is a header-to-header one, since there is no binary to derive a side from; the clang frontend is the one that records the uninstantiated patterns:

abicheck compare libv1.so libv2.so --header old=v1.h --header new=v2.h --config .abicheck.yml

(with .abicheck.yml's compile: block declaring frontend: clang)

case191 is the header-graph break that comparison reports — a public struct gaining a field of a private type.

See also: Dependency & Runtime Floors for the toolchain/runtime side of static linking, and Behavioral & Semantic Compatibility for why an inline body's behavior — not just its signature — is the real contract in the header-only case.


Ladder:Build Profile Comparability · Step 5 · Define Your Contract · Detecting Breaks