C++ Concepts and the Core Guidelines

sandordargo

Sandor Dargo

Posted on October 13, 2021

C++ Concepts and the Core Guidelines

Let's get back to C++ concepts and have a look at the rules and best practices that the Core Guidelines propose.

Having read them through, I found that they are incomplete (no surprise, concepts are new), yet outdated.

How is that possible?

They were written for the Concepts TS, not for the standardized version. So as you'll see, here and there it follows a syntax that is not compilable.

I'm sure it will take some years to find all the best practices and fill the guidelines. After all, they should not change frequently.

Let's see what they offer today.

How to use concepts

Let's start with some rules on how to use concepts.

T.10: Specify concepts for all template arguments

This rule recommends something we already discussed. You shouldn't use bare typename Ts in the template parameter lists.

T is obviously a bad name as it doesn't bring any additional information apart from that it's a template type and you should strive for better names, but the rule mainly suggests not to use these template types without constraining them.

Instead of

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

we should use

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

or even better:

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

T.11: Whenever possible use standard concepts

This rule reminds me of something we discussed in Loops are bad, algorithms are good! Aren't they?.

Whenever the standard library offers you what you need, take it and use it. Reinventing the wheel is dangerous and useless.

Whatever you find in the standard library is better tested, often more expressive and in the vast majority of cases, it provides better performance compared to what you'd write.

It's the same idea for concepts as for algorithms. Why would it be any different?

T.12: Prefer concept names over auto for local variables

This is very similar to T10 which advocates for no bare template parameters, no template parameters without a constraint on them.

In this case, it's not about the typename keyword, but about auto. If we consider typename an unconstrained template parameter, we can also consider auto as an unconstrained type. In another word, auto is the weakest concept.

Instead of using auto n = calculate(); we use write Number auto n = calculate();. In this case, it's worth noting that the rule is outdated as it's still using Concepts TS in which one could use a concept not with but instead of auto which is a bit misleading as it's difficult to know whether what you see is a type or a concept.

T.13: Prefer the shorthand notation for simple, single-type argument concepts

As we saw earlier both for functions and classes, there are several different ways to use concepts, to introduce constraints on your template parameters.

One way was to use the requires clause:

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

It's quite readable, but it's more verbose than necessary.

This rule advocates for using the shorthand notation instead, to use what we call today the constrained template parameters:

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

Or, when you have the possibility go even further and use the abbreviated function template form of

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

How to define concepts

Let's continue with some rules on how to define concepts. With time, this can be the most important section of the core guidelines on concepts. Writing concepts is easy, writing good concepts that are meaningful and carry some semantic meaning is difficult.

T.20: Avoid “concepts” without meaningful semantics

A good concept should do more than enforcing the existence of certain functions, it should do more than requiring a certain API.

A good concept will also communicate semantics.

For example, it's more than enforcing having the operator+ defined, it's communicating that the type modelling a concept is a number.

T.21: Require a complete set of operations for a concept

This next rule is closely related to the previous one. If you want to have meaningful semantics it's hardly useful to model a number only supporting addition.

You need to put in a bit more work and model all the necessary operations, all the necessary comparisons. In general, all the functions that make a type modelling a useable concept.

T.22: Specify axioms for concepts

First, we have to understand what an axiom is.

An axiom or assumption is a statement that is taken to be true, it serves as a premise or starting point for further reasoning and arguments. We take an axiom valid without any evidence.

If you want to express axioms in code, they would be Boolean expressions. C++20 doesn't support axioms, but it might change in the future.

For the time being, you can express axioms as comments:

template<typename T>
    // The operators +, -, *, and / for a number are assumed to follow the usual mathematical rules
    // axiom(T a, T b) { a + b == b + a; a - a == 0; a * (b + c) == a * b + a * c; /*...*/ }
    concept Number = requires(T a, T b) {
        {a + b} -> std::convertible_to<T>;   // the result of a + b is convertible to T
        {a - b} -> std::convertible_to<T>;
        {a * b} -> std::convertible_to<T>;
        {a / b} -> std::convertible_to<T>;
    } 
Enter fullscreen mode Exit fullscreen mode

T.23: Differentiate a refined concept from its more general case by adding new use patterns

If you have two concepts where one is the refined version of the other, use the general one in the refined pattern and add some additional requirements.

Let's say we have this concept:

template<typename I>
concept bool Input_iter = requires(I iter) { ++iter; };
Enter fullscreen mode Exit fullscreen mode

In order to define Fwd_iter correctly, do not write it from scratch:

template<typename I>
concept bool Fwd_iter = requires(I iter) { 
  ++iter;
  iter++; 
}
Enter fullscreen mode Exit fullscreen mode

Instead use, the more generic version and add the extra rules:

template<typename I>
concept bool Fwd_iter = Input_iter<I> && requires(I iter) { iter++; }
Enter fullscreen mode Exit fullscreen mode

This helps both the reader to understand that they have to deal with a more refined version and the compiler can also find the good concept at overload resolution time.

T.24: Use tag classes or traits to differentiate concepts that differ only in semantics

As we discussed earlier a good concept does not only express syntactic requirements, but it's also about semantics.

What if the syntactical requirements are the same for two concepts, but they have different semantics?

In order to disambiguate them, we have to add some syntactical differences.

A way of doing this is to write a tag class or a trait (either a standard or a user-defined one) and make a requirement on it:

template<typename I>    // iterator providing random access
bool RA_iter = ...;

template<typename I>    // iterator providing random access to contiguous data
bool Contiguous_iter =
    RA_iter<I> && is_contiguous<I>::value;  // using is_contiguous trait

Enter fullscreen mode Exit fullscreen mode

T.25: Avoid complementary constraints

It's not a good idea to use complementary constraints meaning that in one function overload you make some requirements and in the other, you require its negation:

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

template<typename T>
    requires C<T>
void f();
Enter fullscreen mode Exit fullscreen mode

Instead of the negated one, just use a general template without negated constraints.

template<typename T>   // general template
    void f();

template<typename T>   // specialization by concept
    requires C<T>
void f();
Enter fullscreen mode Exit fullscreen mode

Why is it a bad idea to use the negated form? As we saw earlier in C++ Concepts and logical operators, negations can be more difficult to handle due to subsumption rules. Besides, it's much less readable to achieve the same effect, not to mention maintainability.

Just keep it stupid simple.

T.26: Prefer to define concepts in terms of use-patterns rather than simple syntax

When I read this title first, I didn't really understand. But the Core guidelines provide a great example.

You might have some helper concepts or type traits such as has_equal<T> and has_not_equal<T>. They would let you (re)create EqualityComparable like this

template<typename T> concept EqualityComparable = has_equal<T> && has_not_equal<T>;
Enter fullscreen mode Exit fullscreen mode

It's not unreadable, but it's better if you use the requires body to express your constraints by writing how you want to use the types modeling the concept:

template<typename T> concept EqualityComparable = requires(T a, T b) {
    { a == b } -> std::same_as<bool>;
    { a != b } -> std::same_as<bool>;
};
Enter fullscreen mode Exit fullscreen mode

Remember, humans are great at following patterns. Use that as a feature!

Additional rules

As we mentioned, there is plenty of space left in the guidelines for additional rules on concepts.

At the moment of writing, I found one among "Template Interfaces". If you found more, let me know so I can include them.

T.41: Require only essential properties in a template’s concepts

You might remember from unit testing, that you shouldn't assert every detail, every internal of a class as it makes your tests brittle. Unit tests should assert just to the right level of detail.

The idea is similar to concepts. A concept should not require too many details and definitely not things that are unrelated.

For example, a concept modelling sortable types, should not require I/O operations at all. The ability of a project to print itself has nothing to do with sortability. If that is required, it should be modelled in a different concept, such as Printable or Streamable.

A good API is strict enough, but loose at the same time and it's definitely stable. This rule helps to achieve the desired level of looseness and stability.

Conclusion

Today, we discussed the already existing best practices and recommendations on concepts in the Core Guidelines.

There are already a decent number of rules, even though they are not up to date with C++20, they are still based on the Concepts TS. Nevertheless, they serve as a good basis for further discussion as our experience of writing concepts grows.

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

Connect deeper

If you liked this article, please

Let me know about your best practices.

Connect deeper

If you liked this article, please

💖 💪 🙅 🚩
sandordargo
Sandor Dargo

Posted on October 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related