Using Concepts with Classes
Learn how to use concepts to express constraints on classes, ensuring they have particular members, methods, and operators.
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
::
withinrequires
expressions. - Concepts like
std::integral
,std::floating_point
,std::same_as
, and type traits likestd::is_base_of
andstd::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 withinrequires
expressions. - You can combine multiple requirements within a single concept using logical operators like
&&
and||
to define comprehensive constraints on class templates.
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