Concepts in C++20

Learn how to use C++20 concepts to constrain template parameters, improve error messages, and enhance code readability.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated

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.

C++20 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 an int and returns void?

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

Concepts and Overloads

Remember, just because a set of arguments did not satisfy a concept, that does not necessarily result in a compilation error. It just means that that specific template is not a viable overload for the function call.

Another function may be, and compilation can continue:

#include <iostream>
#include <concepts>

template <std::integral T>
T Average(T x, T y) {
  std::cout << "Using template function\n";
  return (x + y) / 2;
}

float Average(float x, float y) {
  std::cout << "Using regular function\n";
  return (x + y) / 2;
}

int main() {
  Average(1, 2);
  Average(1.5, 2.2);
}
Using template function
Using regular function

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.

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.

Non-Function Templates

The examples in this lesson use function templates, but concepts can be used with other forms of templates too. The syntax is broadly the same. Below, we create a template class that uses a concept to ensure it can only be instantiated with an integral type argument:

#include <concepts>

template <std::integral T>
class Container {
  T Contents;
};

int main() {
  // This is fine
  Container<int> IntegerThing;
  
  // This is not
  Container<float> FloatingThing;
}
'Container': the associated constraints are not satisfied
the concept 'std::integral<float>' evaluated to false

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

Concepts vs Type Traits

Many of the standard library-type traits that are intended to determine if a type meets a requirement have variations that use concepts instead. Most type traits that return a boolean value are in this category.

For example, the std::base_of type trait we used in the previous example could be replaced with the std::derived_from concept. We just need to reverse the order of the two arguments:

template <typename T>
  requires std::derived_from<T, Player> ||
           std::derived_from<T Monster>
void Function(T Character) {}

When we have a type trait that is intended to determine if a type meets some requirement, we should consider switching to a concept instead, as concepts are specifically designed for this use case.

The requires Keyword with Non-Function Templates

We can use a requires statement with other templates in the way we might expect. Below, we apply it to a class template:

#include <concepts>
template <typename T>
  requires std::integral<T>
class Container {
  T Contents;
};

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¬†and¬†std::floating_point.

There are four main ways to use concepts with templates, and we can combine them as needed:

  1. Constrained Template Parameters: Replace typename with the name of a concept to constrain the template parameter.
  2. The requires Keyword: Use the requires keyword to specify constraints on template parameters using boolean logic and type traits.
  3. 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.
  4. Abbreviated Function Template: Constrain auto parameters in abbreviated function templates by inserting the concept before the auto type.

Was this lesson useful?

Next Lesson

Creating Custom Concepts

Learn how to create your own C++20 concepts to define precise requirements for types, using boolean expressions and requires statements.
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Type Traits and Concepts
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 124 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Creating Custom Concepts

Learn how to create your own C++20 concepts to define precise requirements for types, using boolean expressions and requires statements.
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved