C++ For C# Developers: Part 29 – Template Constraints
C# where
constraints enable our generics to do a lot more. C++ also has constraints and they enable us to write more expressive and efficient code. Today we’ll see how to add some constraints to our templates to achieve these goals.
Table of Contents
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrap-up
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
Constraints
C# has 11 specific where
constraints we can put on type parameters to generics. These include constraints like where T : new()
indicating that T
has a public constructor that takes no parameters. In contrast, C++ provides us with tools to build our own constraints out of compile-time expressions.
So far in C++, all of our templates have had no constraints. Still, we’ve been able to use those parameters in a great many ways. The difference between the two languages is that the default in C# is to treat generic parameters as the least common denominator type: System.Object
/object
. The default in C++ is the other end of the spectrum: template parameters may be used in any way that compiles.
Both languages allow us to set constraints to move the requirements for parameters more toward the opposite end of the spectrum. This means that C# where
constraints make generics’ type parameters more specific and therefore allow us to use more specific functionality like calling a constructor with no parameters. We’re about to see how C++ template constraints make template parameters less specific to perform better overload resolution and give more programmer-friendly compiler error messages. All of this was added in C++20 and is becoming available in all the major compilers.
Requires Clauses
While C# uses the keyword where
to add a constraint, C++ uses requires
. Let’s jump right in and add one to a function template:
struct Vector2 { float X; float Y; }; // Variable template // "Default" value is false template <typename T> constexpr bool IsVector2 = false; // Specialization of the variable template // Change value to true for a specific type template <> constexpr bool IsVector2<Vector2> = true; // Function template template <typename TVector, typename TComponent> // Requires clause // Compile-time expression evaluates to a bool // Can use template parameters here requires IsVector2<TVector> // The function TComponent Dot(TVector a, TVector b) { return a.X*b.X + a.Y*b.Y; } // OK Vector2 vecA{2, 4}; Vector2 vecB{2, 4}; DebugLog(Dot<Vector2, float>(vecA, vecB)); // Compiler error: // // Candidate template ignored: constraints not satisfied // [with TVector = int, TComponent = int] // TComponent Dot(TVector a, TVector b) // ^ // test.cpp:60:10: note: because 'IsVector2<int>' evaluated to false // requires IsVector2<TVector> DebugLog(Dot<int, int>(2, 4));
Rather than a language-specified where
constraint like we’d use in C#, we instead specify any compile-time expression after the keyword requires
. In this case we’re using a variable template that we’ve defaulted to false
and then specialized to opt-in the specific Vector2
type to true
.
These compile-time expressions have access to the template parameters. Like other compile-time expressions, they’re allowed to be arbitrarily complex. For example, consider this variable template that doesn’t have a requires
clause:
// Variable template template <typename T, int N> // Recurse to next-lower value constexpr T SumUpToN = N + SumUpToN<T, N-1>; // Specialization for 0 stop recursion template <typename T> constexpr T SumUpToN<T, 0> = 0; // OK DebugLog(SumUpToN<float, 3>); // 6 // Compile error: // // test.cpp:44:28: fatal error: recursive template instantiation exceeded // maximum depth of 1024 // constexpr T SumUpToN = N + SumUpToN<T, N-1>; // ^ // test.cpp:44:28: note: in instantiation of variable template specialization // 'SumUpToN<float, -1025>' requested here // test.cpp:44:28: note: in instantiation of variable template specialization // 'SumUpToN<float, -1024>' requested here // test.cpp:44:28: note: in instantiation of variable template specialization // 'SumUpToN<float, -1023>' requested here // test.cpp:44:28: note: in instantiation of variable template specialization // 'SumUpToN<float, -1022>' requested here // test.cpp:44:28: note: in instantiation of variable template specialization // 'SumUpToN<float, -1021>' requested here // // ... many more lines of errors DebugLog(SumUpToN<float, -1>);
To stop that infinite recursion, we can add a requires
clause:
template <typename T, int N> // Constraint to positive values only requires (N >= 0) constexpr T SumUpToN = N + SumUpToN<T, N-1>; template <typename T> constexpr T SumUpToN<T, 0> = 0; // OK DebugLog(SumUpToN<float, 3>); // 6 // Compiler error: // // test.cpp:54:14: error: constraints not satisfied for variable template // 'SumUpToN' [with T = float, N = -1] // DebugLog(SumUpToN<float, -1>); // ^~~~~~~~~~~~~~~~~~~ // test.cpp:42:11: note: because '-1 >= 0' (-1 >= 0) evaluated to false // requires (N >= 0) DebugLog(SumUpToN<float, -1>);
We’ve successfully stopped the infinite recursion before it started with a requires
constraint. The compiler didn’t need to instantiate thousands of templates and it didn’t print out thousands of error messages for us to decipher. Instead, we simply get one readable message telling us that the constraint wasn’t satisfied.
Concepts
So far requires
has been a pretty limited tool. That’s because this isn’t the primary way of using it. Instead, we usually use requires
to define a “concept.” A concept is supposed to be a semantic description of a category of types. For example, we might define a Number
concept as a type that supports various numeric operators:
template <typename T> concept Number = requires(T t) { t + t; t - t; t * t; t / t; -t; +t; --t; ++t; t--; t++; };
This is a new use of requires
in a couple of ways. First, we’ve added a parameter list like a function has. This gives us a named parameter t
with the type T
matching the template parameter. Second, we then use that parameter in a series of statements, similar to what a function is. If those statements compile, the constraint is satisfied.
Finally, we save the constraint created by requires
using the concept
keyword. This gives us a way to name the constraint elsewhere when we want to use it. All concepts are templates taking type parameters since their purpose is to categorize types.
Now let’s put the concept to use:
// Function template with two type parameters (the latter is defaulted) template <typename TVal, typename TThreshold=TVal> // Requires clause names concepts requires Number<TVal> && Number<TThreshold> // The function bool IsNearlyZero(TVal val, TThreshold threshold) { return (val < 0 ? -val : val) < threshold; } // All of these are OK since double, float, and int satisfy Number DebugLog(IsNearlyZero(0.0, 0.1)); // true DebugLog(IsNearlyZero(0.2f, 0.1f)); // false DebugLog(IsNearlyZero(2, 1)); // true struct Player{}; // Compiler error: Player doesn't satisfy the Number constraint DebugLog(IsNearlyZero(Player{}, Player{}));
Instead of directly evaluating to a bool
, this use of requires
names the Number
concept and passes arguments to it: Number<TVal>
and Number<TThreshold>
. The requires
clause can still perform binary logic using &&
and ||
operators to combine concepts or check bool
values.
There are a few alternate syntaxes we can use. First, we can put the requires
clause after the parameter list:
template <typename TVal, typename TThreshold=TVal> bool IsNearlyZero(TVal val, TThreshold threshold) requires Number<TVal> && Number<TThreshold> { return (val < 0 ? -val : val) < threshold; }
If we have a trivial requires
clause that simply names a single concept, which is quite typical, we can replace typename
with the name of the concept:
// Trivial concept version (still using typename) template <typename T> bool IsNearlyZero(T val, T threshold) requires Number<T> { return (val < 0 ? -val : val) < threshold; } // Replace typename with concept name template <Number T> bool IsNearlyZero(T val, T threshold) { return (val < 0 ? -val : val) < threshold; }
If we’re using auto
parameters to create “abbreviated function templates,” then we won’t have the template
. Instead, we can simply put the concept name before auto
to require that the parameter satisfy that template:
bool IsNearlyZero(Number auto val, Number auto threshold) { return (val < 0 ? -val : val) < threshold; }
Regardless of which syntax we choose, the usage is always the same as the above because all of these are equivalent. The preference of syntax is mostly a matter of the level of flexibility we need and of style.
Because this form of requires
defines a concept and a concept is what’s named after the other form of requires
, we sometimes use requires requires
to define an ad-hoc concept:
template <typename T> // Requires clause names ad-hoc concept requires requires(T t) { t + t; t - t; t * t; t / t; -t; +t; --t; ++t; t--; t++; } bool IsNearlyZero(T val, T threshold) { return (val < 0 ? -val : val) < threshold; }
Note that this Number
concept is quite incomplete and for example purposes only. The C++ Standard Library has many well-designed concepts such as std::integral
and std::floating_point
that are suitable for production code.
Combining Concepts
Many concepts are defined in terms of other concepts. Just like how we used &&
in our requires
clause, we can do the same when defining a concept
:
// Define a concept in terms of another concept and an ad-hoc concept template <typename T> concept Integer = Number<T> && requires(T t) { t << t; t <<= t; t >> t; t >>= t; t % t; };
We can also use concepts within the definition of our concepts. These are known as “nested concepts:”
template <typename T> concept Vector2 = requires(T t) { // Use a concept from within a concept // This requires the type of t.X to satisfy the Number constraint Number<decltype(t.X)>; // Also require Y to be a Number Number<decltype(t.Y)>; }; struct Vector2f { float X; float Y; }; bool IsOrthogonal(Vector2 auto a, Vector2 auto b) { return (a.X*b.X + a.Y*b.Y) == 0; } Vector2f a{0, 1}; Vector2f b{1, 0}; Vector2f c{1, 1}; DebugLog(IsOrthogonal(a, b)); // true DebugLog(IsOrthogonal(a, c)); // false
Alternately, we can use “compound requirements” to implicitly pass the type that an expression evaluates to as the first argument to a concept. Here’s how the Vector2
concept could have used this:
template <typename T> concept Vector2 = requires(T t) { // t.X must evaluate to a type that satisfies the Number constraint {t.X} -> Number; {t.Y} -> Number; };
Overload Resolution
We’ve seen how to use specialization to write custom versions of templates. This generally works well for particular types such as float
, but it’s difficult to specialize for whole categories of types. This is where concepts come in. When calling an overloaded function, the compiler will look at the concepts to find the one that’s most constrained:
// Incomplete definition of a dynamic array class struct List { int Length; int* Array; int* GetBegin() { return Array; } int& operator[](int i) { return Array[i]; } }; // Incomplete definition of a linked list class struct LinkedList { struct Node { int Value; Node* Next; }; Node* Head; Node* GetBegin() { return Head; } }; // A concept that defines types that can be iterated template <typename T> concept Iterable = requires(T t) { t.GetBegin(); }; // A concept that defines types that can be indexed into // This is more constrained than just Iterable template <typename T> concept Indexable = Iterable<T> && requires(T t) { t[0]; // Can read from an index t[0] = 0; // Can write to an index }; // Indexable overload simply indexes: O(1) int GetAtIndex(Indexable auto collection, int index) { return collection[index]; } // Iterable version has to walk the list: O(N) int GetAtIndex(Iterable auto collection, int index) { auto cur = collection.GetBegin(); for (int i = 0; i < index; ++i) { cur = cur->Next; } return cur->Value; } // Overload resolution calls the Indexable version List list; int a = GetAtIndex(list, 1000); // Overload resolution calls the Iterable version LinkedList linkedList; int b = GetAtIndex(linkedList, 1000);
C# Equivalency
Now that we know how constraints work in C++, let’s see how we’d approximate each of the 11 where
constraints that C# offers. To do this, we’ll use some pre-defined concepts out of the C++ Standard Library’s <concepts>
header and a variable template out of <type_traits>
rather than writing our own versions of these.
C# Constraint | C++ Concept (approximation) |
---|---|
where T : struct |
template <class T> concept C = std::is_class_v<T>; |
where T : class |
template <class T> concept C1 = !Nullable<T> && std::is_class_v<T> |
where T : class? |
template <class T> concept C = std::is_class_v<T>; |
where T : notnull |
template <class T> concept C = !Nullable<T>; |
where T : unmanaged |
N/A. All C++ types are unmanaged. |
where T : new() |
std::default_initializable<T> |
where T : BaseClass |
template <class T> concept C = !Nullable<T> && std::derived_from<T, BaseClass>; |
where T : BaseClass? |
std::derived_from<T, BaseClass> |
where T : Interface |
template <class T> concept C = !Nullable<T> && std::derived_from<T, BaseClass>; |
where T : Interface? |
std::derived_from<T, BaseClass> |
where T : U |
std::derived_from<T, U> |
In the table above, std::derived_from
and std::default_initializable
are concepts and std::is_class_v
is a bool
variable template. Nullable
isn't in the C++ Standard Library, but it might look like this:
template <class T> concept Nullable = // Can assign nullptr to it requires(T t) { t = nullptr; } && // Has a user-defined conversion operator to nullptr or is a pointer (requires(T t) { t.operator decltype(nullptr)(); } || std::is_pointer_v<T>);
This, and some of the other concepts, are approximations of their C# equivalents. C++ doesn't have exact matches for C# "nullable contexts" and other subtle language differences. Feel free to adjust these concepts to suit your intended usage.
Conclusion
C++ constraints provided by requires
and concept
fill a similar role to C# constraints provided by where
. As is often the case when comparing the two languages, the C++ version is essentially a superset of the C# functionality. While some concepts are provided by the C++ Standard Library via the <concepts>
header, we're also given the tools to write our own concepts as we did above with Nullable
and others.
The constraints we create allow us to limit what our templates are allowed to work on, express that intent to users of our templates, generate much more readable compiler errors, and even choose the most optimal overloaded function.
This is in contrast to C# constraints that enable our generics to use more functionality of their type parameters. Because only 11 basic constraints are provided with no ability for us to create our own constraints, we're often forced into trade-offs such as taking a performance hit due to calling functions on an interface, creating garbage due to boxing to a reference type, or jumping through hoops to write generic code.