Concept Subsumption and Ordering
When the same call or instantiation matches multiple constrained templates, the compiler does not give up and report an ambiguity — it picks the most constrained one. This ordering is driven by subsumption: a formal relationship between constraint sets that the compiler determines by normalising them into atomic parts. Understanding subsumption explains why some overloads work as expected while others produce unexpected ambiguities, and how to structure concept hierarchies so the right template is always selected.
Constraint normalization
Before comparing two constraints, the compiler normalizes them — it transforms every constraint expression into a canonical form consisting only of conjunctions and disjunctions of atomic constraints. An atomic constraint is a boolean expression that cannot be decomposed further. The normalization process follows three rules:
E1 && E2Conjunction of the normalized forms of E1 and E2.
E1 || E2Disjunction of the normalized forms of E1 and E2.
C<A1, A2, …> (a concept applied to args)Replaced by the normalized body of C with those args substituted — it is expanded, not treated as a black box.
A type trait variable template like std::is_integral_v<T> is not a concept, so it is never expanded — it becomes a single atomic constraint. This is the root cause of the type-trait ambiguity problem shown later. A named concept, by contrast, is always expanded into its constituent atomic constraints, making its internal structure visible to the subsumption analysis.
template<typename T> concept Integral = std::is_integral_v<T>; template<typename T> concept SignedIntegral = Integral<T> && std::is_signed_v<T>; // After normalization, SignedIntegral<T> becomes: // conjunction of: // std::is_integral_v<T> ← from expanding Integral<T> // std::is_signed_v<T> ← atomic, not a concept // Integral<T> after normalization: // std::is_integral_v<T> ← single atomic constraint // The compiler can now see that SignedIntegral's atoms ⊇ Integral's atoms. // SignedIntegral subsumes Integral.
The subsumption rule
Constraint A subsumes constraint B if every atomic constraint in A's normalized disjunctive normal form implies the corresponding atomic constraint in B's normalized conjunctive normal form. In practice, the key insight is simpler: an atomic constraint A subsumes another atomic constraint B when they arise from the same concept applied to the same arguments. Two syntactically identical type trait expressions do not subsume each other — the compiler treats them as unrelated atoms even if they encode the same predicate.
From subsumption the standard derives a partial ordering of declarations:
D1 is at least as constrained as D2
D1's constraints subsume D2's constraints.
D1 is more constrained than D2
D1 is at least as constrained as D2, AND D2 is NOT at least as constrained as D1.
Overload resolution picks the more constrained overload
When both are viable, the more constrained template is selected. If neither is more constrained, the call is ambiguous.
Why type traits cause ambiguity but concepts do not
This is the most practically important consequence of subsumption. When you write two overloads constrained with raw type traits, the compiler sees each type trait expression as a separate atomic constraint — even if they are syntactically identical. Since identical-but-separate atoms do not subsume each other, the compiler cannot establish an ordering and reports an ambiguity. Replace the type traits with a named concept and the atoms are now shared (they came from the same concept), so subsumption kicks in.
// ✗ AMBIGUOUS — type traits produce unrelated atomic constraints
template<typename T>
requires std::is_integral_v<T> // atom A
T add(T a, T b) { return a + b; }
template<typename T>
requires std::is_integral_v<T> // atom B (looks same, treated as different)
&& (sizeof(T) == 4) // atom C
T add(T a, T b) { return a + b; }
// add(1, 2); ERROR: ambiguous — A and B are not the same atom, no subsumption// ✓ UNAMBIGUOUS — concept atoms ARE shared across declarations
template<typename T>
concept Integral = std::is_integral_v<T>; // defines the shared atom
template<Integral T> // expands to atom Integral's body
T add(T a, T b) { return a + b; }
template<Integral T> // same atom
requires (sizeof(T) == 4)
T add(T a, T b) { return a + b; }
// For add(1, 2): both match; second is more constrained (Integral ⊂ {Integral,sizeof==4})
// Compiler selects the more constrained second overload. No ambiguity.
add((short)1, (short)2); // short: sizeof==2, only first overload matches
add(1, 2); // int: sizeof==4, both match; second selectedBuilding concept hierarchies
Subsumption enables concept hierarchies that mirror type hierarchies. A more specialized concept can subsume a more general one simply by conjoining the general concept with additional requirements. This lets you write overloads at different levels of generality and have the compiler always pick the most specific one.
#include <concepts>
// A three-level hierarchy
template<typename T>
concept Number = std::integral<T> || std::floating_point<T>;
template<typename T>
concept ExactNumber = std::integral<T>; // subsumes Number for integral types
template<typename T>
concept SignedExactNumber = ExactNumber<T> && std::signed_integral<T>;
// Subsumes ExactNumber, which subsumes Number (for integral T)
// Three overloads — compiler picks the most specific one
template<Number T>
std::string describe(T) { return "number"; }
template<ExactNumber T>
std::string describe(T) { return "exact number"; }
template<SignedExactNumber T>
std::string describe(T) { return "signed exact number"; }
describe(3.14f); // float: Number only → "number"
describe(42u); // uint: ExactNumber → "exact number"
describe(-7); // int: SignedExactNumber → "signed exact number"Partial ordering of class template specializations
Subsumption applies not only to function overloads but also to class template specializations. When a type could match multiple constrained specializations, the compiler selects the most constrained one. This makes it possible to write a primary template with broad requirements and specialized templates with narrower ones, without needing explicit partial specialization syntax.
#include <concepts>
// Primary template: accepts any integral type
template<std::integral T>
struct storage {
T value;
void print() const { std::cout << "integral: " << value << '\n'; }
};
// More constrained specialization: integral types that are 4 bytes
template<std::integral T>
requires (sizeof(T) == 4)
struct storage<T> {
T value;
void print() const { std::cout << "4-byte integral: " << value << '\n'; }
// Extra functionality only available for 4-byte types
std::array<uint8_t, 4> bytes() const { /* ... */ return {}; }
};
storage<short> s1; s1.print(); // "integral: ..." — primary
storage<int> s2; s2.print(); // "4-byte integral: " — specialized
s2.bytes(); // only available on 4-byte specializationWhat cannot be subsumed
Not every constraint relationship is visible to subsumption. The compiler does not evaluate the truth of constraints — it only checks whether the atomic constraints in one set are a superset of those in another. Several patterns therefore produce no subsumption, even when the relationship is mathematically obvious:
Two independently-written type traits that encode the same predicate
Why: Each becomes its own atomic constraint. They have different identities even if the values always agree.
Fix: Express both using the same named concept.
A concept definition that evaluates to true for a strict subset of another's types, but is not syntactically a conjunction of that other concept
Why: Subsumption is purely syntactic, not semantic. The compiler does not reason about which types satisfy each concept.
Fix: Define the narrower concept explicitly as C1<T> && extra_requirement<T>.
Negated or inverted constraints: !C<T>
Why: Negation of a concept does not subsume the negation of a subsuming concept (the ordering reverses).
Fix: Avoid negated concept constraints where ordering matters; restructure as positive requirements.