Creating Custom Concepts

Learn how to create your own C++20 concepts to define precise requirements for types, using boolean expressions and requires statements.
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
Posted

In the previous lesson, we introduced C++20 concepts, which allow us to determine if a type meets some specific requirements at compile time. We used concepts like std::integral and std::floating_point in our examples, but of course, we’re not restricted to just what’s available in the standard library.

We can create our own concepts to test types against the exact requirements we have for our program. In this lesson, we’ll cover the key techniques to set this up.

Writing Our Own Concepts

A basic concept looks like this:

#include <concepts>

template <typename T>
concept Integer = std::integral<T>;

We can break it down into four components:

  • A template parameter list (note, this list cannot reference other concepts)
  • The concept keyword
  • The name we want to use for the concept
  • An expression that will return true if the concept is met, or false otherwise.

We can then use our concept in any of the usual ways we covered in the previous lesson:

#include <concepts>
#include <iostream>

template <typename T>
concept Integer = std::integral<T>;

// As a constrained template parameter
template <Integer T>  
void ConstrainedTemplate(T x) {
  std::cout << "That's an integer\n";
}

// As a compile-time boolean expression
template <typename T>
void UnconstrainedTemplate(T x) {
  if constexpr (Integer<T>) {
    std::cout << "That's an integer too\n";
  }
}

int main() {
  ConstrainedTemplate(1);
  UnconstrainedTemplate(2);
}
That's an integer
That's an integer too

Our Integer concept is effectively just re-implementing the std::is_integral concept under a different name, but we can of course go further.

The following Numeric concept combines two standard library concepts and will be satisfied if a type matches either:

#include <concepts>

template <typename T>
concept Numeric =
    std::integral<T> || std::floating_point<T>;

The logic that ultimately returns the boolean representing whether or not the type satisfies the concept can be arbitrarily complex.

Additionally, C++20 introduced additional syntax involving the requires keyword, which can make implementing this logic easier.

Using requires Expressions

When our requirements get more complex, trying to craft a single boolean expression to test for them can get messy. Instead, we can combine multiple requires statements within a requires expression. It looks like this:

template <typename T>
concept MyConcept = requires {
  // ...
};

Between the braces, we list all of our requirements as individual statements. For the concept to be satisfied, all of the requirements must be satisfied. Below, we use this to create a concept that is satisfied if a type is both convertible to and convertible from an int:

#include <concepts>
#include <iostream>

template <typename T>
concept ConvertibleToFromInt = requires {
  requires std::convertible_to<int, T>;
  requires std::convertible_to<T, int>;
};

template <ConvertibleToFromInt T>
void Template(int x) {
  std::cout << "That was convertible: "
    << int(T{x});
}

template <typename T>
void Template(int x) {
  std::cout << "\nThat was not";
}

struct SomeType {
  SomeType(int x) : Value{x} {};
  operator int() { return Value; };

  int Value;
};

int main() {
  Template<SomeType>(42);
  Template<std::string>(42);
}
That was convertible: 42
That was not

Common Mistake: Invalid vs False

For a type to satisfy a concept, the statements within the requires expression must be valid for that type. That does not mean the expression needs to evaluate to true.

In the following example, we’re trying to write a requirement where a type must be integral. This concept will always be satisfied because, for any type, std::integral<T> will be valid. It will not be a substitution failure - it will simply either be true or false:

#include <concepts>
#include <iostream>

template <typename T>
concept Integer = requires {
  std::integral<T>;
};

int main() {
  if (Integer<std::string>) {
    std::cout << "The concept is satisfied";
  }
}
The concept is satisfied

To assert that an expression must not only be valid but also true, we should add the requires keyword before it:

#include <concepts>
#include <iostream>

template <typename T>
concept Integer = requires {
  requires std::integral<T>;
};

int main() {
  if (!Integer<std::string>) {
    std::cout << "The concept is not satisfied";
  }
}
The concept is not satisfied

This distinction will make more sense as we see more examples later in the lesson.

Combining and Negating requires Expressions

Our concepts can combine boolean expressions and requires statements as needed to implement more complex checks. We do this using standard boolean operators like && and ||.

Below, our concept requires the type to be a class or struct using the std::is_class type trait, and additionally requires it be convertible to and from an int using the same requires expression we created earlier:

#include <concepts>
#include <iostream>

template <typename T>
concept IntClass = std::is_class_v<T> &&
  requires {
    requires std::convertible_to<int, T>;
    requires std::convertible_to<T, int>;
  };
  
struct SomeType {/*...*/}; int main() { if (IntClass<SomeType>) { std::cout << "The concept is satisfied"; } }
The concept is satisfied

Below, our type must be a class or struct, and convertible to either a float or an int:

#include <concepts>
#include <iostream>

template <typename T>
concept IntOrFloatClass = std::is_class_v<T> &&
  requires {
    requires std::convertible_to<int, T>;
    requires std::convertible_to<T, int>;
  } || requires {
    requires std::convertible_to<float, T>;
    requires std::convertible_to<T, float>;
};

struct SomeType {/*...*/}; int main() { if (IntOrFloatClass<SomeType>) { std::cout << "The concept is satisfied"; } }
The concept is satisfied

We can also negate requires statements by negating the boolean they’re checking using the ! operator in the normal way. Below, we’re requiring our type be convertible from an int, but not to an int:

template <typename T>
concept SomeConcept = requires {
  requires std::convertible_to<int, T>;
  requires !std::convertible_to<T, int>;
};

We can also negate an entire requires expression, which effectively translates to requiring at least one of the statements within the expression to be invalid.

Below, we reject a type that is simultaneously convertible from and to an int. Being convertible in one direction (or neither) is fine, but not both:

template <typename T>
concept SomeConcept = !requires {
  requires std::convertible_to<int, T>;
  requires std::convertible_to<T, int>;
};

Functional requires Syntax

We can also use a requires expression that looks somewhat like a function:

template <typename T>
concept MyConcept = requires(T x) {
  // ...
};

Within the body of the previous expression, we can imagine x being a value of the type we’re testing - T, in this case. We then provide statements that use this value:

template <typename T>
concept Addable = requires(T x) {
  x + x;
};

It might look like we’re writing a function here, but what we’re doing is specifying requirements on the type T. In the above example, we’re asking the compiler to imagine x is an instance of our template type, T.

We’re then asking it to determine whether x + x would be a valid statement in that scenario. If this would be valid, then T satisfies our concept.

In the following example, we’re providing 4 statements, all of which must be valid for T to satisfy our Arithmetic concept:

#include <iostream>

template <typename T>
concept Arithmetic = requires(T x) {
  x + x;
  x - x;
  x * x;
  x / x;
};

int main() {
  if (Arithmetic<int>) {
    std::cout << "int satisfies the concept";
  }
  if (!Arithmetic<std::string>) {
    std::cout << "\nstd::string does not";
  }
}
int satisfies the concept
std::string does not

Here is a final example, where we’re again saying T must implement the + operator where the right operand is to another T.

But we have an additional requirement: whatever type is returned by that operator+ function must implement the / operator, where the right operand is an int such as 2:

template <typename T>
concept Averagable = requires(T x, T y){
  (x + y) / 2;
};

If a type meets those requirements, it is Averagable, otherwise, it is not.

#include <string>

template <typename T>
concept Averagable =
requires(T x, T y){
  (x + y) / 2;
};

int main(){
  // This is fine
  Average(3, 5);

  // This is not
  std::string A{"Hello"};
  std::string B{"World"};
  Average(A, B);
}

Before the introduction of concepts in C++20, something like this would have been extremely difficult to set up, requiring advanced template metaprogramming.

Now, it’s fairly straightforward, and with most compilers, the error output is much better than we’d be able to achieve previously. It may look something like this:

error: 'Average': no matching overloaded function found
could be 'T Average(T,T)'
but the associated constraints are not satisfied
the concept 'Averagable<std::string>' evaluated to false
binary '/': 'std::string' does not define this operator

The compiler tells us that no function was found that could satisfy that specific call to Average. It correctly identified our template function as a candidate, but ruled it out because of the type constraints we added.

It also explains exactly why the concept returned false - because the std::string type does not define the / operator we specified as a requirement.

Multiple Template Parameters

Our previous concepts have using a single template parameter, but we are free to use multiple:

#include <iostream>

template <typename T1, typename T2>
concept Addable = requires(T1 x, T2 y) {
  x + y;
};

int main() {
  if (Addable<int, float>) {
    std::cout << "int is addable to float";
  }
  if (!Addable<int, std::string>) {
    std::cout << "\nbut not to std::string";
  }
}
int is addable to float
but not to std::string

The order of our template parameters is important for the usual reason - it corresponds with the order of arguments.

However, when working with concepts, there is an additional consideration for our design. Specifically, the first argument will be provided automatically when the concept is used to constrain a template.

We’ve already seen examples of this. When using a concept like std::integral as a boolean expression, we need to explicitly provide the type we’re testing within the <>:

std::integral<int>

But, when using it to constrain a template, the argument is populated automatically during substitution:

#include <concepts>
#include <iostream>

template <std::integral T>
void Template(T x) {
  std::cout << "\nThat was integral too";
}

int main() {
  // We invoke the concept with int
  if (std::integral<int>) {
    std::cout << "That was integral";
  }
  
  // The compiler invokes the concept with int
  Template(42);
}
That was integral
That was integral too

When the concept has multiple parameters, substitution provides the value for the first parameter.

This means that if we’re using a concept to constrain template parameters, and that concept requires multiple arguments, we need to provide the additional arguments within the argument list of our template.

In the following example, our concept is called with <int, float> in both cases:

#include <concepts>
#include <iostream>

template <typename T1, typename T2>
concept AddableTo = requires(T1 x, T2 y) {
  x + y;
};

template <AddableTo<float> T>  
void Template(T x) {
  std::cout << "that type is addable "
    "to float too";
}

int main() {
  if (AddableTo<int, float>) {
    std::cout << "int is addable to float\n";
  }
  Template(42);
}
int is addable to float
that type is addable to float too

Summary

In this lesson, we covered how to create custom concepts in C++20 to define requirements for types. The key takeaways are:

  • Concepts allow us to determine if a type meets specific requirements at compile time.
  • We can write our own concepts using the concept keyword and a boolean expression or a requires expression.
  • The requires expression allows us to list multiple requirements that must all be satisfied for the concept to be met.
  • We can combine and negate requires expressions using boolean operators like &&, ||, and !.
  • Concepts can use functional requires syntax to specify requirements on how instances of a type can be used.
  • Concepts can have multiple template parameters, with the first parameter being automatically provided during substitution when used to constrain a template.

Was this lesson useful?

Next Lesson

Using Concepts with Classes

Learn how to use concepts to express constraints on classes, ensuring they have particular members, methods, and operators.
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Posted
Lesson Contents

Creating Custom Concepts

Learn how to create your own C++20 concepts to define precise requirements for types, using boolean expressions and requires statements.

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

Using Concepts with Classes

Learn how to use concepts to express constraints on classes, ensuring they have particular members, methods, and operators.
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved