Boolean Logic in C++ Requires Clause Conjunctions
In C++20 and later, concept conjunctions using requires
clauses provide a declarative mechanism to combine compile-time
predicates using the logical AND operator (&&).
This article explains how the compiler normalizes these constraints into
atomic constraints, executes compile-time short-circuiting to prevent
invalid template instantiations, manages substitution failures, and
utilizes conjunction structures for constraint subsumption during
template overload resolution.
Constraint Normalization and Atomic Constraints
When the compiler evaluates a requires clause containing
a conjunction, it first decomposes the expression through a process
known as constraint normalization. During normalization, the compiler
unfolds concepts into their underlying operations until it reaches
atomic constraints. An atomic constraint is an expression that cannot be
decomposed further within the constraint system—typically a
constexpr bool expression or a requires
expression evaluating a type's validity.
Unlike standard runtime boolean expressions, constraint conjunction operates directly on normalized constraint trees rather than raw boolean values. This allows the compiler to treat each operand as an independent logical entity rather than a monolithic boolean expression.
Compile-Time Short-Circuit Evaluation
Concept conjunction using && enforces strict
compile-time short-circuit evaluation. When testing whether a concept or
requires-clause is satisfied:
- The compiler evaluates the left-hand operand first.
- If the left-hand operand evaluates to
false, the conjunction is immediately deemed unsatisfied. - The compiler bypasses instantiation and evaluation of the right-hand operand entirely.
This behavior is critical for writing robust generic code. It allows developers to guard complex type checks that would otherwise trigger ill-formed expressions or hard compiler errors if evaluated unconditionally.
template <typename T>
concept ValidPointer = requires(T ptr) {
*ptr;
};
template <typename T>
requires std::is_pointer_v<T> && ValidPointer<T>
void process(T val);In the example above, if T is an int,
std::is_pointer_v<int> evaluates to
false. Because of short-circuiting, the compiler never
attempts to evaluate ValidPointer<int>, preventing a
hard compilation error that would arise from attempting to dereference
an integer inside the concept definition.
Substitution Failures vs. Constraint Non-Satisfaction
When evaluating atomic constraints within a conjunction, any
substitution failure within the constraint's immediate context results
in that atomic constraint evaluating to false. It does not
emit a hard compile error.
If the left operand of a conjunction is satisfied, the compiler
proceeds to substitute template arguments into the right operand. If
substitution succeeds, the resulting expression is evaluated as a
bool. If the substitution encounters an error in its
immediate context, the right-hand constraint evaluates to
false, causing the overall conjunction to evaluate to
false.
Constraint Subsumption and Overload Resolution
Conjunctions directly influence how the compiler orders constrained templates. In C++, more constrained templates take precedence over less constrained templates via a mechanism called subsumption:
- A constraint
P && Qsubsumes constraintP. - A constraint
P && Qsubsumes constraintQ. Pdoes not subsumeP && Q.
During overload resolution, if two functions are viable and their
constraints differ, the compiler checks if one function's constraints
subsume the other. Because a conjunction explicitly demands that both
sub-constraints are met, an overload constrained by
A && B is strictly more specialized than an
overload constrained only by A. If a call matches both
overloads, the compiler selects the A && B
candidate without an ambiguity error.