The Big Four of C++20: Concept
05 Apr 2022Preface: The Evolution of C++ Templates
The evolution of templates is a highly significant milestone in the history of C++ development. Personally, I believe that Concepts represent the most impactful feature along this trajectory. Before diving into the details of Concepts, let us first review the development of templates over time.
timeline
title C++ Templates Evolution
1979 : C with Classes - First Implementation
1988 : First Formal Proposal of Templates
1998 : Templates Enter the Standard (Function/Class Templates, SFINAE)
2011 : C++11 Variadic Templates
2014 : C++14 Variable Templates, Generic Lambda
2017 : C++17 Template Parameter Deduction, if constexpr
2020 : C++20 Concepts
According to Mr. Stroustrup’s recollection, the concept of templates dates back to 1982. However, it was officially proposed at the 1988 USENIX C++ Conference. The initial motivation for designing templates stemmed from the lack of a standard library in C++ at the time. Without templates, it was difficult to design “vector” and “list” containers that could work with multiple data types.
By 1998, templates were officially included in the C++ standard. Even before that, C++ templates had already evolved into a Turing-complete language. Theoretically, any computable problem could be solved using template code alone, utilizing techniques like recursion for loops, template specialization, and partial specialization for branching logic.
For instance, the following template demonstrates a Fibonacci function that can be computed at compile-time:
template<int N>
int fibonacci() {
return fibonacci<N-1>() + fibonacci<N-2>();
}
template<>
int fibonacci<1>() { return 1; }
template<>
int fibonacci<0>() { return 0; }
The SFINAE feature ensures the proper functioning of the above code. The two template specializations establish the base case for recursion, implicitly implementing the functionality of an if condition.
By the time C++17 introduced if constexpr
, the same code could be written in a much more “straightforward” manner.
template<int N>
constexpr int fibonacci(){
if constexpr(N <= 1)
return N;
else
return fibonacci<N-1>() + fibonacci<N-2>();
}
auto val = fibonacci<5>();
Whether it’s SFINAE, std::enable_if
, or if constexpr
, they all essentially serve the same purpose: making the use of compile-time if more straightforward and user-friendly. Compile-time if can be used to evaluate numeric conditions, as seen in the Fibonacci template example, but more often, it is used to evaluate types—a task for which std::enable_if
is specifically designed.
template <typename T, std::enable_if_t<std::is_enum<T>::value, bool> = true>
void func(const T& value) {
// When T is an enum type, this template function is selected
.......
}
template <typename T, std::enable_if_t<std::is_arithmetic<T>::value, bool> = true>
void func(const T& value) {
// When `T` is a numeric type, this template function is selected
.......
}
func
has an overload, and its internal logic needs to differentiate between value
’s type: handling enum and numeric types separately.
To allow the compiler to match the correct function template during instantiation, we use std::enable_if_t
with this long piece of code. While it accomplishes the functionality, the code is verbose and lacks readability.
At this point, concepts can come into play.
1. What is a Concept?
— A constraint (or a set of constraints) on T
.
Let’s demonstrate “constraints on T
” with a simple example:
template<typename T>
concept Integral = std::is_integral<T>::value;
template<Integral T>
bool equal(const T& a, const T& b){
return a == b;
}
The function template equal
returns whether two values are equal. This operation cannot be applied to floating-point types.
To prevent this template from being instantiated with float
or other floating-point types, an Integral
concept is used to constrain T
—ensuring that the template can only be instantiated with integer types.
In the function template equal
, T
is no longer just a “typename”; it is now constrained by Integral
, which is semantically more precise.
If we attempt to instantiate it with a floating-point type, an error will be raised:
auto value_a = equal(1, 3); //OK
auto value_b = equal(0.11, 0.33); //ERROR
equal(0.11, 0.33)
will result in a compile-time error:
2. Why Do We Need Concepts?
If we use std::enable_if
to implement the functionality above, we can compare it to concepts in terms of two aspects: code readability and error messages.
1. Better Code Readability Concepts provide a much clearer and more intuitive way to express constraints, making the code easier to understand and maintain.
template<typename T, std::enable_if_t<std::is_integral<T>::value, bool> = true>
bool equal_2(const T& a, const T& b){
return a == b;
}
Using concepts to constrain T
is more concise and aligns better with natural semantics compared to std::enable_if
.
2. Reduced Code Duplication
Using std::enable_if
for type constraints can lead to a lot of repetitive code, whereas concepts are much easier to reuse.
3. Clearer and More Direct Error Messages
When a concept is not satisfied, the compiler directly points out which concept is not met. On the other hand, error messages from std::enable_if
feel more like “scratching an itch through the boot.”
4. Four Syntax Forms for Constraining T
template<my_concept T>
void func(T t);
void func(my_concept t);
template<typename T> requires my_concept<T>
void func(T t);
template<typename T>
void func(T t) requires my_concept<T>;
The second form is undoubtedly the most concise and naturally semantic way to write it.
The last two forms use the requires
keyword, a method known as a “require-clause,” which allows for the convenient combination of multiple concepts:
template<typename T> requires concept_a<T> && concept_b<T> || sizeof<T> == 4
void func(T t);
4. Built-in Concepts
The <concepts>
header file provides commonly used concepts that satisfy most daily development needs.
These concepts can be categorized into:
- Core language concepts
- Comparison concepts
- Object concepts
- Callable concepts
- Iterator concepts
- Range concepts
Concept
Concept | Description in |
---|---|
same_as |
A type that is identical to another type. |
derived_from |
A type derived from another type. |
convertible_to |
A type that can be explicitly or implicitly converted to another type. |
common_reference_with |
Two types that share a common reference type. |
common_with |
Two types that share a common type after type promotion. |
integral |
A type that is an integer type. |
signed_integral |
A type that is a signed integer type. |
unsigned_integral |
A type that is an unsigned integer type. |
floating_point |
A type that is a floating-point type. |
assignable_from |
A type that can be assigned a value from another type. |
swappable |
A type whose instances can be swapped, or two types that can be swapped with each other. |
swappable_with |
Two types that can be swapped with each other. |
destructible |
A type whose objects can be properly destroyed. |
constructible_from |
A type whose variables can be constructed from a set of argument types, or explicitly initialized with argument types. |
default_constructible |
A type whose objects can be default-constructed. |
move_constructible |
A type whose objects can be move-constructed. |
copy_constructible |
A type whose objects can be copy-constructed and move-constructed. |
boolean |
A type that can be used in boolean logic. |
equality_comparable |
A type where the == operator defines an equality relationship. |
equality_comparable_with |
A type that can define equality relationships with another type. |
totally_ordered |
A type where comparison operators define a total order. |
totally_ordered_with |
A type that can define a total order relationship with another type. |
movable |
A type whose objects can be moved and swapped. |
copyable |
A type whose objects can be copied (copyable ) and moved (movable ). |
semiregular |
A type that is copyable and supports default initialization. |
regular |
A type that is semiregular and also equality comparable (equality_comparable ). |
invocable |
A callable type that can be invoked with a given set of argument types. |
regular_invocable |
A callable type that adheres to the rules of regular invocation. |
predicate |
A callable type that evaluates to a boolean value. |
relation |
A callable type that evaluates to a binary relationship. |
strict_weak_order |
A binary relationship that imposes a strict weak ordering. |
readable |
A type that can be read using the dereference operator * . |
writable |
A type whose referenced object can be written to via an iterator. |
weakly_incrementable |
A semiregular type that supports prefix and postfix increment operations. |
incrementable |
A weakly_incrementable type where increment operations preserve equality comparability. |
input_or_output_iterator |
A type whose object can be incremented and dereferenced. |
sentinel_for |
A type that acts as a positional indicator for an input_or_output_iterator . |
sized_sentinel_for |
A type that acts as a sentinel for an iterator, supporting difference calculations with constant time. |
input_iterator |
A type for reading values via an input iterator, supporting prefix/postfix increment operations. |
output_iterator |
A type for writing values via an output iterator, supporting prefix/postfix increment operations. |
forward_iterator |
A type that extends input_iterator with equality comparison and multi-pass traversal. |
bidirectional_iterator |
A forward_iterator that supports backward traversal. |
random_access_iterator |
A bidirectional_iterator supporting constant-time random access for forward and backward traversal. |
contiguous_iterator |
A random_access_iterator that points to elements stored contiguously in memory. |
range |
Specifies a type as a range, providing begin and end iterators. |
safe_range |
Specifies a range type that safely returns iterators without undefined behavior. |
sized_range |
Specifies a range where the size can be determined in constant time. |
view |
Specifies a range that supports constant-time copy, move, and assignment. |
input_range |
Specifies a range whose iterator satisfies input_iterator . |
output_range |
Specifies a range whose iterator satisfies output_iterator . |
forward_range |
Specifies a range whose iterator satisfies forward_iterator . |
bidirectional_range |
Specifies a range whose iterator satisfies bidirectional_iterator . |
random_access_range |
Specifies a range whose iterator satisfies random_access_iterator . |
contiguous_range |
Specifies a range whose iterator satisfies contiguous_iterator . |
common_range |
Specifies a range with compatible iterators and sentinels. |
viewable_range |
Specifies a range that can be safely converted to a view . |
5. Defining Your Own Concepts
If the concepts provided in the <concepts>
header file cannot satisfy specific requirements, we need to define our own concepts:
template <template-parameter-list >
concept concept-name = constraint-expression;
It is worth noting that the definition of a concept cannot be recursive and cannot be constrained by other concepts during its definition.
template<typename T>
concept V = V<T*>; // Error: Recursive definition is not allowed
template<C1 T>
concept Error1 = true; // Error: Cannot be constrained by other concepts
template<class T> requires C1<T>
concept Error2 = true; // Error: Cannot be constrained by other concepts
If the template-parameter-list
of a concept declares more than one parameter, the argument list provided when using the concept must have one fewer argument than the parameter list.
template <typename T, typename B>
concept Derived = std::is_base_of<B, T>::value;
template<Derived<Base> T>
void f(T param);
6. The requires
Keyword
In Section 3, we introduced the requires
keyword, which can be used to define a “require-clause” to enforce constraints. A require-clause can combine multiple constraints using &&
and ||
operators for flexibility.
In addition to require-clauses, the requires
keyword has another usage: the require-expression.
requires {requirement-seq}
requires (parameter-list) {requirement-seq}
template<typename T>
concept C = requires(T x, T y) {
//////////////////////////Simple Constraints////////////////////////////
x+y; // x+y is valid (x and y can be added)
T::func(); // T defines a static method func (public)
x.func2(); // T defines a non-static method func2 (public)
&x; // x can be addressed
sizeof(T) == 10;// sizeof(T) can be compared with 10 — this constraint is always true
//////////////////////////Type Constraints////////////////////////////
typename T::MyType; // MyType is a nested type defined within T (public)
//////////////////////////Composite Constraints////////////////////////////
//1. x.func() is valid (func is a member method of x)
//2. MyType is defined within T
//3. The return value of func() can be converted to MyType
{x.func()} -> std::convertible_to<typename T::MyType>;
// x.func() is valid and does not throw exceptions
{x.func()} noexcept;
{*x};
//////////////////////////Nested Constraints////////////////////////////
requires MyConcept<T>; // T satisfies the constraints of the MyConcept concept
requires (sizeof(T) == 4);// T's objects occupy 4 bytes of memory
};
Using a requires-expression
allows the definition of concepts with very complex logic.
The parentheses ()
enclose the parameter list (which is optional), while the curly braces {}
enclose the constraint list.
The expressions in the constraint list are checked by the compiler for validity (the expressions are not evaluated; only their feasibility is verified), and the result is true
or false
. If all expressions in the constraint list pass the checks, the requires
expression evaluates to true
.
In this example, the constraint x + y;
does not actually perform addition but instead requires that x
and y
are “addable.”
The constraints in a constraint list can be divided into four categories:
- Simple Constraints: An arbitrary expression, where the compiler only checks its syntactical validity. For example:
x + y;
is a simple constraint. - Type Constraints: Starts with
typename
, followed by a type name. - Composite Constraints: Has the form
{ expression } noexcept (optional) -> return-type-requirement (optional);
.The compiler checks the validity ofexpression
. Ifnoexcept
is used, the compiler also checks whetherexpression
may potentially throw exceptions. Finally, ifreturn-type-requirement
is not omitted, the compiler checks whether the return type ofexpression
matches the requirement. - Nested Constraints: Starts with the
requires
keyword, followed by other constraint expressions.
Afterword
When researching concept
, the term constraints
often appears alongside it. These two terms can be confusing. Personally, I think they can be understood as follows:
Concept
is a means, whileconstraints
are the goal.Concept
is the tool used to achieve constraints.- The essence of a
concept
is a set. This set defines one or more “conditions.” During compilation, these “conditions” are applied toT
. IfT
fails to satisfy any of these “conditions,” a compilation error is generated. This process is what constraints are about. - The relationship between
concept
and templates is akin to the relationship between templates and regular code. Templates generalize specific types intoT
, while concepts impose constraints onT
.