C++ Concepts: Compound Requirements and Return Types

In modern C++, concepts allow developers to define semantic and syntactic constraints on template parameters. Within a requires expression, compound requirements verify the validity of an expression while optionally enforcing noexcept guarantees and evaluating the resulting return type against a type constraint. This article explores how compound requirement checking processes and validates these return types, focusing on expression value categories, decltype((expression)) substitution, and placeholder type deduction mechanics.

Anatomy of a Compound Requirement

A compound requirement in a C++ requires expression uses the following syntax:

{ expression } noexcept(optional) -> type-constraint;

When the compiler evaluates this construct, it executes the following verification pipeline in order:

  1. Validity: Evaluates whether expression is valid and well-formed.
  2. Exception Specification (Optional): If noexcept is specified, verifies that expression is declared not to throw exceptions.
  3. Return Type Checking (Optional): If a trailing -> type-constraint is present, deduces the type of the expression and verifies that it satisfies the constraint.

If any of these stages fail, the entire compound requirement evaluates to false, causing the enclosing concept to not be satisfied.

The Return Type Evaluation Mechanism

When evaluating the trailing type-constraint, the compiler does not simply extract the plain value type. Instead, it follows precise rules governed by template argument deduction for constrained placeholders.

1. Deducing decltype((expression))

The compiler evaluates the return type as decltype((expression)). The double parentheses are critical: they ensure that the expression's value category is preserved in the resulting type:

  • Lvalue: Evaluates to an lvalue reference (T&).
  • Xvalue: Evaluates to an rvalue reference (T&&).
  • Prvalue: Evaluates to a non-reference type (T).

Because reference qualifiers are preserved, return type constraints must account for whether a function returns by value, reference, or rvalue reference.

2. Placeholder Type Deduction

The return-type requirement -> Concept behaves conceptually as if the deduced type is assigned to a constrained auto variable:

Concept auto __deduced = expression; // conceptually, using decltype(auto) semantics

More specifically, the language rules define that decltype((expression)) must satisfy the type constraint:

  • If the constraint is a bare concept name C, the compiler checks C<decltype((expression))>.
  • If the constraint includes additional arguments C<Args...>, the deduced type is supplied as the first template argument: C<decltype((expression)), Args...>.

Common Constraints and Value Categories

Because the evaluated type includes reference qualifiers, different standard concepts behave distinctly when used in compound requirements.

Exact Type Matching with std::same_as

The standard concept std::same_as<T, U> checks that both types are identical. Because decltype((expr)) preserves references, exact matching can produce unexpected results if references are present:

template <typename T>
concept ReturnsExactInt = requires(T t) {
    { t.getValue() } -> std::same_as<int>;
};
  • If t.getValue() returns int, the deduced type is int (prvalue). The check std::same_as<int, int> evaluates to true.
  • If t.getValue() returns int&, the deduced type is int& (lvalue). The check std::same_as<int&, int> evaluates to false.

To accept both values and references, the requirement must either specify the reference explicitly (std::same_as<int&>) or check the decayed/underlying type using a custom constraint.

Implicit Conversion with std::convertible_to

A common idiom is requiring that an expression's result can be converted to a specific target type:

template <typename T>
concept Stringifiable = requires(T t) {
    { t.toString() } -> std::convertible_to<std::string>;
};

This checks std::convertible_to<decltype((t.toString())), std::string>. This verifies that decltype((t.toString())) can be implicitly and explicitly converted to std::string, seamlessly accommodating lvalue references, rvalue references, and value types such as std::string_view or const char*.

Summary of the Evaluation Steps

When the compiler resolves { expr } -> Concept<Args...>;:

  1. Check if expr is syntactically valid in an unevaluated context.
  2. Determine U = decltype((expr)).
  3. Construct the constraint check Concept<U, Args...>.
  4. Normalize and evaluate the associated concept constraints with U as the first argument.
  5. If constraint satisfaction yields true, the return type requirement passes.