C++20 Concepts: Testing constrained functions
In my last month post How C++20 Concepts can simplify your code I introduced the different kind of a requires-clause, part of C++20s Concepts. Concepts and the requires-clause allow us to put constraints on functions or classes and other template constructs. In today's post I like to build and example applying constraints to a function template and then look how to test them. Having constraints is a nice thing, but are they worth if they go untested?
The difference between a requires-clause and a requires-expression
In the last post I only showed a requires-clause and the three valid places such a clause can be, as a requires-clause, a trailing requires-clause, and when creating a concept. But there is another requires-thing, the requires-expression. And guess what, there is more than one kind of requires-expression. But hey, you are reading a post about C++; you had it coming.
A requires-expression has a body which itself has one of multiple requirements. The expression can have an optional parameter list. Those a requires-expression looks like a function called requires
except for the return-type which is implicitly bool
.
Now, inside of a requires-expression we can have four distinct types of requirements:
- Simple requirement
- Nested requirement
- Compound requirement
- Type requirement
Simple requirement
This kind asserts the validity of an expression. For example, a + b
is an expression. It requires that there is an operator+
for these two types. If there is one, it fulfils this requirement, otherwise we get a compilation error.
Nested requirement
A nested requirement asserts that an expression evaluates to true
. A nested requirement always starts with requires
. So we have a requires
inside a requires-expression. And we don't stop there, but later more. With a nested requirement we can apply a type-trait to the parameters of the requires-expression. Beware that this requires a boolean value, so either use the _v
version of the type-trait or ::value
. Of course, this is not limited to type-traits. You can supply any expression which evaluates to true
or false
.
Compound requirement
With a compound requirement we can check the return type of an expression and optional if the expressions result is noexcept
. As the name indicates, a compound requirement has the expression in curly braces, followed by the optional noexcept
and something like a trailing return-type. Just that this trialing part needs to be a concept against which we check the result of the expression.
Type requirement
The last type of requirement we can have inside a requires-expression is the type requirement. It looks much like a simple requirement, just that it is introduced by typename
. It asserts that a certain type is valid. We can use it to check whether a given type has a certain subtype, or if a class template is instantiable with a given type.
An example: A constrained variadic function template add
Let's let code speak. Assume that we have a variadic function template add
.
1 2 3 4 5 |
|
It used fold expression to execute the plus operation to all values in the parameter pack args
and provides an initial value with arg
. We are looking at a binary left fold. This is a very short function template. However, the requirements to a type are hidden. What is typename
? Any type, right? But wait, it must at least provide operator+
. The parameter pack can take values of different types, but what if we like to constrain it to all types be of the same type? And do we really want to allow a throwing operator+
? Further as add
returns auto
, what if operator+
of a type returns a different type? Do we really want to allow that? Oh yes, and there is the question whether add
makes sense with just a single parameter which leads to an empty pack. Doesn't make much sense to me to add nothing. Let's bake all that in requirements. We like
- The type must provide
operator+
- Only same types passed to
arg
- At least two parameter are required, such that the pack is not empty.
operator+
should benoexcept
- and return an object of the same type
Before we start with the requires-expression we need some additional type-traits. The function template signature has only a parameter pack. For some of the tests we need one type out of that pack. Therefore, a type-trait first_type_t
helps us to split the first type from the pack. For the check whether all types are of the same type, we define a variable template are_same_v
using std::conjunction_v
to apply std::is_same
to all elements. Third, we need a concept same_as_first_type
to assert the return type with a compound requirement. It used first_type_t
to compare the return type of the compound requirement to the first type of the parameter pack. Here is a sample implementation1.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
As you can see, we expect that the compiler inserts the missing template parameter for same_as_first_type
as the first parameter. In fact, the compiler always fills them from the left to the right in case of concepts.
Now that we have the tools let's create the requires-expression.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
The numbers of the callouts in the example match the requirements we listed earlier. That would be the first part. We now have a constraint function template using three out of four possible requirement kinds. You have probably accustomed to the new syntax, so does clang-format
, but I hope you can see that we not only have constrained add
we also added documentation to it. It is surprising how many requirements we had to a type for just a one-line function-template. Now think about your real-world code and how hard it is there sometimes to understand why a certain type causes a template instantiation to error.
Testing the constraints
Great, now that we have this super constrained and documented add
function, why would you believe me that all the requirements are correct? No worries, I expect you to not trust me so far, I wouldn't trust myself.
What strategy can we use to verify the constraints? Sure, we can create small code snippets which violate one of the assertions and ensure that the compilation fails. But come on, that is not great and cumbersome to repeat. We can do better!
Whatever the solution is, so far we can say that we need a mock object which can have a conditional noexcept
operator+
and that operator can be conditionally disabled. I like my code to be unique, without copy and past parts, hence a class template sound good. In the last part we have seen how we can conditionally disable a method using a NTTP and requires
. Passing the noexcept
status as another NTTP is simple. A mock class can look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
1 we create a class template called ObjectMock
taking two NTTP of type bool
. It has an operator+
2 which has the conditional noexcept
controlled by NOEXCEPT
the first template parameter and a matching return-type. The same operator is controlled by a trailing requires-clause which disables it based on hasOperatorPlus
, the second template parameter. The second version 3 is the same, except that is returns a different type and with that does not match the expectation of the requires-expression of add
. A third NTTP, validReturnType
, controls two different operators 2 and 3, it enables only one of them.
In 4 we define three different mocks with the different properties. With that we have our mock.
A concept to test constraints
The interesting question is now, how do we test the add
function? We clearly need to call it with the different mocks and validate that is fails or succeeds but without causing a compilation error. The answer is, we use a combination of a concept wrapped in a static_assert
. Let's call that concept TestAdd
. We need to pass at least either one or two types to it, based on our requirement, that add
should not work with just one parameter. That calls for a variadic template parameter of the concept. Inside the requires-expression of TestAdd
we make the call to add
. There is one minor thing, we need values in order to call add
. Do you remember, a requires-expression can have a parameter list. We can use the parameter pack and supply it as a parameter list. After that we can expand the pack when calling add
:
1 2 3 4 5 6 |
|
Wrap the test concept in a static_assert
Nice, we have a concept which evaluates to true
or false
and calls add
with a given set of types. The last thing we have to do is to use TestAdd
together with our mocks inside a static_assert
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
In 1 we test with int
that add
works with built-in types but refuses NoAdd
, the mock without operator+
. Next, the rejection of mixed types is tested by 2. 1 already ensured as a side-effect that same types are permitted. Disallowing a parameter pack with less than 2 values is asserted by 3 and by that add
must be called with at least two parameters. 4 verifies that operator+
must be noexcept
. Second last, 5 ensures that operator+
returns an object of the same type, while 6 ensures that a valid class works. We are testing this already implicitly with other tests and is there for completeness only. That's it! We just tested the constraints of add
during compile-time with no other library or framework! I like that.
Summary
I hope you learned something about concepts and how to use them, but most and for all how to test them.
Concepts are a powerful new feature. While their main purpose is to add constraints to a function, they to also improve documentation and help us make constraints visible to users. With the technique I showed in this post you can ensure that your constraints are working as expected with just C++ utilities, of course at compile-time.
If you have other techniques or feedback, please reach out to me on X or via email. In case, you like a more detailed introduction into Concepts tell me about it.
Andreas
Please note, C++20 ships with a concept
same_as
. This one here is a version which ignores cvref qualifiers and is a variadic version to retrieve the first type of a parameter pack. ↩