Concepts vs. Type Traits

3 hours ago 3

What is the difference between a concept and a type trait? Note that you can create a type trait using a requires-expression:

template <typename T> constexpr bool has_fun = requires(T v) { v.fun(); };

You can also constrain a template with a type trait using a requires-clause:

template <typename T> requires std::is_standard_layout_v<T> void fun(T v);

There are differences though. Some are in the language, some in the tools, and one is human communication.

Concepts for declarations

First, you can put concepts in locations where you cannot put type traits. Given the following concept,

template <typename T> concept Small = sizeof(T) <= sizeof(int);

we can constrain a function template in the following way,

template <Small T> void fun(T v);

or in the following way.

void fun(Small auto v);

Or, if we want to disturb people, we can use the variant with empty angle brackets:

template <Small<> T> void fun(T v); void fun(Small<> auto v);

This form of constraining types with concepts can be called a type-constraint. (While the feature is popular, this name is not. I am following the convention from the C++ Standard: [temp.param].)

It works like this. You use it when you need to name the constraint for a type without naming the type. Compare these two:

template <typename T> requires Small<T> // (1) void fun(T v); template <Small T> // (2) void fun(T v);

In case (1), we form a predicate that returns a true/false answer. In order to form the predicate, we had to use the name T of the type being constrained. In case (2) there is no predicate in sight: that is, nothing that would return a true/false answer that we could stick to, say, if constexpr. We formulated the type-constraint — Small — and we can stick it wherever a constraint-without-naming-the-type fits.

We create a type-constraint by taking a concept, and providing in angle brackets all the concept parameters, except for the first. In the case of our concept Small “except for the first” resulted in passing zero arguments, so the angle brackets can be omitted. This becomes more interesting when we have concepts with more than one parameter:

template <typename T, typename U> concept EquallySized = sizeof(T) == sizeof(U); template <Small X, EquallySized<X> Y> void bar(X x, Y y);

This is a shorthand for:

template <typename X, typename Y> requires Small<X> && EquallySized<Y, X> void bar(X x, Y y);

The type-constraint notation may look disturbing for concepts with two symmetrical parameters, but becomes quite intuitive for constraining function-like things, where you have to name the type of the function and of all its parameters. Consider concept std::invocable and its use as a type-constraint:

template <typename X, typename Y, std::invocable<X, Y> F> void apply(X x, Y y, F fun); // will call f(x, y)

The type-constaint notation can be used in two other places. One is when declaring variables without specifying the type (but specifying the constraint):

void baz() { Small auto x = make_x(); EquallySized<int> decltype(auto) y = make_y(); }

This is syntactic sugar for:

void baz() { auto x = make_x(); static_assert(Small<decltype(x)>); decltype(auto) y = make_y(); static_assert(EquallySized<decltype(y), int>); }

The other context is when we declare constraints inside a requires-clause.

template <typename T> constexpr bool has_small_value = requires(T x) { { x.value() } -> Small; };

This requires that objects of type T have member function value() and that its return type, call it R, satisfies the constraint Small<R>. This is equivalent to:

template <typename T> constexpr bool has_small_value = requires(T x) { requires Small<decltype(x.value())>; };

The Standard Library concepts std::same_as and std::convertible_to are often seen used in this position:

template <typename T> concept Addable = requires(T x, T y) { { x + y } -> std::convertible_to<T>; { x += y } -> std::same_as<T&>; };

As said above, this type-constraint functionality is just syntactic sugar; we would be able to express the same without concepts, with a longer notation. But there is one more use of contracts that cannot be substituted by anything else: the ordering of function overloads and class specializations by constraints. Consider these two function template overloads:

template <typename T, typename U> requires Small<T> void joy(T t, U u); // (1) template <typename T, typename U> requires Small<T> && EquallySized<T, U> void joy(T t, U u); // (2)

The second one is more constrained and it looks pretty self-explanatory. We get an intuitive behavior during overload resolution:

joy(1, 1LL); // selects (1) joy(1, 1); // selects (2)

Contrast this with the situation where we use type traits instead of concepts:

template <typename T> constexpr bool IsSmall = sizeof(T) <= sizeof(int); template <typename T, typename U> constexpr bool IsEquallySized = sizeof(T) == sizeof(U); template <typename T, typename U> requires IsSmall<T> void mud(T t, U u); // (3) template <typename T, typename U> requires IsSmall<T> && IsEquallySized<T, U> void mud(T t, U u); // (4) int main() { mud(1, 1LL); // selects (3) mud(1, 1); // ERROR: ambiguous }

The second call is now ambiguous because both overloads match: both the predicates in (3) and in (4) are satisfied. So why did the case with concepts work? This is because the compiler recognizes Small and EquallySized as concepts and employs a different mechanism for selecting the right overload. In the case of type traits,

IsSmall<T> && IsEquallySized<T, U>

is treated as an expression, and the only thing the compiler gets from this expression is whether its value is true or false. In contrast, in the case of concepts,

Small<T> && EquallySized<T, U>

is not an expression: it is a conjunction of two constraints. It is not operator && anymore, even though the same symbol is used. In this case the two operands of the conjunction are compared against constraints and sub-constraints of other overloads. This process is described in more detail in this post. But the point is, it is a different process when concepts are involved.

This process (of selecting the best overload based on template constraints) is however very complicated and very fragile. I would advise anyone against using this feature. Unless you are sure you have mastered every tiny detail of this complex machinery, it is very likely that you will get it wrong in all but the simplest cases. Consider this one example using the abbreviated template syntax:

void pain(auto t, auto u) // (5) requires Small<decltype(t)>; void pain(auto t, auto u) // (6) requires Small<decltype(t)> && EquallySized<decltype(t), decltype(u)> int main() { pain(1, 1LL); // selects (5) pain(1, 1); // (*) ??? }

Now we have an even more painful situation in the call marked with (*): Clang selects overload (6) while GCC reports an ambiguity in overload resolution (it gives a compile-time error). In this case it looks like GCC follows the letter of the C++ Standard while Clang follows the intuitive user expectations.

Concepts for tools

Compilers generate more accurate error messages when you use concepts rather than type traits for expressing constraints. This is because type traits are a hack. They are the result of clever people taking tools that were designed for something else (defining parametrized constants) and cleverly using them for expressing constraints. It works to some extent, but:

  1. You are not co-operating with the compiler: you are just forcing the compiler to do something.
  2. The compiler doesn’t know your intentions — that you are expressing constraints — so it cannot help you.

In contrast, concepts are by definition a tool for expressing constraints. Every compiler knows this, and whenever it sees a concept, it knows you are expressing a constraint or composing a constraint form smaller constraints. Because of this, it can keep track of the atomic constraints, when you are using the compound constraint. Compare again a type trait built from smaller type traits and a concept built from smaller concepts:

// type trait template <typename T, typename U> constexpr bool IsRightSized = IsSmall<T> && IsSmall<U> && IsEquallySized<T, U>; // concept template <typename T, typename U> concept RightSized = Small<T> && Small<U> && EquallySized<T, U>;

Now, if I put:

// type trait static_assert(IsRightSized<int, char>);

I will get an error message that expression IsRightSized<int, char> evaluated to false. Not that bad, but if I put:

// concept static_assert(RightSized<int, char>);

I will get a slightly longer error message saying that expression sizeof(T) == sizeof(U) evaluates to false when T is int and U is char, and concept EquallySized is mentioned.

By using concepts, you are revealing your intentions to the compiler and other tools. This alone may be sufficient motivation to use concepts for expressing constraints. Not to mention the compile times: concepts — unlike class or variable templates — are never instantiated: you do not pay the cost of instantiating templates.

Concepts for humans

The third motivation is social. By using concepts you are selling your intention better to other programmers, or to users of your library. This distinction is not formal or precise, but it is very important.

A type trait checks the presence or the properties of certain constructs in the types it constraints, like whether a declaration of a certain member function is present. And that’s it: we are not interested if this function does the right thing when called.

When we declare a concept, apart from syntactic constraints, we also imply the existence of semantic requirements. They cannot be expressed directly in C++ concepts, but semantic requirements are integral part of concepts in the sense of Generic Programming philosophy. This is a good reason to declare a concept: to signal that there are also semantic requirements. Note that in the Standard library we have two concepts std::invocable and std::regular_invocable which express exactly the same syntactic requirements and differ only by semantic requirements.

What is a semantic requirement? Consider this example:

template <typename T> concept Addable = requires(T x, T y) { { x + y } -> std::convertible_to<T>; { x += y } -> std::same_as<T&>; // `x + y` is equality-preserving // x + y == (T{x} += y); // x + y == y + x // x + (y + z) == (x + y) + z };

All the comments in this concept describe the semantic requirements. This post shows how we may try to signal formally semantic requirements.

Whoever invokes a generic algorithm constrained by a concept, should make sure that their type also satisfies the semantic requirements. This is because the algorithm will rely on these semantics. Therefore, when programmers see a concept declared in the library, they should feel compelled to learn this concept. It will probably come accompanied by documentation, describing the semantic requirements. Concepts, unlike type traits, will be few, rather big, and well documented.

Read Entire Article