During the previous weeks, we discussed the motivations behind C++ concepts and how to use them with functions and with classes. But we have hardly written any. We defined a functionally incomplete concept called Number
for the sake of example, but that's it. Now are going into details on what kind of constraints we can express in a concept.
This article would be too long if I included the different kinds of constraints all at once. In this one, we are going start from the simples concepts combining existing ones then we are going to finish with required operations and in general requirements on a class' API.
Next week, I'll show you how to write requirements on return types, how to express type requirements and how to nest constraints.
It's high time to finally get started.
The simples concept
Let's define the simplest concept we can imagine first, just to see the syntax.
template<typename T>
concept Any = true;
First, we list the template parameters, in this case, we have only one, T
, but we could have multiple ones separated by commas. Then after the keyword concept,
we declare the name of the concept and then after the =
we define the concept.
In this example, we simply say true
, meaning that for any type T
the concept will be evaluated to true
; any type is accepted. Should we wrote false
, nothing would be accepted.
Now that we saw the simplest concept, let's check what building blocks are at our disposal to construct a more detailed concept.
Use already defined concepts
Arguably the easiest way to define new concepts is by combining existing ones.
For instance, in the next example, we are going to create - once again - a concept called Number
by accepting both integers and floating-point numbers.
#include <concepts>
template<typename T>
concept Number = std::integral<T> || std::floating_point<T>;
As you can see in the above example, we could easily combine with the ||
operator two concepts. Of course, we can use any logical operator.
Probably it's self-evident, but we can use user-defined concepts as well.
#include <concepts>
template<typename T>
concept Integer = std::integral<T>;
template<typename T>
concept Float = std::floating_point<T>;
template<typename T>
concept Number = Integer<T> || Float<T>;
In this example, we basically just aliased (and added a layer of indirection to) std::integral
and std::floating_point
to show that user-defined concepts can also be used in a combination of concepts.
As we saw earlier, there are plenty of concepts defined in the different headers of the standard library so there is an endless way to combine them.
But how to define truly unique concepts?
Write your own constraints
In the coming sections, we are going to delve into how to express our own unique requirements without using any of the predefined concepts.
Requirements on operations
We can simply express that we require that a template parameter support a certain operation or operator by wishful writing.
If you require that template parameters are addable you can create a concept for that:
#include <iostream>
#include <concepts>
template <typename T>
concept Addable = requires (T a, T b) {
a + b;
};
auto add(Addable auto x, Addable auto y) {
return x + y;
}
struct WrappedInt {
int m_int;
};
int main () {
std::cout << add(4, 5) << '\n';
std::cout << add(true, true) << '\n';
// std::cout << add(WrappedInt{4}, WrappedInt{5}) << '\n'; // error: use of function 'auto add(auto:11, auto:12) [with auto:11 = WrappedInt; auto:12 = WrappedInt]' with unsatisfied constraints
}
/*
9
2
*/
We can observe that when add()
is called with parameters of type WrappedInt
- as they do not support operator+
- the compilation fails with a rather descriptive error message (not the whole error message is copied over into the above example).
Writing the Addable
concept seems rather easy, right? After the requires
keyword we basically wrote down what kind of syntax we expect to compile and run.
Simple requirements on the interface
Let's think about operations for a little longer. What does it mean after all to require the support of a +
operation?
It means that we constrain the accepted types to those having a function T T::operator+(const T& other) const
function. Or it can even be T T::operator+(const U& other) const
, as maybe we want to add to an instance of another type, but that's not the point here. My point is that we made a requirement on having a specific function.
So we should be able to define a requirement on any function call, shouldn't we?
Right, let's see how to do it.
#include <iostream>
#include <string>
#include <concepts>
template <typename T> // 2
concept HasSquare = requires (T t) {
t.square();
};
class IntWithoutSquare {
public:
IntWithoutSquare(int num) : m_num(num) {}
private:
int m_num;
};
class IntWithSquare {
public:
IntWithSquare(int num) : m_num(num) {}
int square() {
return m_num * m_num;
}
private:
int m_num;
};
void printSquare(HasSquare auto number) { // 1
std::cout << number.square() << '\n';
}
int main() {
printSquare(IntWithoutSquare{4}); // error: use of function 'void printSquare(auto:11) [with auto:11 = IntWithoutSquare]' with unsatisfied constraints,
// the required expression 't.square()' is invalid
printSquare(IntWithSquare{5});
}
In this example, we have a function printSquare
(1) that requires a parameter satisfying the concept HasSquare
(2). In that concept, we can see that it's really easy to define what interface we expect. After the requires
keyword, we have to write down how what calls should be supported by the interface of the accepted types.
Our expectations are written after the requires
keyword. First, there is a parameter list between parentheses - like for a function - where we have to list all the template parameters that would be constrained and any other parameters that might appear in the constraints. More on that later.
If we expect that any passed in type have a function called square
, we simply have to write (T t) {t.square();}
. (T t)
because we want to define a constraint on an instance of T
template type and t.square()
because we expect that t
instance of type T
must have a public function square()
.
If we have requirements on the validity of multiple function calls, we just have to list all of them separated by a semicolon like if we called them one after the other:
template <typename T>
concept HasSquare = requires (T t) {
t.square();
t.sqrt();
};
What about parameters? Let's define a power
function that takes an int
parameter for the exponent:
template <typename T>
concept HasPower = requires (T t, int exponent) {
t.power(exponent);
};
// ...
void printPower(HasPower auto number) {
std::cout << number.power(3) << '\n';
}
The exponent
variable that we pass to the T::power
function has to be listed after the requires
keyword with its type, along with the template type(s) we constrain. As such, we fix that the parameter will be something that is (convertible to) an int
.
But what if we wanted to accept just any integral number as an exponent. Where is a will, there is a way! Well, it's not always true when it comes to syntactical questions, but we got lucky in this case.
First, our concept HasPower
should take two parameters. One for the base type and one for the exponent type.
template <typename Base, typename Exponent>
concept HasPower = std::integral<Exponent> && requires (Base base, Exponent exponent) {
base.power(exponent);
};
We make sure that template type Exponent
is an integral and that it can be passed to Base::power()
as a parameter.
The next step is to update our printPower
function. The concept HasPower
has changed, now it takes two types, we have to make some changes accordingly:
template<typename Exponent>
void printPower(HasPower<Exponent> auto number, Exponent exponent) {
std::cout << number.power(exponent) << '\n';
}
As Exponent
is explicitly listed as a template type parameter, there is no need for the auto
keyword after it. On the other hand, auto
is needed after HasPower
, otherwise, how would we know that it's a concept and not a specific type?! As Exponent
is passed as a template type parameter to HasPower
constraints are applied to it too.
Now printPower
can be called the following way - given that we renamed IntWithSquare
to IntWithPower
following our API changes:
printPower(IntWithPower{5}, 3);
printPower(IntWithPower{5}, 4L);
At the same time, the call printPower(IntWithPower{5}, 3.0);
will fail because the type float
does not satisfy the constraint on integrality.
Do we miss something? Yes! We can't use IntWithPower
as an exponent. We want to be able to call Base::power(Exponent exp)
with a custom type, like IntWithPower
and for that, we need two things:
-
IntWithPower
should be considered anintegral
type -
IntWithPower
should be convertible to something accepted bypow
from thecmath
header.
Let's go one by one.
By explicitly specifying the type_trait
std::is_integral
for IntWithPower
, we can make IntWithPower
an integral type. Of course, if we plan to do so in real life, it's better to make sure that our type has all the characteristics of an integral type, but that's beyond our scope here.
template<>
struct std::is_integral<IntWithPower> : public std::integral_constant<bool, true> {};
Now we have to make sure that IntWithPower
is convertible into a type that is accepted by pow
. It accepts floating-point types, but when it comes to IntWithPower
, in my opinion, it's more meaningful to convert it to an int
and let the compiler perform the implicit conversion to float
- even though it's better to avoid implicit conversions in general. But after all, IntWithPower
might be used in other contexts as well - as an integer.
For that we have to define operator int
:
class IntWithPower {
public:
IntWithPower(int num) : m_num(num) {}
int power(IntWithPower exp) {
return pow(m_num, exp);
}
operator int() const {return m_num;}
private:
int m_num;
}
If we check our example now, we'll see that both printPower(IntWithPower{5}, IntWithPower{4});
and printPower(IntWithPower{5}, 4L);
will compile, but printPower(IntWithPower{5}, 3.0);
will fail because 3.0
is not integral.
Right, as we just stated, pow
operates on floating-point numbers but we only accept integrals. Let's update our concept accordingly!
template <typename Base, typename Exponent>
concept HasPower = (std::integral<Exponent> || std::floating_point<Exponent>) && requires (Base base, Exponent exponent) {
base.power(exponent);
};
Now we can call printPower
with any type for base
that satisfies the HasPower
concept and both with integral and floating-point numbers as an exponent.
Let's have a look at the full example now:
#include <cmath>
#include <iostream>
#include <string>
#include <concepts>
#include <type_traits>
template <typename Base, typename Exponent>
concept HasPower = (std::integral<Exponent> || std::floating_point<Exponent>) && requires (Base base, Exponent exponent) {
base.power(exponent);
};
class IntWithPower {
public:
IntWithPower(int num) : m_num(num) {}
int power(IntWithPower exp) {
return pow(m_num, exp);
}
operator int() const {return m_num;}
private:
int m_num;
};
template<>
struct std::is_integral<IntWithPower> : public std::integral_constant<bool, true> {};
template<typename Exponent>
void printPower(HasPower<Exponent> auto number, Exponent exponent) {
std::cout << number.power(exponent) << '\n';
}
int main() {
printPower(IntWithPower{5}, IntWithPower{4});
printPower(IntWithPower{5}, 4L);
printPower(IntWithPower{5}, 3.0);
}
In this example, we can observe how to write a concept that expects the presence of a certain function that can accept a parameter of different constrained types. We can also see how to make a type satisfying built-in type traits, such as std::is_integral
.
Conclusion
Today we started to discover how to write our own concepts. First, we combined already existing concepts into more complex ones, then we continued with making requirements on the validity of operations on the constrained types then we finished by writing requirements for any function call with or without a parameter list.
Next time we'll continue with constraining the return types, making type and then nested requirements.
Stay tuned!
If you want to learn more details about C++ concepts, check out my book on Leanpub!
Connect deeper
If you found interesting this article, please subscribe to my personal blog and let's connect on Twitter!
Top comments (2)
That's such a great and complete explanation, thanks Sandor!
Thanks for your kind words, Alberto! I'm glad you liked it!