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