Using Concepts with Classes

Learn how to use concepts to express constraints on classes, ensuring they have particular members, methods, and operators.
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 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:

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

Function Pointers and std::invocable

In C++, functions also have a type, and like any other type, we can create pointers to them using the address-of operator &.

In the following example, our Call() function receives a function pointer as a parameter, and then invokes the function being pointed at:

#include <iostream>

template <typename T>
void Call(T Function) {
  Function();
}

void Greet() {
  std::cout << "Hello";
}

int main() {
  Call(&Greet);
}
Hello

We cover techniques like this in a full chapter later in the course, but it’s worth covering how concepts can help here.

When our template expects a typename to be something that can be invoked, like a function pointer, we can constrain it using the std::invocable concept:

#include <iostream>
#include <concepts>

template <std::invocable T>
void Call(T Function) {
  Function();
}

void Greet() {
  std::cout << "Hello";
}

int main() {
  Call(&Greet);
}
Hello

As with all concepts, we can use std::invocable as a standalone expression to generate a boolean at compile time. We provide the type we’re testing as the first template argument.

In the following example, we use decltype to provide the type of &Greet:

#include <iostream>
#include <concepts>

void Greet() {
  std::cout << "Hello";
}

int main() {
  if (std::invocable<decltype(&Greet)>) {
    std::cout << "Greet is invocable";
  }
}
Greet is invocable

When the function we’re testing is a member of a class, we need to qualify its exact location using the scope resolution operator, as in SomeType::SomeMethod.

We also have to provide the concept with a second template argument, which will be a pointer to our type. We discuss this second argument in more detail later in this lesson.

Putting these two properties together, our previous Renderable concept could be written using std::invocable like this:

#include <iostream>

template <typename T>
concept Renderable =
  std::invocable<decltype(&T::Render), T*>;

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);
};

std::declval()

Within concepts, we can continue to use std::declval to create hypothetical values, if preferred. Our previous Renderable concept could be written like this instead:

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

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

Parameter Types with std::invocable

The std::invocable concept accepts additional template arguments, beyond the type we’re testing. These template arguments represent the types we want our function to be invocable with.

Below, we check whether &Greet is invocable with a std::string and an int:

#include <concepts>
#include <iostream>

void Greet(std::string Greeting, int Num) {
  std::cout << Greeting << ", Num: " << Num;
}

int main() {
  if constexpr (std::invocable<
    decltype(&Greet), std::string, int>) {
    std::cout << "Greet is invocable with a "
                 "std::string and an int";
  }
}
Greet is invocable with a std::string and an int

Below, we’re using this concept to constrain a template type parameter.

Remember, when used in this context, the first argument to our concept is provided automatically during substitution, so we only provide the types our function will be invoked with:

#include <iostream>
#include <concepts>

template <std::invocable<std::string, int> T>
void Call(T Function) {
  Function("Hello", 42);
}

void Greet(std::string Greeting, int Num) {
  std::cout << Greeting << ", Num: " << Num;
}

int main() {
  Call(&Greet);
}
Hello, Num: 42

Reviewing our earlier Renderable concept, we can see it used std::invocable like this, indicating that we’ll be calling T::Render with a pointer to a T:

template <typename T>
concept Renderable =
  std::invocable<decltype(&T::Render), T*>;

This is typical with class methods. We can imagine the methods have a hidden argument, which will be a pointer to the object it was called on. For example, if we call SomeRock.Render(), the hidden parameter will be &SomeRock.

That is, it will be the value that is available through the this pointer within our method body.

In the following example, we update our Renderable concept to use std::invocable with additional parameter requirements:

#include <iostream>
#include <concepts>

template <typename T, typename R1, typename R2>
concept Renderable = std::invocable<
  decltype(&T::Render), T*, R1, R2>;

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.

 

Was this lesson useful?

Next Lesson

Run Time Type Information (RTTI) and typeid()

Learn to identify and react to object types at runtime in using RTTI, dynamic casting and the typeid() operator
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Posted
Lesson Contents

Using Concepts with Classes

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

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

Run Time Type Information (RTTI) and typeid()

Learn to identify and react to object types at runtime in using RTTI, dynamic casting and the typeid() operator
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved