Skip to content

C++ Concepts for better metaprogramming • The practice

In a previous article we have explored some of the fundamental problems encountered with Template Metaprogramming, which I briefly recap here

  1. Constraining template parameters
  2. Diagnostics and debugging
  3. Compile-time template function overload

All the benefits of C++ templates, such as optimized code generation and powerful generic abstractions based on implicit interfaces, come at the cost of a weak mechanism to enforce constraints on template parameters. In code, the requirements on template parameters are expressed in the template body. And this is a problem because expressing all the assumptions in the implementation basically hides them and makes their understanding difficult.

Since the requirements on the parameters are defined in the template body, error messages related to incorrect usage will only be generated when the templates are instantiated and not at the points of invocation. The problem here is that this “lazy error reporting” may happen at a very distant point from the call site along the instantiation path within other related template instantiations that will obscure the meaning of the messages.

Template overload is a very important aspect of C++ metaprogramming for static polymorphism and code optimization. The lack of direct language support for overloading based on template parameters has forced programmers to achieve the same effect using ad-hoc low-level techniques to hijack the compiler by manipulating the function template signatures (using type traits, SFINAE, etc.). The problem is that these techniques make the code more complicated, slower to compile and hard to understand.

C++ Concepts aim at solving all of these problems.

Introduction

Concepts represent requirements on template parameters that must be satisfied in order for the call to be considered correct and the template instantiated. Technically, a Concept is merely a constant expression C<T...> (more precisely a “predicate”) on type and non-type parameters evaluated at compile-time that produces a boolean value true if the expression compiles and false otherwise.

The general syntax to define a concept is as follows

template<[TEMPLATE_PARAMETER_LIST]>
concept C = requires ([PARAMETER_LIST])* {
   [REQUIREMENT]*
};

where [TEMPLATE_PARAMETER_LIST] is a list of type and/or non-type template parameters, C a valid C++ name to identify the Concept, [PARAMETER_LIST] an optional function-style list of parameters that are merely used by the compiler to evaluate the requirements (there is no calling convention, storage, etc.), and [REQUIREMENT] a list of requirements representing the assumptions enforced by the Concept.

Defining a Concept is technically very similar to defining a bool variable template initialized with a compile-time constant expression, on the lines of this. However, while there are Concepts emulation libraries that use similar ad-hoc techniques based on constant expressions or even (yuck!) macros, C++20 has introduced the specialized keywords concept and requires together with other constructs and rules that greatly simplify concepts-based metaprogramming.

Each REQUIREMENT in the Concept definition can be one of the following types

  1. Simple requirement to assert the validity of an arbitrary expression
  2. Type requirement to assert the validity of dependencies between types
  3. Compound requirement to assert the validity of a functional expression (i.e. including return type)
  4. Nested requirement to assert the validity of localized “inlined” constraints

Here we are not going into the details of each of them as these can be easily found in the C++20 Standard Specifications (§7.5.7) or C++ reference website (here). For now it suffices to know that they are the means to define the syntactic requirements of Concepts.

Once a Concept is defined, it can be used in a template declaration to constrain its parameters, like in the following code

template<typename T> requires C<T>
void f();

The requires keyword here is used to apply the concept C to the type parameter T of the function template f. There are a couple important things to note at this point.

Firstly, there is a distinct separation between the use of the template (i.e. when it is invoked) and its definition when it comes to express the assumptions on the parameters. Unlike with legacy metaprogramming, where all the requirements can be found buried within the template body, now it’s possible to clearly express them within Concepts as separate constructs.

Secondly, since Concepts are merely boolean predicates they can be easily composited using logical operators (AND, OR, etc.) to model more nuanced concepts (pardon the pun) of the application domain. This allows them to be used as building blocks to express more complex abstractions.

Requirements on template parameters

Let’s see a practical example in code. Consider the print function template used in the previous article

template<typename T>
void print(const T& val) {
    std::cout << val << std::endl;
}

A requirement for the parameter T is that it must be a “streamable” type, that is it must support stream insertion. Prior to C++20 this requirement could be specified by using ad-hoc low-level techniques such as type traits classes. With Concepts it can be done more concisely and cleanly like in the following code

// Define the Concept for a "streamable" type
template<typename T>
concept Streamable = requires (T val, std::ostream out) {
   out << val;
};

// Apply the requirements for a "streamable"
template<typename T> requires Streamable<T>
void print(const T& val) {
    //...
}

// or more nicely
template<Streamable T>
void print(const T& val) {
    //...
}

The declaration <Streamable T> is equivalent to the one using the requires clause and is often called the “short form”. The function can then be used as usual, like in the below code

print(123);                    // OK: 'int' is streamable
print("Ciao");                 // OK: 'char*' is streamable
print(std::vector{1, 2, 3});   // ERROR: std::vector is not streamable

What happens in the above code is this: when the function print is invoked the template arguments are deduced and substituted into the template parameters, in this case T = std::vector<int>. This substitution is also done for the Streamable<T> Concept in order to check its requirements.

The Concept declares a parameter list (T val, std::ostream out) that’s used to evaluate the requirements. Keep in mind that a Concept is a compile-time expression and no code is executed or generated, meaning that the parameters are only used “internally” to process the Concept’s requirements. These parameters can have any type, as long as they are visible from the Concept’s scope.

The only requirement for the Streamable concept (out << val) is evaluated by checking that the expression is valid and compiles with no errors. In this case we require that the value of type T must support insertion into an output stream. Since for int and char* an insertion operator is defined by default, the expression out << val is correct and the Concept evaluates to true. This is not true for std::vectors in which case the expression out << val wouldn’t compile and the Concept evaluates to false.

It is important to note that the check on the Concepts is done at the point of invocation, meaning that the compiler doesn’t need to instantiate and process the entire template definition. This clearly separates the constraints on the types from their usage in the implementation.

Diagnostics and debugging

One of the most awful aspects of working with templates is the terrible debugging experience when failures occur. The programmer will be faced with dozens, if not hundreds, of error messages that are imprecise at best and completely undecipherable in the worst scenarios. In the previous article we have seen that these issues arise because of lazy error detection in the instantiation chains.

The weak separation between use of the template (where requirements on types should be checked) and definition of the algorithm (where they are ultimately checked) forces the compiler to instantiate and parse the complete code in order to guarantee that all type parameters are used correctly. With C++ Concepts this separation is well defined as all the requirements on the template parameters can be expressed directly in the declaration as part of the signature thru a requires clause.

If we run the version using Concepts of the above incorrect use of print the compiler can enforce the requirements at the call site and immediately provide information related to which one is not being satisfied. For example, Clang would report the following

<source>:17:4: error: no matching function for call to 'print'
   print(std::vector{1, 2, 3});
   ^~~~~
<source>:10:6: note: candidate template ignored: constraints not satisfied [with T = std::vector<int>]
void print(const T& val) {
     ^
<source>:9:31: note: because 'std::vector<int>' does not satisfy 'Streamable'
template<typename T> requires Streamable<T>
                              ^
<source>:6:8: note: because 'out << val' would be invalid: invalid operands to binary expression ('std::ostream' (aka 'basic_ostream<char>') and 'std::vector<int>')
   out << val;

That’s the whole report telling us in a few lines exactly what it should be said, that is the instantiation of print failed because the requirement on the parameter T with std::vector<int> is not met as this type does not satisfy our concept of Streamable. This is clearly a big improvement over legacy C++ metaprogramming error reporting.

NOTE: To get these terse and precise error messages all the templates that are instantiated along the compilation path must use Concepts. If any one doesn’t then we’ll get the usual old verbose and obscure diagnostic reports.

Compile-time template function overload

The fundamental aspect of generic programming is the decoupling of algorithms from the data types they can operate upon. This gives programmers the ability to choose the most suitable algorithms based on information from different contexts at compile time, thus allowing for the generation of highly optimized code.

However, unlike with ordinary (non-templated) interfaces where choosing the correct function based on the parameter types is well regulated by the rules of overload resolution, for template parameters the situation is more complicated as a generic type does not have an explicit predefined interface. Consider the below example that was presented in the previous article

// Overload #1
template<typename T>
void print(const T& val) {
    std::cout << val;
}

// Overload #2
template<typename T>
void print(const T& val) {
    detail::streamer<T> s{};
    std::copy(std::begin(val), std::end(val), std::back_inserter(s));
}

We have two different printing function templates implementing different algorithms based on the nature of the parameter T. Specifically, the first overload works for streamable primitive and user-defined types, and the second for ranges of streamable types. Note that the functions have the same signature and would be considered as the same entity by the compiler. Even if we write them using different type naming like so

// Overload #1
template<typename Streamable>
void print(const Streamable& val) {
    std::cout << val;
}

// Overload #2
template<typename Range>
void print(const Range& val) {
    detail::streamer<Range> s{};
    std::copy(std::begin(val), std::end(val), std::back_inserter(s));
}

We need a way to overload them on the parameter T in order for the compiler to be able to distinguish between the two. We have seen how this can be accomplished using specific template metaprogramming techniques based on SFINAE rules and type traits. With Concepts the same can be achieved as in the following example (full code here)

template<typename T>
concept Streamable = requires (T val, std::ostream out) {
   out << val;
};

template<typename T>
concept Range = requires (T val) {
   std::begin(val);
   std::end(val);
};

template <typename T>
using Value_Of = T::value_type;

// Overload #1
template<Streamable T> requires (!Range<T>)
void print(const T& val) {
    std::cout << val;
}

// Overload #2
template<Range T> requires Streamable<Value_Of<T>>
void print(const T& val) {
    detail::streamer<T> s{};
    std::copy(std::begin(val), std::end(val), std::back_inserter(s));
}

The Streamable Concept models the idea of a type that can be serialized in textual form to an output stream. It has one simple requirement for the type to support stream insertion operations. The Range Concept models the idea of a collection of elements that can be traversed from a starting element to an ending one.Value_Of is not a Concept but an operator that must return a type (i.e. a metafunction), here implemented as an alias template.

Since Concepts are simply predicates, they can be easily composed or reused in other Concepts to create more constrained abstractions. For example, we may define a Streamable_Range Concept to model the idea of a range of streamable types, and a Streamable_Value to represent single streamable types, that is not part of a group (full code here)

template<typename T>
concept Streamable_Value = requires {
   requires Streamable<T>;
   requires !Range<T>;
};

template<typename T>
concept Streamable_Range = requires {
   requires Range<T>;
   typename Value_Of<T>;
   requires Streamable<Value_Of<T>>;
};

These Concepts are defined using nested and type requirements and can be used right in the template parameter declarations as usual

// Overload #1
template<Streamable_Value T>
void print(const T& val) {
    std::cout << val;
}

// Overload #2
template<Streamable_Range T>
void print(const T& val) {
    detail::streamer<T> s{};
    std::copy(std::begin(val), std::end(val), std::back_inserter(s));
}

We just overloaded the function template on the parameter T so that the compiler can evaluate the Concepts and decide which implementation to choose based on whether the argument for T satisfies the requirements. There is no more the need to manipulate the compiler’s behavior by exploiting complicated rules and to use low-level code that leaks into the template interfaces. Now we have simpler constructs for defining and applying requirements that are natively part of the language with much of the work left to the compiler so that programmers can focus on writing good Concepts.

Defining Concepts

Defining Concepts is all about creating abstractions, and so the identifyability problem arises. Determining how many and which requirements to use is key to creating good Concepts. Unfortunately there is no specific set of rules for how to design good abstractions in the general sense as this is largely dependent on the the programmer’s abilities. And also on the application domain as it may be easier in some fields more than others.

Concepts typically have at least a few requirements in order to precisely represent the abstractions they are intended to model. Single requirement Concepts may fit an overly high number of types and make their scope too broad. Such elementary Concepts are often not suitable to constrain template parameters and need either be refined or used together with other Concepts in order to express the assumptions effectively.

An example is given by the Streamable Concept defined earlier, which conveys the idea of something that can be turned into a sequence of text characters and sent to a sink. It is a very simple one with only a single requirement and the effects of this oversimplification can be seen in the example presented earlier for overloading template parameters. If we write the example as follows

// Overload #1
template<Streamable T>
void print(const T& val) {
    std::cout << val;
}

// Overload #2
template<Range T> requires Streamable<Value_Of<T>>
void print(const T& val) {
    detail::streamer<T> s{};
    std::copy(std::begin(val), std::end(val), std::back_inserter(s));
}

and try to use the function like so

print(std::string{"abc"});   // ERROR: ambiguous call to print

the code will not compile because the call is ambiguous. The problem is that std::string satisfies both the concept of a streamable and also that of a range of streamable values. The Concepts we defined are likely too elementary and fit a broader set of types that we intend to represent. They either need to be refined with additional requirements or composed with other Concepts to create more expressive abstractions.

To make the point that creating good abstractions is not a simple task let’s consider a more practical example. In the development of a video game there are different types of assets that can be acquired by the characters. We want to model the idea of “fireable” assets, such as weapons. A Concept that models this family of assets could be defined as in the following code (full example here)

template<typename T>
concept Fireable = requires (T val) {
   { val.fire() } -> std::same_as<bool>;
};

Our Fireable Concept has the only requirement for all weapon-like assets to provide a fire() method returning a boolean flag. The same_as<T> predicate is a C++20 Concept that requires the return type of the expression to be the same as the given one. It’s basically just a wrapper around the std::is_same<U,V> type traits with the first type U implicitly deduced by the compiler from the return type of the requirement expression.

A generic weapon that works with different types of ammunition satisfying the requirements of Fireable could be defined by the following class

template<typename T>
class weapon {
 public:
   using ammo_type = T;
   
   bool aim(target t) {
      //...
      return true; // Target locked
   }
   
   bool fire() {
      //...
      return true; // Ammo fired
   }
   
   void reload() {
      //...
   }
   
  private:
    std::vector<T> m_ammos;
};

Consider now the following generic function that fires all fireable items in the provided list

template <Fireable T>
void fire_all(std::list<T>& items) {
   for(auto& item : items) {
      if(item.fire()) {
        // Pay bonus if target hit...
      } else {
        // No more ammos...
      }
   }
}

If we call it like in the following code

std::list<weapon<bullet>> guns(3);
std::list<weapon<udarts>> bows(3);

fire_all(guns);
fire_all(bows);

it works as expected since weapons are assets that satisfy the requirements of a Fireable. However, in the application we also have events that are represented by the following class

class event {
 public:
   bool fire() {
     //...
     return true; // All listeners notified
   }
   void queue() {
     //...
   }
};

Calling fire_all() like in the following code – perhaps by mistake – still works because event types also satisfy the requirements of a Fireable, but the outcome is probably not as expected

std::list<event> events(2);
fire_all(events);   // Events are fired as weapons!

Similar issues would also appear if there are function templates overloaded on parameters that require different Concepts but with subsumed requirements (i.e. the requirements of one are a subset of the other)

template<typename T>
concept Fireable = requires (T val) {
   { val.fire() } -> std::same_as<bool>;
};

template<typename T>
concept Notifiable = requires (T val) {
   { val.fire() } -> std::same_as<bool>;
   { val.queue() };
};

// Overload #1
template <Fireable T>
void fire_all(std::list<T>& weapons) {
   //...
}

// Overload #2
template <Notifiable T>
void fire_all(std::list<T>& events) {
   //...
}

std::list<weapon<bullet>> guns(3);
std::list<event> events(2);

fire_all(guns);     // Call #1
fire_all(events);   // ERROR: Ambiguous call

Here all Fireable ‘s requirements are subsumed by Notifiable, so types that satisfy Notifiable also satisfy Fireable at the same time, therefore creating an ambiguity. In this case event types do satisfy the requirements of both Concepts, so overload resolution fails.

The event and weapon types are both represented equally well by the Fireable Concept despite the semantics being quite different. The problem here is that the Concept does not adequately model the abstraction that it’s supposed to model.

A better one would be as defined in the following code

template<typename T>
concept Fireable = requires (T val, target t) {
   typename T::ammo_type;
   { val.fire() } -> std::same_as<bool>;
   { val.aim(t) } -> std::same_as<bool>;
};

This improved (refined) version adds the requirements for all weapon-like assets to provide aiming functionality and the name of the type of ammunition used. If we now call the non-overloaded fire_all() function like in the earlier example

std::list<weapon<bullet>> guns(3);
std::list<event> events(2);

fire_all(guns);
fire_all(events);    // ERROR: events are not fireable weapons

event types are now rejected because they don’t satisfy the requirements of a Fireable, so the call fails. Similarly, in the overloaded fire_all() both weapon and event types only satisfy one Concept as the set of requirements are disjoint enough for the compiler to determine which is the right function.

As said earlier, defining Concepts is all about creating good abstractions and there is no one-size-fits-all solution for this task. It mainly depends on the developer’s experience and ability for abstract thinking. However, some general guidelines that may help in defining good Concepts could be the following

  • Beware of using too little information as this will make the Concepts “underfit” the class of types they are intended to represent (i.e. too generic) thus increasing the chances for unexpected behavior, such as using algorithms on the wrong types.
  • As a corollary to the previous point, whenever possible avoid single requirement Concepts as they may behave as a sort of catch-all mechanism when working with similar types thus creating ambiguities.
  • If a Concept is too elementary and there is not much opportunity for refinement, consider composing it with other Concepts to create more expressive abstractions.
  • Don’t make Concepts too constrained as the more requirements they have the less generic the algorithms and data structures using them will be.
  • Make sure the sets of requirements for different Concepts participating in overload resolutions are sufficiently disjoint to avoid ambiguities.

Again these are broad guidelines that I have found useful based on experience. Writing good abstractions is mostly up to the skills of the programmer and the specific use cases.

Conclusions

Concepts are one of the Big Four of C++20 and while it is indeed “big” for a reason, knowing the fundamentals and being familiar with the core principles will surely improve the way we do metaprorgamming with C++ templates. The benefits can be appreciated in three main areas briefly summarized below.

First, they way requirements on template parameters are defined and applied is cleaner and more intuitive compared to the old practices used in “classic” C++. There is no more the need to dabble with low-level techniques to manipulate the interfaces and write arcane code that may be very unpleasant to read.

Second, the dreaded diagnostics reports generated when working with templates have been improved with more human-friendly error messages and less verbose text, which greatly enhances the debugging experience. However, be aware that when mixing Concept-based templates with non-Concept based ones we can still get the usual messy reports.

Third, selecting the most suitable/optimal algorithm or data structure thru overloads and specializations is now easier as most of the work will be taken care of by the compiler. In my opinion it is now more idiomatic and in line with standard C++ rules without much of the workaround feeling that traditional techniques such as SFINAE have.

For anyone who is consistently using template metaprogramming in their work this feature will undoubtedly bring some fresh air and allow to write better code.

Published inModern C++