Concepts in C++20
Learn how to use C++20 concepts to constrain template parameters, improve error messages, and enhance code readability.
Given that SFINAE is excessively complex and inelegant, C++20 introduced a new language feature that almost entirely replaces it: concepts
Concepts give us a more direct and powerful way of specifying constraints on template parameters. This feature allows us to write constraints in a much easier and more readable way. Some of the advantages include:
- Enforcing type constraints on template parameters without requiring SFINAE
- Making template error messages more readable and easier to understand
- Improving code documentation by explicitly specifying what types are allowed as parameters
In this lesson, we'll introduce concepts with some examples from the standard library. We'll then show the four main ways we can use concepts with our templates.
In the next lesson, we'll show how we can create our own concepts, specifying the exact requirements we need for our specific use cases.
What Are Concepts?
We can imagine a concept being a set of requirements for a type. We pass the type to a concept at compile time, and the concept returns true
if the type meets the requirements. Requirements can include things like:
- Is the type a number?
- Is the type a class?
- Is the type a class that is derived from
Player
? - Does the type have a member function called
Render()
that accepts anint
and returnsvoid
?
The standard library includes a collection of pre-defined concepts that cover the first three examples. In the next lesson, we'll see how we can create our own concepts for our highly specific requirements.
Most of the standard library concepts are available from the <concepts>
header:
#include <concepts>
For example, the std::integral
concept can tell us if a type is an integer: We pass the type as a template argument, and get a boolean back at compile time:
#include <iostream>
#include <concepts>
int main() {
if constexpr (std::integral<int>) {
std::cout << "int is an integral";
}
if constexpr (!std::integral<float>) {
std::cout << "\nfloat isn't";
}
}
int is an integral
float isn't
Of course, we could have done this exact same thing using type traits. What makes concepts different from type traits is that the C++ specification allows concepts to interact with templates as a feature of the language itself.
This capability is baked into the syntax of the language, and there are four main ways we can use it.
Option 1: Constrained Template Parameters
Within a template parameter list, we can replace typename
types with the name of a concept. This parameter will still receive a type name as an argument. But now, for the template to be a valid candidate, that type must implement the requirements specified in the concept.
So, if we wanted to ensure our type was an integer, we no longer need to manually create assertions or implement SFINAE. Instead, we can just replace typename
with the name of a concept in our template parameter list.
So, instead of writing this:
template <typename T>
void SomeFunction(T x) {
// ...
}
We can instead write this:
template <std::integral T>
void SomeFunction(T x) {
// ...
}
We're now fully documenting our type requirements, and our template will not appear within the overload candidate list when non-integral arguments are provided.
Additionally, the compiler will generate meaningful error messages explaining why our template wasn't an option: In this case, it was because the double
arguments we provided were not integral:
#include <concepts>
template <std::integral T>
T Average(T x, T y) {
return (x + y) / 2;
}
int main() {
// This is fine
Average(1, 2);
// This is not
Average(1.5, 2.2);
}
error: 'Average': no matching overloaded function found
could be 'T Average(T,T)' but the associated constraints are not satisfied
the concept 'std::integral<double>' evaluated to false
Similar to the example we showed using std::enable_if
in the previous lesson, concepts allow us to route function calls to different templates, in a much clearer way:
#include <iostream>
#include <concepts>
template <std::integral T>
T Average(T x, T y) {
std::cout << "Using integral function\n";
return (x + y) / 2;
}
template <std::floating_point T>
T Average(T x, T y) {
std::cout << "Using floating point function";
return (x + y) / 2;
}
int main() {
Average(1, 2);
Average(1.5, 2.2);
}
Using integral function
Using floating point function
Constrained vs Unconstrained Templates
In our Overload Resolution lesson, we mentioned that the compiler prefers to choose templates that are constrained by concepts over those that aren't.
Understanding Overload Resolution
Learn how the compiler decides which function to call based on the arguments we provide.
Below, we have a general, unconstrained function template that can be instantiated with any typename
.
We also have a template function with the same name, constrained such that only type names that satisfy std::integral
are supported. When the arguments are integral, the compiler selects the more specialized template:
#include <concepts>
#include <iostream>
template <std::integral T>
T Average(T x, T y) {
std::cout << "Using integral function\n";
return (x + y) / 2;
}
template <typename T>
T Average(T x, T y) {
std::cout << "Using generic function";
return (x + y) / 2;
}
int main() {
Average(1, 2);
Average(1.5, 2.2);
}
Using integral function
Using generic function
This gives us similar behaviour to what we described in our section on template specialization. The key difference is that with template specialization, we need to specify an exact type.
With concepts, we can be much more flexible.
Option 2: The requires
Keyword
The addition of concepts also came with a new piece of syntax - the requires
keyword. This keyword is used in a few different ways, which we'll cover in this lesson.
Below, we use the requires
keyword in a function template. It has access to the types our template is using, and will return true
if the template can handle those types.
We could rewrite our previous example using requires
like this:
#include <iostream>
#include <concepts>
template <typename T>
requires std::integral<T>
T Average(T x, T y) {
std::cout << "Using integral function\\n";
return (x + y) / 2;
}
template <typename T>
requires std::floating_point<T>
T Average(T x, T y) {
std::cout << "Using floating point function";
return (x + y) / 2;
}
int main() {
Average(1, 2);
Average(3.4, 5.3);
}
Using integral function
Using floating point function
The requires
method is more verbose than simply replacing typename
, but it gives us some more options. For example, we can use boolean logic. In the below example, we allow our type to match one of two concepts, using a requires
clause and the ||
operator:
#include <concepts>
template <typename T>
requires std::integral<T> ||
std::floating_point<T>
T Average(T x, T y) {
return (x + y) / 2;
}
int main() {
// This is fine
Average(1, 2);
Average(3.4, 5.3);
// This is not
Average("Hello", "World");
}
error: 'Average': no matching overloaded function found
the associated constraints are not satisfied
the concept 'std::integral<const char*>' evaluated to false
the concept 'std::floating_point<const char*>' evaluated to false
We're also not restricted to just using concepts within requires
statements - we can use other compile-time techniques too.
Below, we use the std::is_base_of
type trait from the previous lesson to state our template is only compatible with types that derive from Player
or Monster
:
#include <concepts>
class Player {};
class Monster {};
class Goblin : public Monster {};
class Rock {};
template <typename T>
requires std::is_base_of_v<Player, T> ||
std::is_base_of_v<Monster, T>
void Function(T Character) {}
int main() {
// This is fine
Function(Player{});
Function(Monster{});
Function(Goblin{});
// This is not
Function(Rock{});
}
error: 'Function': no matching overloaded function found
the associated constraints are not satisfied
Option 3: Trailing Requires Clause
When creating function templates, we have the option of using a requires
statement in a slightly different way. It can be placed between the function signature and the function body, like this:
template <typename T>
T Average(T x, T y)
requires std::integral<T>
{
return (x + y) / 2;
}
The main use case for this is when we have a function that is not a template, but is a member of a class or struct that is.
This effectively allows us to disable member functions based on the template parameters of the class or struct they're part of:
#include <concepts>
template <typename T>
struct Container {
void Function()
requires std::integral<T>
{}
};
int main() {
// This is fine
Container<int> IntContainer;
IntContainer.Function();
// This is not
Container<float> FloatContainer;
FloatContainer.Function();
}
error: 'Function': no function satisfied its constraints
the 'Container<float>::Function' constraint was not satisfied
the concept 'std::integral<float>' evaluated to false
Option 4: Abbreviated Function Template
The final way we can use concepts is within abbreviated function templates. As a reminder, abbreviated function templates let us create template functions simply by setting one or more parameter types to auto
:
auto Average(auto x, auto y) {
return (x + y) / 2;
}
We can constrain these parameters using concepts too. We insert the concept before the auto
type:
#include <concepts>
auto Average(std::integral auto x,
std::integral auto y) {
return (x + y) / 2;
}
int main() {
// This is fine
Average(1, 2);
// This is not
Average(1.0, 2.0);
}
error: 'Average': no matching overloaded function found
the concept 'std::integral<double>' evaluated to false
the constraint was not satisfied
Combining Approaches
We are free to combine the previous techniques as we see fit. Below, we use a constrained template parameter to specify requirements for the first type, and a requires
statement to restrict the second type.
For the template to be eligible for a given function call, all the requirements need to be met. In this case, the first type must be integral, whilst the second must be either integral or floating point:
template <std::integral T1, typename T2>
requires std::integral<T2> ||
std::floating_point<T2>
void Function(T1x , T2 y){}
Summary
In this lesson, we explored the powerful new feature introduced in C++20: concepts. Concepts provide a more expressive and readable way to constrain template parameters and improve template error messages. The key takeaways from this lesson are:
- Concepts are sets of requirements for types, allowing us to enforce type constraints on template parameters without relying on SFINAE.
- Concepts make template error messages more readable and easier to understand, improving code documentation and clarity.
- The standard library includes pre-defined concepts that cover common type requirements, such as
std::integral
andstd::floating_point
.
There are four main ways to use concepts with templates, and we can combine them as needed:
- Constrained Template Parameters: Replace
typename
with the name of a concept to constrain the template parameter. - The
requires
Keyword: Use therequires
keyword to specify constraints on template parameters using boolean logic and type traits. - Trailing Requires Clause: Place a
requires
statement between the function signature and the function body to disable member functions based on the template parameters of the class or struct they're part of. - Abbreviated Function Template: Constrain
auto
parameters in abbreviated function templates by inserting the concept before theauto
type.
Creating Custom Concepts
Learn how to create your own C++20 concepts to define precise requirements for types, using boolean expressions and requires
statements.