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.
Contents
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()
andstd::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 requiredauto
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.