Logo

Blog


C++20 Concepts: Testing constrained functions

This post is a short version of Chapter 1 Concepts: Predicates for strongly typed generic code from my latest book Programming with C++20. The book contains a more detailed explanation and more information about this topic.

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.

C++20 requires-expression.

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
template<typename... Args>
auto add(Args&&... args)
{
  return (... + args);
}

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

  1. The type must provide operator+
  2. Only same types passed to arg
  3. At least two parameter are required, such that the pack is not empty.
  4. operator+ should be noexcept
  5. 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
1 First type struct which retrieves and stores the first type of a pack
template<typename T, typename...>
struct first_type
{
  using type = T;
};

2 Using alias for clean TMP
template<typename... Args>
using first_type_t = typename first_type<Args...>::type;

3 Check whether all types are the same
template<typename T, typename... Ts>
inline constexpr bool are_same_v = std::conjunction_v<std::is_same<T, Ts>...>;

4 Concept to compare a type against the first type of a parameter pack
template<typename T, typename... Args>
concept same_as_first_type =
    std::is_same_v<std::remove_cvref_t<T>,
                   std::remove_cvref_t<first_type_t<Args...>>>;

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
template<typename... Args>
requires requires(Args... args)
{
  (... + args);                  1 Simple requirement
  requires are_same_v<Args...>;  2 Nested requirement with type-trait
  requires sizeof...(Args) > 1;  3 Nested requirement with a boolean expression asserts at least 2 parameters
  {
    (... + args)
  }
  noexcept  4 Compound requirement ensuring noexcept
      ->same_as_first_type<Args...>;  5 Same compound requirement ensuring same type
}
auto add(Args&&... args)
{
  return (... + args);
}

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 Class template mock to create the different needed properties
template<bool NOEXCEPT, bool hasOperatorPlus, bool validReturnType>
class ObjectMock
{
  public:
  ObjectMock() = default;

  2 Operator plus with controlled noexcept can be enabled
  ObjectMock& operator+(const ObjectMock& rhs) noexcept(NOEXCEPT) requires(
      hasOperatorPlus&& validReturnType)
  {
    return *this;
  }

  3 Operator plus with invalid return type
  int operator+(const ObjectMock& rhs) noexcept(NOEXCEPT) requires(
      hasOperatorPlus && not validReturnType)
  {
    return 3;
  }
};

4 Create the different mocks from the class template
using NoAdd               = ObjectMock<true, false, true>;
using ValidClass          = ObjectMock<true, true, true>;
using NotNoexcept         = ObjectMock<false, true, true>;
using DifferentReturnType = ObjectMock<false, true, false>;

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
template<typename... Args>
concept TestAdd =
    requires(Args... args)  1 Define a variadic concept as helper
{
  add(args...);  2 Call add by expanding the pack
};

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
1 Assert that type has operator+
static_assert(TestAdd<int, int, int>);
static_assert(not TestAdd<NoAdd, NoAdd>);

2 Assert, that no mixed types are allowed
static_assert(not TestAdd<int, double>);

3 Assert that pack has at least one parameter
static_assert(not TestAdd<int>);

4 Assert that operator+ is noexcept
static_assert(not TestAdd<NotNoexcept, NotNoexcept>);

5 Assert that operator+ returns the same type
static_assert(not TestAdd<DifferentReturnType, DifferentReturnType>);

6 Assert that a valid class works
static_assert(TestAdd<ValidClass, ValidClass>);

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 Twitter or via email. In case, you like a more detailed introduction into Concepts tell me about it.

Andreas


  1. 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.