C++20 Concepts: Testing Constrained Functions

C++20 Concepts: Testing Constrained Functions

By Andreas Fertig

Overload, 31(174):7-9, April 2023


Concepts and the requires clause allow us to put constraints on functions or classes and other template constructs. Andreas Fertig gives a worked example including how to test the constraints.

The difference between a requires-clause and a requires-expression

In July 2020 [Fertig20], I 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 an article about C++; you had it coming.

A requires-expression has a body, which itself has one or more requirements. The expression can have an optional parameter list. A requires-expression therefore looks like a function called requires, except for the return-type which is implicitly bool. See Figure 1.

Figure 1

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 of requirement 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. 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 (optionally) whether 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. This trailing part needs to be a concept against which we can 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 whether 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.

  template<typename... Args>
  auto add(Args&&... args)
  {
    return (... + args);
  }

It uses a fold expression to execute the plus operation on all values in the parameter pack Args. 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 want to constrain it to all types be of the same type? And do we really want to allow a throwing operator+? Furthermore, as add returns auto, what if operator+ of a type returns a different type? Do we really want to allow that? Oh yes, and then there is the question of 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 have:

  1. The type must provide operator+
  2. Only the same types are passed to args
  3. At least two parameters are required, so that the pack is not empty
  4. operator+ should be noexcept
  5. operator+ should return an object of the same type.

Before we start with the requires-expression, we need some additional type-traits. The function template signature only has 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. Thirdly, we need a concept same_as_first_type to assert the return type with a compound requirement. It can use first_type_t to compare the return type of the compound requirement to the first type of the parameter pack. Listing 1 is a sample implementation1.

// First type struct which retrieves and stores
// the first type of a pack 
template<typename T, typename...>
struct first_type
{
  using type = T;
};
// Using alias for clean TMP 
template<typename... Args>
using first_type_t = 
  typename first_type<Args...>::type;
// 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>...>;
// 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...>>>;
Listing 1

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 (see Listing 2).

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

The numbers of the callouts in the example match the requirements we listed earlier, which is the first step. We now have a constraint function template using three out of four possible requirements. You are probably accustomed to the new syntax, as is 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 write 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, would you believe me if I said that all the requirements are correct? No worries, I expect you not to 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 is cumbersome to repeat. We can do better!

Whatever the solution is, so far we can say that we need a mock object that can have a conditional noexcept operator+ and that that operator can be conditionally disabled. Rather than copy and paste parts, we can use a class template. 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 Listing 3.

// Class template mock to create the different
// needed properties 
template<bool NOEXCEPT, bool hasOperatorPlus,
  bool validReturnType>
class ObjectMock
{
  public:
  ObjectMock() = default;
  // Operator plus with controlled noexcept can
  // be enabled 
  ObjectMock& operator+(const ObjectMock& rhs)
    noexcept(NOEXCEPT) 
    requires(hasOperatorPlus&& validReturnType)
  {
    return *this;
  }
  // Operator plus with invalid return type 
  int operator+(const ObjectMock& rhs)
    noexcept(NOEXCEPT) 
    requires(hasOperatorPlus && 
    not validReturnType)
  {
    return 3;
  }
};
// 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>;
Listing 3

① we create a class template called ObjectMock, taking two NTTP of type bool. It has an operator+ ②, 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 ③ 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 ②and ③; it enables only one of them. In ④, 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 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. If 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 (see Listing 4).

template<typename... Args>
concept TestAdd =
  requires(Args... args)  // Define a variadic
                          // concept as helper  
{
  add(args...);           // Call add by
                          // expanding the pack 
};
Listing 4

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 (Listing 5).

// Assert that type has operator+ 
static_assert(TestAdd<int, int, int>);
static_assert(not TestAdd<NoAdd, NoAdd>);
// Assert, that no mixed types are allowed 
static_assert(not TestAdd<int, double>);
// Assert that pack has at least one parameter 
static_assert(not TestAdd<int>);
// Assert that operator+ is noexcept 
static_assert(not TestAdd<NotNoexcept,
  NotNoexcept>);
// Assert that operator+ returns the same type 
static_assert(not TestAdd<DifferentReturnType,
  DifferentReturnType>);
// Assert that a valid class works 
static_assert(TestAdd<ValidClass, ValidClass>);
Listing 5

In ①, 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 ②. ① already ensured as a side-effect that the same types are permitted. Disallowing a parameter pack with less than two values is asserted by ③ and therefore add must be called with at least two parameters. ④ verifies that operator+ must be noexcept. Second last, ⑤ ensures that operator+ returns an object of the same type, while ⑥ ensures that a valid class works. We are already implicitly testing this with other tests and this 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 have learned something about concepts and how to use them, but most of all, how to test them.

Concepts are a powerful new feature. While their main purpose is to add constraints to a function, they also improve documentation and help us make constraints visible to users. With the technique I have shown in this article, you can ensure that your constraints are working as expected using 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. If you would like a more detailed introduction into Concepts, let me know.

Reference

[Fertig20] Andreas Fertig ‘How C++20 Concepts can simplify your code’, published 7 July 2020 at https://andreasfertig.blog/2020/07/how-cpp20-concepts-can-simplify-your-code/

Footnote

  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.

Andreas Fertig is a trainer and lecturer on C++11 to C++20, who presents at international conferences. Involved in the C++ standardization committee, he has published articles (for example, in iX) and several textbooks, most recently Programming with C++20. His tool – C++ Insights (https://cppinsights.io) – enables people to look behind the scenes of C++, and better understand constructs.

This article was published on Andreas Fertig’s blog in August 2020
(https://andreasfertig.blog/2020/08/cpp20-concepts-testing-constrained-functions/) as a short version of Chapter 1 ‘Concepts: Predicates for strongly typed generic code’ from his latest book Programming with C++20. The book contains a more detailed explanation and more information about this topic.






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.