Skip to main content

Constraints and Concepts

I never dived too deeply into SFINAE as one would find madness lurking not far away down there. With C++20 around the corner it fortunately becomes only necessary for code of former standards. Concepts bring interesting features to C++ and I deliver a basic introduction to their usage.

Introduction

The type trait std::is_copy_constructible returns true for a type supporting construction from an instance via the copy constructor. However, it only takes the type itself into account, notwithstanding contained elements. Consider the following example for illustration.

#include <memory>
#include <vector>

using move_only = std::unique_ptr<uint32_t>;

auto const constinit is_copy_constructible
  = std::is_copy_constructible_v<std::vector<move_only>>;

static_assert(is_copy_constructible);

void func(std::vector<auto> const& v)
{
    /* Error: */ auto copy = v;
}

Firstly, std::unique_ptr owns a unique pointer to data. We cannot copy such a pointer, as otherwise multiple pointers to the same data would exist and therefore they would lose their uniqueness. To avoid that, std::unique_ptr only supports movement. The pointer we moved to would contain the actual redirection, while the original one would contain nullptr. So, with move_only we declare a type that we can only move.

If we cannot copy move_only, we also cannot copy a range 1 that contains such. Especially we cannot copy std::vector<move_only> and we cannot copy-construct it. We might want to check this requirement via a static assertion. So, we define a constant is_copy_constructible that holds the result from std::is_copy_constructible, applied to std::vector<move_only>. Note that we ensure its evaluation at compile-time through the new keyword constinit. We then verify the result through static_assert().

It turns out that our check holds true for std::vector<move_only>, but we still cannot copy-construct it. The according attempt in func() will fail during compilation. While std::is_copy_constructible checks for the availability of the according copy constructor, it does not check the underlying elements. So, as we try to copy them, we hit an error. Raymond Chen outlines the reasons for that behavior.

The copyable class claims to be copyable, but how do we know that its copy constructor will compile successfully? There’s no way to know, because there is no definition visible. We have to go by what it says on the tin, and the tin says that it’s copyable.

I understand his point, but I do not understand why it would apply to contained elements, while not to std::vector itself. Sure, std::vector has known semantics, but one expects valid results from std::is_copy_constructible for any type, even the custom ones, right? With the inclusion of constraints and concepts, standard library developers might have a reason to improve on the current state. So, let us take a look at the newly available features and then find a proof-of-concept solution.

Constraints

C++14 gave us auto for generic function parameters and C++17 gave us fold expressions. So, one might write a function like the following. It takes an arbitrary number of generic xvalues and provides them to std::cout via operator << using perfect forwarding.

auto func(auto&&... elements)
{
  (std::cout << ... << std::forward<decltype(elements)>(elements));
}

While func() does not state any explicit requirements for its parameters, it implicitly expects them to provide a fitting operator <<. If one of them does not, it leads to a verbose error message during compilation. One had to resort to verbose and hard to maintain SFINAE concepts to state such requirements. With C++20 and its introduction of constraints, it becomes straightforward.

Requires Clauses

Firstly, we gain the ability to express requirements on generic types using requires clauses, the initiation of which takes place via the new keyword requires. For instance, we can check for the minimum allowed size of the given type.

template <typename T>
  requires 4u < sizeof(T)
auto func(T) {}

Furthermore, we also can use any expression evaluable during compilation. This especially includes constexpr functions. Consider the following example for illustration.

template <typename T>
  requires (test<T>())
auto func(T) {}

Here, the compiler considers func() as valid candidate if test() returns true for T during compilation. If we wanted func() to take only uint32_t and uint64_t, we could define a default template for test() returning false. Then, we could use template specializations for allowed types that return true.

template <typename T> consteval auto test() { return false; }

template <> consteval auto test<uint32_t>() { return true; }
template <> consteval auto test<uint64_t>() { return true; }

Note the usage of the new keyword consteval. We use it to declare test() as an immediate function. Therefore, it needs to fulfill the same requirements as constexpr functions, but we additionally require its evaluation during compilation, whereas evaluation of constexpr functions might take place during execution.

Requires Expressions

With requires clauses we have seen examples of conditions regarding their type. Using requires expressions, we can also state requirements according to properties of an instance thereof. In the previous example we assumed that passed parameters overload a fitting operator << for output. We can express this in the following form.

template <typename T>
  requires requires (T t) { std::cout << t; }
auto func(T) {}

We initialize requires expressions via the same keyword as requires clauses, leading to the repetition of requires. The expression then takes parameters of our type and ensures the validity of provided code. For an instance of T, we make sure its ability to provide output to std::cout via operator <<. Note that no evaluation of the requirements takes place. Therefore, the compiler does not actually print its hypothetical instance during compilation.

We can even use nested expressions and verify further properties thereof.

template <typename T>
  requires requires (T t)
  {
    { std::cout << t };
    { t.value } -> std::convertible_to<uint32_t>;
    { t.~T() } noexcept;
  }
auto func(T) {}

Using such, we can describe requirements according to their return type using the arrow operator. For example, we expect from an instance of T the field value with its type convertible to uint32_t. Note that the compiler uses decltype(t.value) as first parameter to std::convertible_to. If we needed to match the type of the field exactly, we would have used the std::is_same constraint.

Finally, we can disallow exceptions from function calls via noexcept. In our example, we expect the destructor of our type to not throw.

Concepts

How can any language exist without any concept of functions? We would write redundant code over and over again. Concepts introduce the same idea for constraints. Instead of typing out all the requirements for every function we declare, we can attach a name to such and use it wherever needed. The previous example as concept would looks as following.

template <typename T>
concept Concept = requires (T t)
{
  { std::cout << t };
  { t.value } -> std::convertible_to<uint32_t>;
  { t.~T() } noexcept;
};

We use the newly added keyword concept and express all the requirements in the equivalent requires expression. We can use our Concept instead of typing out all the requirements explicitly. The following example shows the most basic usage of it.

template <typename T> requires Concept<T>
auto func(T) {}

Instead of the requires expression itself, we can use the concept's name. If we check for multiple constraints in func(), we can express such via the common logical operators && and ||.

template <typename T> requires Concept1<T> && Concept2<T>
auto func(T) {}

C++20 additionally allows us to abbreviate the case for one single concept as in the following example.

template <Concept T>
auto func(T) {}

Instead of typename, we can use a concept directly. Based on the syntax for an unconstrained function, one can even write the following.

auto func(Concept auto) {}

The usage of auto for the parameter makes apparent that we have a template funtcion with func() 2. Therefore, the compiler instantiates separate non-template functions for every distinct parameter thereof.

Overload Resolution

You might wonder why one would write out requirements for function templates if the compilation fails nevertheless. Firstly, considering preconditions improves the quality of code overall. Secondly, the error messages for failed constraints promise better error messages. Compilers do not support this yet, but work on the standard library promises better error messages for template instantiation at some point. You might want to have such in your library instead of verbose SFINAE failures.

Finally, concepts participate in overload resolution 3. This feature enables you to provide different implementations based on type's properties. Consider the following example.

auto func(auto) {}

template <typename T> requires Concept1<T>
auto func(auto) {}

template <typename T> requires Concept1<T> && Concept2<T>
auto func(auto) {}

Firstly, we have an unconstrained function template. Then, we have an overload which requires Concept1. Finally, we have an overload that requires both, Concept1 and Concept2. Which one does the compiler take? It chooses the most constrained, valid overload.

If T supports both constraints, the compiler chooses the last — most restrictive — overload. It takes the second overload if T only supports Concept1. It cannot take the more restrictive overload, because of Concept2 as requirement, while the other one does not restrict its parameter. The compiler takes the first overload only if T does not support any of the concepts.

This feature enables you to express conditions in your code without diving into SFINAE with its verbosity and decreased maintainability.

Recursive std::is_copy_constructible

In the introduction we have seen the limitations of std::is_copy_constructible. It takes only the given type into account, but not the contained elements of a range. We want to improve on that using concepts and define three overloads of our function test() to perform the recursive check.

#include <conceps>
#include <ranges>

template <typename T>               consteval bool test() { return false; }
template <std:copy_constructible T> consteval bool test() { return true;  }

template <std::ranges::range R>
  requires std::copy_constructible<R>
consteval bool test()
{
    return test<std::ranges::range_value_t<R>>();
}

The first, unconstrained overload of test() provides the default case returning false. However, if the template parameter satisfies std::copy_constructible, the second overload returns true.

The final overload takes a std::ranges::range as template parameter and verifies the constraint. Then test() retrieves the contained type of the range via std::ranges::range_value_t and provides it recursively to one of the overloads. The compiler takes the same overload for nested ranges again. This happens for std::vector<std::vector>, for example. As soon as it arrives at a type other than a range, it checks for its property through the first two overloads.

With our test(), the following fails as expected.

using move_only = std::unique_ptr<uint32_t>;

static_assert(                        test<move_only>  ());
static_assert(            test<std::vector<move_only>> ());
static_assert(test<std::vector<std::vector<move_only>>>());

We cannot copy-construct a std::vector if it directly or indirectly contains a type that we cannot copy. For obvious reasons it also returns false if we cannot copy the non-range type. Yet, we have successful checks if the range contains a copyable type.

/* Successful checks: */
static_assert(                        test<uint32_t>  ());
static_assert(            test<std::vector<uint32_t>> ());
static_assert(test<std::vector<std::vector<uint32_t>>>());

Due to test() as an immediate function, we can use it to express our requirements in func().

template <typename T>
    requires (test<T>())
void func(T const& v)
{
    auto copy = v;
}

We have now stated the requirements of our function, can expect a better error message and provide the way for another overload if unsatisfied constraints exist.

Runtime Performance of Concepts

We use immediate functions throughout our examples and the compilation process needs to evaluate constraints during compilation anyway to determine the called candidate. Therefore, we do not expect surprises here. To make sure nevertheless, consider the following check.

int main()
{
  return test<std::vector<std::vector<move_only>>>();
}

It gets compiled to the following machine code.

main:
        push    rbp
        mov     rbp, rsp
        mov     eax, 0
        pop     rbp
        ret

It turns out we return EXIT_SUCCESS in unconventional manner. The application returns the constant zero as result in eax. As expected, our constraints and immediate functions have not introduced any runtime penalties whatsoever.

Conclusion

With concepts great capabilities enter the C++ world.

Concepts provide more accessible syntax compared to SFINAE.

Before the introduction of concepts, developers have taken metaprogramming great lengths to state their requirements on template parameters. The need for concepts becomes especially apparent in the ranges library. Compared to the previous approaches, concepts feel natural and easily understandable.

Concepts lead to better error messages.

For failed SFINAE substitutions error messages come along as a wall of text, only a small portion of which actually provides useful information. It takes a little experience to find that part. With concepts such errors already provide better digestible errors. With their adoption in the standard library the situation will improve. You might want to provide the same luxury for the users of your library.

Concepts participate in overload resolution.

You want to print your types if they support operator << and otherwise only their address? How would you do that using SFINAE? Have fun if you cannot map your requirements to type traits. With concepts, you can simply put another overload beside your existing. You can finally write your compile-time labyrinth generators and JSON parsers more efficiently.

Have fun writing solid code.


1

C++20 introduces the concept of ranges. It considers anything one can pass to std::ranges::begin() and std::ranges::end() as a range.

template <typename T>
concept range = requires(T& t)
{
  /* Preserves equality for forward iterators. */
  ranges::begin(t);
  ranges::end  (t);
};

That holds especially true for all standard containers.

2

Note the change in the draft of the specification between gcc 9.2 and the next major version. Previously, a constrained function had not required auto for its parameter.

/* Valid for gcc 9.2. */
auto func(Concept) {}

/* Valid for gcc (trunk). */
auto func(Concept auto) {}

With the increased expressiveness comes along ambiguity. A conveniently named concept aside, one cannot distinguish the former variation from a non-template function immediately. As a consequence, you cannot easily determine whether you pass an rvalue or xvalue, for instance. Finally, the draft was changed to the latter. Now, auto makes clear that we have an actual function template here.

3

Contrary to C++, the C# compiler does not consider constraints on generic parameters for overload resolution. Therefore, one cannot define mutually exclusive overloads regarding constraints. C# nevertheless considers them ambiguous even if there would exist only one valid variant.