Using Concepts with Classes

Learn how to use concepts to express constraints on classes, ensuring they have particular members, methods, and operators.

Ryan McCombe
Published

In this lesson, we'll build on our previous knowledge of concepts to learn how to apply them to test more elaborate types, such as those defined by classes. By the end of this lesson, you will be able to:

  • Check for the existence of class members using requires expressions
  • Constrain the types of member variables using type traits and concepts like std::integral and std::same_as
  • Require class methods with specific parameter and return types using functional-style requires expressions
  • Ensure classes implement equality comparison operators like == and != using concepts
  • Combine multiple requirements within a concept to define comprehensive constraints on class templates

By mastering the techniques covered in this lesson, you'll be able to write more robust and expressive generic code using class templates. You'll catch errors at compile-time, provide clearer intentions about template requirements, and create more maintainable and self-documenting code.

Class Members

To check if a type has a specific member variable or function, we can use the scope resolution operator :: within a requires expression. Below, our Sized concept is satisfied if the type has a member called Size:

#include <iostream>

template <typename T>
concept Sized = requires { T::Size; };

struct Rock {
  int Size;
};

int main() {
  if constexpr (Sized<Rock>) {
    std::cout << "Rock has a size";
  }
}
Rock has a size

We can perform the same check within a functional-style requires expression as follows:

template <typename T>
concept Sized = requires(T x) {
  T::Size;

  // Alternatively:
  x.Size;
};

Remember, we can have multiple statements inside a requires expression, and we can combine expressions using boolean logic. Below, we require our type either have a Size member, or both a Width and Height member:

template <typename T>
concept Sized = requires {
  T::Size;
} || requires {
  T::Width;
  T::Height;
};

Class Member Types

When we're writing a concept that requires a type to have a specific member, we'll typically also care about the exact nature of that member. In the following example, we check whether the Size member satisfies the std::integral concept:

#include <concepts>
#include <iostream>

template <typename T>
concept IntegerSized =
  std::integral<decltype(T::Size)>;

Within a requires expression: our check could look like this:

#include <concepts>

template <typename T>
concept IntegerSized = requires {
  requires std::integral<decltype(T::Size)>;
};

Requiring an Exact Type

When we want to check if the member matches an exact type, we can use the std::same_as concept. Below, we require our Size member to be exactly an int:

template <typename T>
concept IntegerSized =
  std::same_as<int, decltype(T::Size)>;

However, an exact check like this is often more rigid than is necessary. For example, in most scenarios where our template requires an int, types like an int& or const int& would also be acceptable.

To handle this, we can perform our comparison with reference and / or const qualifiers removed from the type, using the std::remove_reference and std::remove_const type traits respectively.

We can remove both in a single step using the std::remove_cvref type trait:

#include <concepts>

template <typename T>
concept IntegerSized =
  std::same_as<int, std::remove_cvref_t<
    decltype(T::Size)>>;

We covered this scenario, and type traits like std::remove_cvref in more detail earlier in the chapter:

Type Traits: Compile-Time Type Analysis

Learn how to use type traits to perform compile-time type analysis, enable conditional compilation, and enforce type requirements in templates.

Class Methods

Typically, the friendliest way to require our types to have specific methods is to use a functional requires expression, and then provide statements that use those methods.

In the following example, we require our type to have a Render() method that is invocable with no arguments:

#include <iostream>

template <typename T>
concept Renderable = requires(T Object) {
  Object.Render();
};

struct Rock {
  void Render(){};
};

int main() {
  if constexpr (Renderable<Rock>) {
    std::cout << "Rock is renderable with no"
      " arguments";
  }
}
Rock is renderable with no arguments

Method Parameter Types

To require our methods to be callable with specific argument types, we can simply pass example values within the statement. Below, we require the Render() method to be invocable with two int arguments.

The fact we've chosen 1 and 2 as the values is inconsequential - we can just use any values of the type we're testing:

#include <concepts>
#include <iostream>

template <typename T>
concept Renderable = requires(T Object) {
  Object.Render(1, 2);
};

struct Rock {
  void Render(int x, int y){};
};

int main() {
  if constexpr (Renderable<Rock>) {
    std::cout << "Rock is renderable with "
      "two integer arguments";
  }
}
Rock is renderable with two integer arguments

We can alternatively add these as hypothetical arguments within the parameter list of our requires syntax. This lets us explicitly specify the type, and also give the variables a name, which can be help document our assumptions:

template <typename T>
concept Renderable =
  requires(T Object, int Width, int Height) {
    Object.Render(Width, Height);
};

Remember, our concepts are not restricted to a single template argument. Below, we update our Renderable concept to accept three arguments.

Two of them - R1 and R2 are used when testing the parameter list of the Render() method:

#include <concepts>
#include <iostream>

template <typename T, typename R1, typename R2>
concept Renderable =
  requires(T Object, R1 x, R2 y) {
    Object.Render(x, y);
};

struct Rock {
  void Render(int x, float y){};
};

int main() {
  if constexpr (Renderable<Rock, int, float>) {
    std::cout << "Rock is renderable with "
      "an int and a float";
  }
}
Rock is renderable with an int and a float

Method Return Types

The syntax to test a method's return value within a concept is a little more complex. In the following example, we're requiring our object to have a Render() method that accepts no arguments and returns something that satisfies the std::integral concept.:

template <typename T>
concept Renderable = requires(T Object) {
	{ Object.Render() } -> std::integral;
};

Commonly, we'll want to assert that the return type matches a specific type, or is convertible to a specific type. The C++ specification requires us to provide a concept here rather than a type, but the standard library's same_as and convertible_to concepts can cover our needs.

Below, we are requiring render to return an int:

template <typename T>
concept Renderable = requires(T Object) {
	{ Object.Render() } -> std::same_as<int>;
};

Below, we are requiring the Render() method to accept an argument of template parameter type A and return something convertible to another template parameter type, R:

#include <concepts>
#include <iostream>

template <typename T, typename A, typename R>
concept RenderReturns = requires(T Obj, A x) {
  { Obj.Render(x) } -> std::convertible_to<R>;  
};

struct Rock {
  int Render(int) { return 42; };
};

int main() {
  if constexpr (RenderReturns<Rock, int, float>) {
    std::cout << "Rock::Render(int) return type"
      " is convertible to a float";
  }
}
Rock::Render(int) return type is convertible to a float

Operators

As operators are essentially functions, we can test that a type has an operator in exactly the same way we'd test it have any other function.

Below, our concept requires that the type implement the == operator with another object of the same type being the right operand:

template <typename T>
concept Comparable = requires(T x, T y) {
  x == y; 
};

Below, we extend this to require the operator to return something that is convertible to a boolean:

#include <concepts>
#include <iostream>

struct Container {
  int Value;
  bool operator==(const Container& Other) const {
    return Other.Value != Value;
  }
};

template <typename T>
concept Comparable = requires(T x, T y) {
  { x == y } -> std::convertible_to<bool>;  
};

int main() {
  if constexpr (Comparable<Container>) {
    std::cout << "Container is comparable";
  }
}
Container is comparable

In this example, we expand the concept with an additional argument, allowing us to specify what type we're going to be comparing our object to:

#include <concepts>
#include <iostream>

struct Container {/*...*/}; template <typename T1, typename T2> concept ComparableTo = requires(T1 x, T2 y) { { x == y } -> std::convertible_to<bool>; { y == x } -> std::convertible_to<bool>; { x != y } -> std::convertible_to<bool>; { y != x } -> std::convertible_to<bool>; }; int main() { if constexpr (ComparableTo<Container, int>) { std::cout << "Container is comparable to int"; } }

Note, similar concepts to what we made above are already available in the standard library:

std::equality_comparable<Container>;
std::equality_comparable_to<Container, int>

Summary

In this lesson, we learned how to use concepts to test for more elaborate requirements. The key takeaways include:

  • Concepts allow you to define constraints on class instances, specifying requirements for member variables, member functions, and operators.
  • You can check for the presence of specific member variables and functions using the scope resolution operator :: within requires expressions.
  • Concepts like std::integral, std::floating_point, std::same_as, and type traits like std::is_base_of and std::is_integral can be used to constrain the types of class members.
  • The friendly syntax for requiring methods uses a functional-style requires expression with example parameter values, allowing you to specify parameter and return types.
  • Alternatively, methods can be checked for invocability with specific argument types using std::invocable and related concepts.
  • Operators like == and != can be tested as normal functions within requires expressions.
  • You can combine multiple requirements within a single concept using logical operators like && and || to define comprehensive constraints on class templates.
Next Lesson
Lesson 34 of 128

Run Time Type Information (RTTI) and typeid()

Learn to identify and react to object types at runtime using RTTI, dynamic casting and the typeid() operator

Questions & Answers

Answers are generated by AI models and may not have been reviewed. Be mindful when running any code on your device.

Constraining Member Variable Type
How can I require a class member variable to be of a specific type using concepts?
Requiring Method Signature
Can I constrain a class to have a method with a specific signature using concepts?
Requiring Equality Operator
How can I ensure a class has a proper equality operator using concepts?
Checking Member Type with Type Traits
Can I use type traits to check the type of a class member within a concept?
SFINAE vs Concepts
How do C++ concepts compare to SFINAE for constraining templates?
Requiring Multiple Member Functions
How can I require a class to have multiple member functions using concepts?
Constraining Class Templates
Can I use concepts to constrain class templates?
Or Ask your Own Question
Get an immediate answer to your specific question using our AI assistant