Skip to content

Constraint Subsumption and Overload Resolution

Constraint Subsumption and Overload Resolution

Section titled “Constraint Subsumption and Overload Resolution”

When multiple constrained function templates are viable for a call, the compiler uses subsumption --- a partial ordering on constraints --- to select the most constrained candidate. This mechanism eliminates the ambiguity problems that plagued SFINAE-based overload sets and enables Clean, readable overloading based on concept constraints.

The C++ standard defines a partial ordering on constraints called subsumption [N4950 §13.5.4]. Given two constraints PP and QQWe say PP subsumes QQ (written PQP \succeq Q) if PP is at least as restrictive as QQ --- meaning that every set of template arguments satisfying PP also satisfies QQ.

Formally, for a constraint PP to subsume a constraint QQ:

\forall \mathrm{substitutions S : P(S) \implies Q(S)

This is a structural comparison performed by the compiler, not a runtime check. The rules for Determining subsumption between constraint conjunctions and disjunctions are [N4950 §13.5.4.1]:

PPQQPP subsumes QQ?
ABA \land BAAYes (conjunction subsumes each conjunct)
AAABA \land BNo (the conjunct is less restrictive)
AAABA \lor BYes (disjunction is subsumed by each disjunct)
ABA \lor BAANo (the disjunction is less restrictive)
AAAAYes (identical constraints subsume each other)
AABBIndeterminate (incomparable unless one implies the other)

Proof: Partially-Ordered Overloads Are Preferred

Section titled “Proof: Partially-Ordered Overloads Are Preferred”

Claim: When two viable function templates have constraints PP and QQAnd PP subsumes QQ but QQ does not subsume PPThe overload with constraint PP is unambiguously preferred.

Proof:

  1. By [N4950 §13.5.4/1], a constraint PP subsumes a constraint QQ if, after normalizing both constraints into sets of atomic constraints, every atomic constraint in PP“s normalized set is subsumed by at least one atomic constraint in QQ‘s normalized set, using the template parameter mapping.

  2. Subsumption is a preorder (reflexive and transitive) on the set of constraints. It is not a total order --- some constraints are incomparable.

  3. The partial ordering of constraints induces a partial ordering on the set of viable function templates. If f1f_1 has constraint PP and f2f_2 has constraint QQAnd PQP \succ Q (strict subsumption), then f1f_1 is more constrained than f2f_2 [N4950 §13.10.3.2/1].

  4. [N4950 §13.10.3.2/1] states: “a viable function F1F_1 is defined to be a better function than another viable function F2F_2 if … F1F_1’s associated constraints subsume F2F_2‘s associated constraints and F2F_2‘s associated constraints do not subsume F1F_1‘s associated constraints.”

  5. The “better function” rule is applied in overload resolution [N4950 §13.10.3]. If exactly one viable function is better than all others, it is selected. If no unique best function exists, the call is ambiguous.

  6. When PQP \succ Q strictly (subsumes but is not subsumed by), f1f_1 is the unique best function. No ambiguity arises.

  7. When PQP \succeq Q and QPQ \succeq P (both subsume each other, i.e., the constraints are equivalent), neither function is strictly better than the other. The call is ambiguous.

  8. When neither PQP \succeq Q nor QPQ \succeq P (the constraints are incomparable), neither function is better than the other. The call is ambiguous.

Therefore, partially-ordered overloads with strict subsumption are unambiguously resolved, while Equivalent or incomparable constraints produce ambiguity. \blacksquare

Corollary: For subsumption to work correctly, constraints must be written in a structurally Comparable form. Two constraints that are logically equivalent but structurally different are Incomparable for subsumption purposes, leading to ambiguity.

Corollary: Negated constraints (!C) are incomparable with all other constraints because Negation does not preserve subsumption ordering. A constraint !std::integral<T> is incomparable With std::floating_point<T> even though, set-theoretically, every floating-point type is Non-integral.

Before performing subsumption, the compiler normalizes constraints into a disjunctive normal form (DNF) --- a disjunction of conjunctions of atomic constraints [N4950 §13.5.4.1]:

C=(a1a2)(b1b2)C = (a_1 \land a_2 \land \ldots) \lor (b_1 \land b_2 \land \ldots) \lor \ldots

Each disjunct (a1a2)(a_1 \land a_2 \land \ldots) is a conjunction of atomic constraints. The DNF Representation is unique (up to reordering) for a given constraint expression.

Normalization algorithm:

  1. Replace each concept-id Concept<T, Args...> with its definition’s normalized constraint (recursively).
  2. Apply the distributive law to convert to DNF:
  • (AB)C(AC)(BC)(A \land B) \lor C \to (A \lor C) \land (B \lor C)
  1. Collect atomic constraints within each conjunction.
  2. Remove duplicate atomic constraints within each conjunction.

Example:

template<typename T>
concept A = std::integral<T>;
template<typename T>
concept B = std::signed_integral<T>;
template<typename T>
concept C = A<T> && (B<T> || std::floating_point<T>);

The normalization of C<T> proceeds as follows:

  1. Expand A<T> to std::integral<T>.
  2. Expand B<T> to std::signed_integral<T>.
  3. C<T> becomes std::integral<T> && (std::signed_integral<T> || std::floating_point<T>).
  4. Apply distributive law: (std::integral<T> && std::signed_integral<T>) || (std::integral<T> && std::floating_point<T>).

The DNF is two disjuncts:

  • Disjunct 1: std::integral<T> && std::signed_integral<T>
  • Disjunct 2: std::integral<T> && std::floating_point<T>

For subsumption, the compiler checks that every atomic constraint in each disjunct of PP is Subsumed by at least one atomic constraint in the corresponding disjunct of QQ.

An atomic constraint is the smallest unit of constraint checking [N4950 §13.5.4.1]. It consists Of an expression and a template parameter mapping. The atomic constraint is satisfied if and only If:

  1. The template arguments are successfully substituted into the expression.
  2. The resulting expression is true.

An atomic constraint is identified by its structural form --- the expression tree, including the Template parameter mapping. Two atomic constraints are the same if and only if their expression Trees are identical (same tokens, same structure) and their template parameter mappings are Equivalent.

Critical implication: Two atomic constraints that are logically equivalent but syntactically Different are considered different constraints. For example:

template<typename T>
concept IsInt1 = std::is_same_v<T, int>;
template<typename U>
concept IsInt2 = std::is_same_v<U, int>;

When comparing IsInt1<T> and IsInt2<T>The compiler maps T (from the first concept) to T (from the second concept) and then compares the expression trees. Both reduce to std::is_same_v<T, int>So they are structurally identical and subsume each other.

But consider:

template<typename T>
concept IsIntA = std::integral<T> && std::is_same_v<T, int>;
template<typename T>
concept IsIntB = std::is_same_v<T, int> && std::integral<T>;

Both normalize to the same set of atomic constraints: {std::integral<T>, std::is_same_v<T, int>}. The ordering of conjunctions does not matter for normalization. Both subsume each other.

However:

template<typename T>
concept IsIntC = requires(T t) { requires std::is_same_v<T, int>; };

This introduces a requires-expression with a local parameter t. The atomic constraint inside the requires-expression has a different structural form than std::is_same_v<T, int>. Even though They are logically equivalent, the compiler considers them structurally different, and they are Incomparable for subsumption.

How the Compiler Selects the Most Constrained Viable Function

Section titled “How the Compiler Selects the Most Constrained Viable Function”

When resolving a call to a constrained function template, the compiler follows this process [N4950 §13.10.3]:

  1. Name lookup finds all candidate functions.
  2. Template argument deduction determines the template arguments for each viable candidate.
  3. Constraint satisfaction eliminates candidates whose constraints are not satisfied.
  4. Partial ordering by constraints selects the most constrained candidate among the remaining viable functions.

If, after constraint subsumption, exactly one candidate is more constrained than all others, that Candidate is selected. If no unique most-constrained candidate exists (i.e., two candidates are Equally constrained or incomparable), the call is ambiguous and the program is ill-formed.

#include <concepts>
#include <iostream>
#include <string>
#include <vector>
// Less constrained: only requires integral
template<std::integral T>
void process(T value) {
std::cout << "integral: " << value << "\n";
}
// More constrained: requires integral AND signed
template<std::integral T>
requires std::is_signed_v<T>
void process(T value) {
std::cout << "signed integral: " << value << "\n";
}
int main() {
process(42); // Calls the more constrained overload (signed)
process(42u); // Calls the less constrained overload (unsigned)
// process(3.14); // Error: no viable overload (not integral)
}

Output:

signed integral: 42
integral: 42

The second overload subsumes the first because std::integral<T> && std::is_signed_v<T> implies std::integral<T>.

When a non-template function competes with a constrained function template, the standard overload Resolution rules apply [N4950 §13.10.3]. A non-template function is preferred over a function Template when the signatures are otherwise equally good matches. However, if the non-template Function’s signature requires an implicit conversion that the template does not, the template may be Preferred.

#include <concepts>
#include <iostream>
void process(int x) {
std::cout << "non-template int: " << x << "\n";
}
template<std::integral T>
void process(T x) {
std::cout << "template integral: " << x << "\n";
}
int main() {
process(42); // Calls non-template: exact match on non-template preferred
process(42L); // Calls template: long matches T exactly; non-template requires conversion
// process(3.14); // Error: template not viable (not integral), no non-template match
}

Output:

non-template int: 42
template integral: 42

The rule is: when both a non-template and a template are viable, the non-template is preferred if And only if the argument conversions are equally good [N4950 §13.10.3.2]. For process(42)Both Are exact matches, so the non-template wins. For process(42L)The template is an exact match (T = long) while the non-template requires a narrowing conversion (long to int), so the Template wins.

Key insight: Constraints do not make a template “better” than a non-template function. The Partial ordering rules for constraints only apply between constrained function templates. A Non-template function and a constrained template are compared using the standard overload resolution Tie-breaking rules (non-template preferred on a tie).

#include <concepts>
#include <iostream>
// Overloaded on signed vs unsigned via concepts
template<std::signed_integral T>
void classify(T x) {
std::cout << "signed: " << x << "\n";
}
template<std::unsigned_integral T>
void classify(T x) {
std::cout << "unsigned: " << x << "\n";
}
// Non-template overload for bool specifically
void classify(bool b) {
std::cout << "bool: " << b << "\n";
}
int main() {
classify(42); // signed: 42
classify(42u); // unsigned: 42
classify(true); // bool: 1 (non-template wins; bool matches bool exactly)
}

Note that bool satisfies std::signed_integral (on most implementations where bool is treated As a signed integral type). But the non-template overload for bool is preferred because it is an Exact match without requiring template instantiation.

The standard library concepts in <concepts> are carefully designed so that subsumption works Correctly. For example [N4950 §18.4]:

  • std::integral<T> subsumes std::integral<T> (identity).
  • std::signed_integral<T> subsumes std::integral<T> (every signed integral is integral).
  • std::integral<T> does not subsume std::signed_integral<T> (not every integral is signed).
  • std::forward_iterator<T> subsumes std::input_iterator<T> (every forward iterator is an input iterator).

This hierarchy enables natural overload sets:

#include <concepts>
#include <forward_list>
#include <vector>
#include <iostream>
template<std::input_iterator It>
void advance(It& it, std::iter_difference_t<It> n) {
std::cout << "single-pass advance\n";
while (n-- > 0) ++it;
}
template<std::forward_iterator It>
void advance(It& it, std::iter_difference_t<It> n) {
std::cout << "multi-pass advance\n";
while (n-- > 0) ++it;
}
int main() {
std::vector<int>::iterator vi;
advance(vi, 3); // Calls forward_iterator overload
std::istream_iterator<int> ii;
// advance(ii, 3); // Would call input_iterator overload
}