Concepts — case studies

This post has been inspired by the readers’ questions about using concepts to solve real problems. We will have a look at two such problems and see if, and how, concepts can help.

Case Study 1

My concept has two functions: one produces a value, and the other one later consumes this value:

template <typename T>
void test(T& encoder, span<byte> buffer)
{
  auto state = encoder.init();
  // do something with state
  encoder.encode(state, buffer);
}

How to reflect in a concept that the result of init() is a type that can be later passed to encode()?

A simple way to do it would be to put a combined expression as one of the concept requirements:

template <typename E>
concept Encoder = requires(E& e, span<byte> b)
{
  e.encode(e.init(), b);
};

But this has limitations. First, if our concept has two “init” functions init_1() and init_2() producing the value of the same type, and two “encode” functions encode_1() and encode_2() consuming the value of the same type, we will need a lot of combined expressions to reflect all the combinations.

Second, if function encode() modifies the state of the object in-place, and its argument type is an lvalue reference to non-const, such combined expression is actually ill-formed.

A solution to this problem employed in the STL is to additionally require in the concept that a constrained type provides a nested type alias that names the type in question: even if no algorithm actually uses this type.

template <typename E>
concept Encoder = requires(E& e, 
                           typename E::state_t& s,
                           span<byte> b)
{
  { e.init() } -> std::convertible_to<typename E::state_t>;
  e.encode(s, b);
};

Note that I have expressed the nested type requirement in an unusual way. It is not a separate declaration ending with a semicolon: it is in the “argument list” of the requires-expression. This makes the definition shorter, but this was not the reason. I needed to introduce the name s so that I can use it in the description of member function encode(). Otherwise, I would have to resort to solutions that look quite complicated:

template <typename E>
concept Encoder = requires(E& e)
{
  typename E::state_t;
  { e.init() } -> std::convertible_to<typename E::state_t>;
}
&& requires(E& e, 
            typename E::state_t& s,
            span<byte> b)
{
   e.encode(s, b);
};

This perfectly illustrates that requires-expression is just an expression, so you can combine it with other expressions. Token && is a conjunction operator — rather than a logical-or — and has the special compile-time short-circuiting properties. Another alternative:

template <typename E>
concept Encoder = requires(E& e)
{
  typename E::state_t;
  { e.init() } -> std::convertible_to<typename E::state_t>;

  requires requires(typename E::state_t& s,
                    span<byte> b)
  {
    e.encode(s, b);
  };
};

This is a nested requirement and uses the peculiar requires requires syntax. Another alternative uses std::declval:

template <typename E>
concept Encoder = requires(E& e, span<byte> b)
{
  typename E::state_t;
  { e.init() } -> std::convertible_to<typename E::state_t>;
  e.encode(std::declval<typename E::state_t&>(), b);
};

Which illustrates that arguments in requires-expression is just a syntactic sugar over what you can already do with std::declval. Similarly, we could substitute std::declval<E&>() for e in all the above examples, but using an argument name makes the declarations easier to read.

Anyway, in all these examples, including the first one, we have to repeat the typename E::state_t. This is a known inconvenience in C++20 concepts, and solutions are explored to resolve this problem in the future version of C++.

Case Study 2

In the above example, we were using a buffer of type std::span<std::byte>. Let’s change our concept a bit, and say that a type models concept Encoder if it has function encode that works with any type modelling concept Buffer. How do we do that? We could write something like this:

template <typename E, typename B>
concept Encoder = 
  Buffer<B> &&
  requires(E& e, typename E::state_t& s, B b)
{
  { e.init() } -> std::convertible_to<typename E::state_t>;
  e.encode(s, b);
};

This would compile, and could be used to check if a given encoder works with a given buffer, but this is not what we wanted. We want to check if an encoder can encode any type modelling concept Buffer.

Unfortunately, there is no way to do it. And this is not an omission in the concepts syntax. While this need occurs from time to time when we are doing template meta-programming, it is not in scope of what we call Generic Programming. In order to test if a given type (or set of types) satisfies a concept, we have to provide concrete types, and see if all the necessary declarations are present. Checking if a type satisfies a concept is similar to instantiating a template (although no template instantiation actually takes place).
If we have an encoder class:

struct MyEncoder
{
  using state_t = std::size_t;
  state_t init();
  void encode(state_t& s, span<byte> buf);
};

We can pass it to our concept, and it can test that all expressions are there, by substituting MyEncode for the template parameter E in concept Encoder:

static_assert(Encoder<MyEncoder>);

And once we do it, all types are known and concrete: there is no template anymore. We could test if our encoder works with one buffer type, or another buffer type, but there is no way a compiler can check — using template parameter substitution — if the encoder works with all possible types modelling concept Buffer that were ever defined or are yet to be defined. This would require an infinite number of tests. This is somewhat similar to virtual function templates: we do not have them, because it would require virtual tables of infinite sizes: one slot per any combination of function arguments’ types.


And that’s it for today. For the end, if you like to learn from videos, the organizers of C++Now offered me an opportunity to give a talk about practical aspects of concepts, based on the posts from this blog. You can watch it here.

This entry was posted in programming and tagged , , . Bookmark the permalink.

3 Responses to Concepts — case studies

  1. Paul Topping says:

    In Case Study 1, wouldn’t the first code snippet without concepts express the desired relationship? If encoder.init() doesn’t return a type acceptable to encoder.encode(), the compiler will give a type error. Why do we need a concept here?

    • Thanks. This is a very important question. The first snippet does indeed express the intent and the expectations of the type clearly. One could argue that this representation is clearer that any other that uses concepts.

      So, why do we need a concept? A concept is supposed to be the interface between the generic library author, and the generic library user. If you are both writing the algorithms and you are the only one using them, then you will likely not benefit from concepts. The idea is that the “body” of the concept is the only thing that the generic library users should be ever exposed to, in order to understand what a type should provide. They should be never exposed to the implementations of the generic library algorithms, even when they see compiler error messages. In my example, function test() was supposed to illustrate an algorithm. But you could look at it from a different perspective.

      You could say, as a generic library author, that instead of a concept, you are giving the users a function test(): the user is supposed to instantiate function template test() with their types, and the function will tell in a clear way if anything is missing. This would work, and we could call it an alternative implementation of concepts.

      Apart from that what concepts add is:

      1. You can put them syntactically in places where you cannot normally put other templates:

      template <Iterator It, SentinelFor<It> S> ...;
      
      void f(invokable<int, int> auto fun) ...;
      

      2. Concepts, even though they are templates, they are never instantiated. Therefore the check whether a type satisfies a concept is less resource-consuming for the compiler: it works faster.

      3. Using a concept is a clear hint to the compiler that you are checking constraints, so compilers can be optimized for that use case: check constraints faster, generate more dedicated compiler errors.

      In the end, a code that compiles correctly with concepts will also compile correctly without concepts (except for the case where you use concept-based overloading), so the only thing you get is better experience during the compilation.

      • Paul Topping says:

        Thanks. That makes sense. So, to summarize for my own thoughts, the benefit to the library creator is better expression of design and the benefit to the library consumer is better error messages.

        In some sense, every programmer is both a class creator and consumer if they write quality modular code, even if they are the only programmer working on the project. It will be interesting to see how concepts will be used in code that is not packaged as a library to be consumed by the masses. I know I would benefit sometimes from better error messages when I use a class that I developed years ago. Also, when I have to maintain that class, I might benefit from the clearer design using concepts. Concepts will have passed an important test if using them helps even in non-library situations.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.