Skip to content

Case 122: Uninstantiated Template Signature Change

Field Value
Verdict ๐ŸŸก COMPATIBLE_WITH_RISK
Category Risk
Platforms Linux, macOS, Windows
Flags โ€”
Detected ChangeKinds template_body_changed
Source files examples/case122_template_signature_uninstantiated/

Category: Modern C/C++ Contract | Verdict: โš ๏ธ COMPATIBLE_WITH_RISK

Verdict and consumer impact

clamp<T>'s parameter types change from int, int to long, long between v1 and v2. The library ships clamp header-only and instantiates nothing โ€” it exports only an ordinary library_version() function โ€” so the .so itself is byte-identical where clamp is concerned. Nothing that already links against the library breaks today: the risk is source-level and forward-looking. A consumer that writes clamp<int>(x, a, b) only picks up the new parameter types (and a different mangled symbol on its own object file) the next time it recompiles against the updated header. That's why the canonical verdict is COMPATIBLE_WITH_RISK (template_body_changed), not a binary break: a real hazard for future consumers, not a proven break for any consumer that exists yet.

Old/new diff

v1.h v2.h
template <typename T> T clamp(T value, int lo, int hi); template <typename T> T clamp(T value, long lo, long hi);

abicheck command

The default object/header comparison lane is a documented gap for this case โ€” it's the point being demonstrated, not a mistake:

g++ -shared -fPIC -g v1.cpp -o libtpl_v1.so
g++ -shared -fPIC -g v2.cpp -o libtpl_v2.so
abicheck compare libtpl_v1.so libtpl_v2.so \
  --header old=v1.h --header new=v2.h \
  --ast-frontend clang --gcc-path "$(command -v clang)"
# โ†’ NO_CHANGE (exit 0) โ€” the documented L0-L2 gap, see "Minimum evidence"

Seeing the real finding needs L3 build context (a compile database) plus L4 source-ABI replay, with public-header scoping turned off (the L2 header backend doesn't model templates at all, so scoping can't recognize clamp as public and would otherwise silently drop the finding as "internal"):

cat > v1.compile_commands.json <<EOF
[{"directory": "$PWD", "command": "c++ -std=c++17 -c v1.cpp -o v1.o", "file": "$PWD/v1.cpp"}]
EOF
cat > v2.compile_commands.json <<EOF
[{"directory": "$PWD", "command": "c++ -std=c++17 -c v2.cpp -o v2.o", "file": "$PWD/v2.cpp"}]
EOF

python3 <<'PYEOF'
import dataclasses
from pathlib import Path
from abicheck.buildsource.inline import collect_inline_pack

for v in ("v1", "v2"):
    pack = collect_inline_pack(
        sources=Path("."),
        build_info=Path(f"{v}.compile_commands.json"),
        public_header_roots=(f"{v}.h",),
        extractor="clang",
    )
    dataclasses.replace(pack, root=Path(f"{v}.evidence")).write()
PYEOF

abicheck dump libtpl_v1.so -H v1.h -p v1.compile_commands.json \
  --build-info v1.evidence --ast-frontend clang --gcc-path "$(command -v clang)" -o v1.abi.json
abicheck dump libtpl_v2.so -H v2.h -p v2.compile_commands.json \
  --build-info v2.evidence --ast-frontend clang --gcc-path "$(command -v clang)" -o v2.abi.json

abicheck compare v1.abi.json v2.abi.json --no-scope-public-headers

(--ast-frontend clang/--gcc-path select the supported Clang AST frontend because castxml isn't installed in this environment; drop them on a host with castxml. The standalone collect CLI command was removed in the ADR-043 CLI reset, so collect_inline_pack() is called directly here to build the evidence pack for this hand-written, non-CMake compile database โ€” a real project instead points dump --sources <tree> --depth source at its actual build.)

Expected abicheck finding

Default (object/header) lane: NO_CHANGE (exit 0) โ€” documented gap

L3+L4 lane: COMPATIBLE_WITH_RISK (exit 0)

- template_body_changed: Uninstantiated public template 'clamp'
  implementation changed. Invisible to artifact comparison; consumers
  pick up the new body on recompile.

Minimum evidence

min_evidence: L4 โ€” this is missed by both L0-L1 (object/DWARF: the binary never instantiates clamp, so nothing is emitted to compare) and L2 (castxml/header AST: clamp is a function template, and neither castxml nor the Clang AST backend used in this environment models uninstantiated templates at all โ€” confirmed above, the default header lane returns NO_CHANGE). Only L4 source-ABI replay parses the real Clang AST โ€” which does emit uninstantiated FunctionTemplateDecl nodes โ€” and hashes each template's parameter list, catching the mismatch as template_body_changed. Reaching that evidence needs an explicit L3 compile-unit pack (public headers alone, with no build metadata, aren't enough to scope clamp as reachable) plus --no-scope-public-headers on compare.

Why abicheck catches it

abicheck/buildsource/source_diff.py's L4 replay walks the real Clang AST for each side's compile unit, including uninstantiated template declarations, and hashes each template's subtree (its parameter list among it). The two hashes differ here because the parameter types changed (int, int โ†’ long, long), and the replay reports that as template_body_changed โ€” a RISK finding, since no shipped binary instantiates the template yet.

Runtime failure demonstration

Severity: source-level, not a runtime crash. Nothing in the shipped .so changes, so there is no "swap the library, watch it crash" scenario here โ€” the break lives entirely in what a consumer compiles against. app.cpp instantiates clamp<int>(5, 0, 10) itself; compiling it against each header shows the mangled symbol the consumer's own object file gets:

g++ -std=c++17 -c app.cpp -I. -o app_v1.o          # includes v1.h
g++ -std=c++17 -DUSE_V2 -c app.cpp -I. -o app_v2.o  # includes v2.h
nm app_v1.o | grep clamp
# โ†’ W _Z5clampIiET_S0_ii    (int, int)
nm app_v2.o | grep clamp
# โ†’ W _Z5clampIiET_S0_ll    (long, long)

Each consumer object is only ever paired with the header it was compiled against, so both link and run fine on their own:

g++ -std=c++17 app.cpp -I. -L. -ltpl -Wl,-rpath,. -o app          # v1.h
./app
# โ†’ 5 1
g++ -std=c++17 -DUSE_V2 app.cpp -I. -L. -ltpl -Wl,-rpath,. -o app  # v2.h
./app
# โ†’ 5 1

The risk isn't a runtime mismatch between an old consumer and a new library โ€” it's that a consumer's existing source, unchanged, silently resolves against a different template signature (and produces a differently mangled symbol) the next time it's recompiled against updated headers, which can surface as overload-resolution or linker surprises elsewhere in a larger codebase.

Safe redesign

For ABI-sensitive templates, ship explicit instantiations (template class Foo<int>; or template T clamp<int>(T, int, int);) so the instantiation becomes a real symbol abicheck can track at L0/L1 like any other function (see case17_template_abi), or pair the binary comparison with a source-level (header-diff or L4) check specifically for template signatures, since artifact comparison alone cannot see this class of change.

Real-world example: header-only template libraries (much of the C++ standard library's own design, or libraries like Eigen/fmt) routinely change template internals between releases; this case is why "the binary didn't change" is not sufficient evidence that a template-heavy library's ABI contract with source consumers is stable.

Cross-tool comparison

abidw/abidiff/abi-compliance-checker are not installed in this environment. Both tools work from compiled binaries and/or castxml-derived header ASTs, the same L0-L2 evidence this case's default lane already shows cannot see the change โ€” so neither would be expected to report anything here without a source-AST-replay capability equivalent to abicheck's own L4.


Source files

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

See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.