DEV Community

Sandor Dargo
Sandor Dargo

Posted on • Updated on • Originally published at sandordargo.com

4 ways to use C++ concepts in functions

Welcome back to the series on C++ concepts. In the previous article, we discussed what are the motivations behind concepts, why we need them. Today we are going to focus on how to use existing concepts. There are a couple of different ways.

The 4 ways to use concepts

To be more specific, we have four different ways at our disposal.

For all the ways I am going to share, let's assume that we have a concept called Number. We are going to use a very simplistic implementation for it. I include it so that if you want to try the different code snippets, you have a concept to play with, but keep in mind that it is incomplete in a functional sense. More about that in a next episode.

#include <concepts>

template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;
Enter fullscreen mode Exit fullscreen mode

Using the requires clause

In the first of the four presented ways, we use the requires clause between template parameter list and the function return type - which is auto in this case.

template <typename T>
requires Number<T>
auto add(T a, T b) {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

Note how we use the concept, how we define in the requires clause that any T template parameter must satisfy the requirements of the concept Number.

In order to determine the return type we simply use auto type deduction, but we could use T instead as well.

Unfortunately, we can only add up two numbers of the same type. We cannot add a float with an int

If we tried so, we'd get a bit long, but quite understandable error message:

main.cpp: In function 'int main()':
main.cpp:15:27: error: no matching function for call to 'add(int, float)'
   15 |   std::cout << add(5,42.1f) << '\n';
      |                           ^
main.cpp:10:6: note: candidate: 'template<class T>  requires  Number<T> auto add(T, T)'
   10 | auto add(T a, T b)  {
      |      ^~~
main.cpp:10:6: note:   template argument deduction/substitution failed:
main.cpp:15:27: note:   deduced conflicting types for parameter 'T' ('int' and 'float')
   15 |   std::cout << add(5,42.1f) << '\n';
      |                           ^

Enter fullscreen mode Exit fullscreen mode

If we wanted the capability of adding up numbers of multiple types, we'd need to introduce a second template parameter.

template <typename T,
          typename U>
requires Number<T> && Number<U>
auto add(T a, U b) {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

Then calls such as add(1, 2.14) will also work. Please note that the concept was modified. The drawback is that for each new function parameter you'd need to introduce a new template parameter and a requirement on it.

With the requires clause, we can also express more complex constraints. For the sake of example, let's just "inline" the definition of number:

template <typename T>
requires std::integral<T> || std::floating_point<T>
auto add(T a, T b) {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

Though for better readability, in most cases, I consider a better practice to name your concept, especially when you have a more complex expression.

Trailing requires clause

We can also use the so-called trailing requires clause that comes after the function parameter list (and the qualifiers - const, override, etc. - if any) and before the function implementation.

template <typename T>
auto add(T a, T b) requires Number<T> {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

We have the same result as we had with the requires clause we just wrote it with different semantics. It means that we still cannot add two numbers of different types. We'd need to modify the template definition similarly as we did before:

template <typename T, typename U>
auto add(T a, U b) requires Number<T> && Number<U> {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

Still, we have the drawback of scalability. Each new function parameter potentially of a different type needs its own template parameter.

Just as for the requires clause, you can express more complex constraints in the trailing requires clause.

template <typename T>
auto add(T a, T b) requires std::integral<T> || std::floating_point<T> {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

Constrained template parameter

The third way to use a concept is a bit terser than the previous ones, which also brings some limitations.

template <Number T>
auto add(T a, T b) {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we don't need any requires clause, we can simply define a requirement on our template parameters right where we declare them. We use a concept name instead of the keyword typename. We'll achieve the very same result as with the previous two methods.

If you don't believe it, I'd urge you to check it on Compiler Explorer.

At the same time, it's worth to note that this method has a limitation. When you use the requires clause in any of two presented ways, you can define an expression such as requires std::integral<T> || std::floating_point<T>. When you use the constrained template parameter way, you cannot have such expressions; template <std::integral || std::floating_point T> is not valid.

So with this way, you can only use single concepts, but in a more concise form as with the previous ones.

Abbreviated function templates

Oh, you looked for brevity? Here you go!

auto add(Number auto a, Number auto b) {
  return a+b;
}
Enter fullscreen mode Exit fullscreen mode

There is no need for any template parameter list or requires clause when you opt for abbreviated function templates. You can directly use the concept where the function arguments are enumerated.

There is one thing to notice and more to mention.

After the concept Number we put auto. As such we can see that Number is a constraint on the type, not a type itself. Imagine if you'd simply see auto add(Number a, Number b). How would you know as a user that Number is not a type but a concept?

The other thing I wanted to mention is that when you follow the abbreviated function template way, you can mix the types of the parameters. You can add an int to a float.

#include <concepts>
#include <iostream>

template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;

auto add(Number auto a, Number auto b) {
  return a+b;
}

int main() {
  std::cout << add(1, 2.5) << '\n';
}
/*
3.5
*/
Enter fullscreen mode Exit fullscreen mode

So with abbreviated function templates we can take different types without specifying multiple template parameters. It makes sense as we don't have any template parameters in fact.

The disadvantage of this way of using concepts is that just like with constrained template parameters, we cannot use complex expressions to articulate our constraints.

How to choose among the 4 ways?

We have just seen 4 ways to use concepts, let's have a look at them together.

#include <concepts>
#include <iostream>

template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;

template <typename T>
requires Number<T>
auto addRequiresClause(T a, T b) {
  return a+b;
}

template <typename T>
auto addTrailingRequiresClause(T a, T b) requires Number<T> {
  return a+b;
}

template <Number T>
auto addConstrainedTemplate(T a, T b) {
  return a+b;
}

auto addAbbreviatedFunctionTemplate(Number auto a, Number auto b) {
  return a+b;
}

int main() {
    std::cout << "addRequiresClause(1, 2): " << addRequiresClause(1, 2) << '\n';
    // std::cout << "addRequiresClause(1, 2.5): " << addRequiresClause(1, 2.5) << '\n'; // error: no matching function for call to 'addRequiresClause(int, double)'
    std::cout << "addTrailingRequiresClause(1, 2): " << addTrailingRequiresClause(1, 2) << '\n';
    // std::cout << "addTrailinRequiresClause(1, 2): " << addTrailinRequiresClause(1, 2.5) << '\n'; // error: no matching function for call to 'addTrailinRequiresClause(int, double)'
    std::cout << "addConstrainedTemplate(1, 2): " << addConstrainedTemplate(1, 2) << '\n';
    // std::cout << "addConstrainedTemplate(1, 2): " << addConstrainedTemplate(1, 2.5) << '\n'; // error: no matching function for call to 'addConstrainedTemplate(int, double)'
    std::cout << "addAbbreviatedFunctionTemplate(1, 2): " << addAbbreviatedFunctionTemplate(1, 2) << '\n';
    std::cout << "addAbbreviatedFunctionTemplate(1, 2): " << addAbbreviatedFunctionTemplate(1, 2.14) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Which form should we use? As always, the answer is it depends...

If you have a complex requirement, to be able to use an expression you need either the requires clause or the trailing requires clause.

What do I mean by a complex requirement? Anything that has more than one concept in it! Like std::integral<T> || std::floating_point<T>. That is something you cannot express either with a constrained template parameter or with an abbreviated template function.

If you still want to use them, you have to extract the complex constraint expressions into their own concept.

This is exactly what we did when we defined the concept Number. On the other hand, if your concept uses multiple parameters (something we'll see soon), you still cannot use constrained template parameters or abbreviated template function - or at least I didn't find a way for the time being.

If I have complex requirements and I don't want to define and name a concept, I'd go with either of the first two options, namely with requires clause or with trailing requires clause.

In case I have a simple requirement, I'd go with the abbreviated function template. Though we must remember that abbreviated function templates let you call your function with multiple different types at the same time, like how we called add with an int and with a float. If that is a problem and you despise the verboseness of the requires clause, choose a constrained template parameter.

Let's also remember that we talk about templates. For whatever combination, a new specialization will be generated by the compiler at compile time. It's worth to remember this in case you avoided templates already because of constraints on the binary size or compile time.

Conclusion

Today, we have seen how to use concepts with function parameters. We detailed 4 different ways and saw that the more verbose ones give us more flexibility on the constraints, while the tersest one (abbreviated function template) gives extreme flexibility with the types we can call the function with.

Next time, we are going to discuss what kind of concepts we get from the standard library before we'd actually start writing our own concepts.

Stay tuned!

If you want to learn more details about C++ concepts, check out my book on Leanpub!

Top comments (0)