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.
Partial Ordering of Constraints
Section titled “Partial Ordering of Constraints”The C++ standard defines a partial ordering on constraints called subsumption [N4950 §13.5.4]. Given two constraints and We say subsumes (written ) if is at least as restrictive as --- meaning that every set of template arguments satisfying also satisfies .
Formally, for a constraint to subsume a constraint :
\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]:
| subsumes ? | ||
|---|---|---|
| Yes (conjunction subsumes each conjunct) | ||
| No (the conjunct is less restrictive) | ||
| Yes (disjunction is subsumed by each disjunct) | ||
| No (the disjunction is less restrictive) | ||
| Yes (identical constraints subsume each other) | ||
| Indeterminate (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 and And subsumes but does not subsume The overload with constraint is unambiguously preferred.
Proof:
By [N4950 §13.5.4/1], a constraint subsumes a constraint if, after normalizing both constraints into sets of atomic constraints, every atomic constraint in “s normalized set is subsumed by at least one atomic constraint in ‘s normalized set, using the template parameter mapping.
Subsumption is a preorder (reflexive and transitive) on the set of constraints. It is not a total order --- some constraints are incomparable.
The partial ordering of constraints induces a partial ordering on the set of viable function templates. If has constraint and has constraint And (strict subsumption), then is more constrained than [N4950 §13.10.3.2/1].
[N4950 §13.10.3.2/1] states: “a viable function is defined to be a better function than another viable function if … ’s associated constraints subsume ‘s associated constraints and ‘s associated constraints do not subsume ‘s associated constraints.”
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.
When strictly (subsumes but is not subsumed by), is the unique best function. No ambiguity arises.
When and (both subsume each other, i.e., the constraints are equivalent), neither function is strictly better than the other. The call is ambiguous.
When neither nor (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.
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.
Normal Form of Constraints
Section titled “Normal Form of Constraints”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]:
Each disjunct is a conjunction of atomic constraints. The DNF Representation is unique (up to reordering) for a given constraint expression.
Normalization algorithm:
- Replace each concept-id
Concept<T, Args...>with its definition’s normalized constraint (recursively). - Apply the distributive law to convert to DNF:
- Collect atomic constraints within each conjunction.
- 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:
- Expand
A<T>tostd::integral<T>. - Expand
B<T>tostd::signed_integral<T>. C<T>becomesstd::integral<T> && (std::signed_integral<T> || std::floating_point<T>).- 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 is Subsumed by at least one atomic constraint in the corresponding disjunct of .
Atomic Constraints and Their Combination
Section titled “Atomic Constraints and Their Combination”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:
- The template arguments are successfully substituted into the expression.
- 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]:
- Name lookup finds all candidate functions.
- Template argument deduction determines the template arguments for each viable candidate.
- Constraint satisfaction eliminates candidates whose constraints are not satisfied.
- 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 integraltemplate<std::integral T>void process(T value) { std::cout << "integral: " << value << "\n";}
// More constrained: requires integral AND signedtemplate<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: 42integral: 42The second overload subsumes the first because std::integral<T> && std::is_signed_v<T> implies std::integral<T>.
Interaction with Non-Template Overloads
Section titled “Interaction with Non-Template Overloads”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: 42template integral: 42The 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 conceptstemplate<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 specificallyvoid 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.
Subsumption with Standard Concepts
Section titled “Subsumption with Standard Concepts”The standard library concepts in <concepts> are carefully designed so that subsumption works Correctly. For example [N4950 §18.4]:
std::integral<T>subsumesstd::integral<T>(identity).std::signed_integral<T>subsumesstd::integral<T>(every signed integral is integral).std::integral<T>does not subsumestd::signed_integral<T>(not every integral is signed).std::forward_iterator<T>subsumesstd::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}