Template Constraints and Concepts in C++20

Template Constraints, introduced with Concepts in C++20, provide a way to impose conditions on template parameters, allowing developers to restrict the types and values that can be used in templates. This feature makes templates more readable, safe, and maintainable by ensuring that template arguments meet specific requirements or concepts.

Before C++20, constraints were typically enforced using techniques like SFINAE (Substitution Failure Is Not An Error), which could make code complex and error messages cryptic. With Concepts, template constraints become first-class citizens in the language, offering a more intuitive way to express template requirements.

Concepts in C++20

A concept is a compile-time predicate (i.e., a condition) that specifies the properties or behaviors a type must satisfy to be used as a template argument. If the type fails to meet the conditions, the template instantiation fails with a clear error message.

Basic Syntax for Concepts

template <typename T>
concept ConceptName = /* predicate or condition */;

Concepts can be used to constrain templates in a few different ways:

  1. In the template parameter list (before typename or class)
  2. Using the requires clause to add constraints at the end of a template declaration

Example of Concepts

Concept Definition

Here’s an example of a concept that checks if a type supports the < operator (i.e., if a type is comparable):

template <typename T>
concept LessThanComparable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;  // Must support the '<' operator and return a boolean
};

In this concept:

  • requires introduces a list of expressions that the type T must satisfy.
  • The expression { a < b } -> std::convertible_to<bool>; checks that the result of a < b can be converted to a bool.

Using Concepts to Constrain Templates

1. Constraining a Template Parameter:

You can use the concept directly in the template parameter list, replacing typename or class:

template <LessThanComparable T>  // Constrain 'T' using the 'LessThanComparable' concept
bool is_smaller(const T& a, const T& b) {
    return a < b;
}
  • Here, T is constrained by the LessThanComparable concept, so only types that support the < operator can be passed as arguments to is_smaller.

2. Using requires Clauses:

You can also use requires clauses to apply constraints after the template parameter list:

template <typename T>
requires LessThanComparable<T>  // 'requires' clause constraining 'T'
bool is_smaller(const T& a, const T& b) {
    return a < b;
}

This is equivalent to the previous example but uses the requires clause for readability.

3. Abbreviated Function Template Syntax:

C++20 allows you to abbreviate the syntax further by directly using the concept name in place of the typename keyword:

bool is_smaller(LessThanComparable auto a, LessThanComparable auto b) {
    return a < b;
}

This is the most concise way to express template constraints using Concepts. The keyword auto here is shorthand for “any type that satisfies the given concept.”

Built-in Concepts in C++20

C++20 provides several standard library concepts in the <concepts> header. These built-in concepts allow you to impose common requirements on template arguments without defining custom concepts.

Some common standard concepts include:

  • std::integral: Requires the type to be an integral type (e.g., int, char).
  • std::floating_point: Requires the type to be a floating-point type (e.g., float, double).
  • std::convertible_to<From, To>: Requires that a type can be converted from From to To.
  • std::same_as<T, U>: Requires that T and U are the same type.

Example Using Built-in Concepts:

#include <concepts>

template <std::integral T>  // Only integral types are allowed
T gcd(T a, T b) {
    while (b != 0) {
        T temp = b;
        b = a % b;
        a = temp;
    }
    return a;
}

int main() {
    gcd(10, 15);  // OK, both are integers
    // gcd(10.5, 15.2);  // Error, because 'double' is not an integral type
}

In this example:

  • The gcd function is constrained by the std::integral concept, ensuring that only integral types (e.g., int, char) can be passed as template arguments.
  • If a floating-point type like double is passed, the compiler will produce a meaningful error message indicating that the type does not satisfy the std::integral concept.

Custom Concepts with Multiple Conditions

You can create custom concepts with multiple conditions to refine your template requirements. For instance, the following concept checks if a type is both copyable and default-constructible:

#include <concepts>

template <typename T>
concept CopyableAndDefaultConstructible = std::copyable<T> && std::default_initializable<T>;

template <CopyableAndDefaultConstructible T>
T create_and_copy(const T& obj) {
    T copy = obj;  // Requires copy constructor
    return copy;   // Requires default constructor
}

In this example:

  • CopyableAndDefaultConstructible is a concept that checks two conditions: that the type T is both copyable and default-constructible.
  • The create_and_copy function can only be instantiated with types that satisfy both of these conditions.

Concept Refinement

You can refine concepts by combining or extending existing ones. For example:

template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;  // Type must support '+' operator
};

template <typename T>
concept AddableAndLessThanComparable = Addable<T> && LessThanComparable<T>;  // Combine two concepts

In this example:

  • Addable checks if a type supports the + operator.
  • AddableAndLessThanComparable combines both the Addable and LessThanComparable concepts to impose more constraints on the type.

requires Expressions

Another feature introduced with Concepts is the requires expression, which allows you to write inline constraints within a function body.

Example:

template <typename T>
void print_if_addable(const T& a, const T& b) {
    if constexpr (requires { a + b; }) {  // Check if 'a + b' is a valid expression
        std::cout << a + b << std::endl;
    } else {
        std::cout << "Cannot add these types!" << std::endl;
    }
}

In this example:

  • The requires expression inside if constexpr checks whether a + b is a valid expression.
  • If the types support the + operator, it prints the result; otherwise, it prints an error message.

Advantages of Concepts in C++20

  1. Clearer Error Messages: One of the main benefits of Concepts is better, more meaningful compile-time error messages when a template argument does not meet the required constraints. This contrasts with earlier techniques like SFINAE, which often resulted in obscure or verbose error output.
  2. Improved Code Readability: Concepts make templates more readable by explicitly stating the requirements on template arguments, making it easier for both the writer and the reader to understand what types are expected.
  3. Code Safety and Early Detection of Errors: By enforcing constraints at compile time, Concepts help prevent misuse of templates and detect errors earlier in the development process.
  4. Simplification of Template Metaprogramming: Concepts reduce the need for complicated template metaprogramming tricks like SFINAE. Constraints and type checks can be written in a more natural and declarative style, which simplifies code and improves maintainability.