How to provide default implementations in C++ 20 using concepts?

Viewed 447

I am experimenting the possibility of using concepts introduced in C++20 as static interfaces. So far, I am doing well except that I am unable to figure out a way to provide a "default implementation" for a concept.

For example, I have a concept named ByteBuffer, and it is stated like this:

template <typename T>
concept ByteBuffer = requires (T t) {
  { t.read_byte() } noexcept -> std::convertible_to<uint8_t>;
  { t.has_byte() } noexcept -> std::same_as<bool>;

  /* default implementation for `t.read_until(...)`? */
};

I should be able to, logically, provide a default implementation for t.read_until(...) (parameters omitted), and allow concrete implementations to override the default one. Is there a way? How can I do it?

If this is not possible, I think it is reasonable to add such an ability.

Currently, I have to resort to the CRTP to provide a facade class, and I think it more redundant than I could conceive.

4 Answers

Concepts are not base classes. In fact, concepts have no explicit or implicit relation to any of their template arguments.

Concepts do precisely one thing: verify whether a particular set of template parameters is appropriate to use when instantiating a particular template. That's all.

Concepts contain a sequence of expressions and terms that ought to be valid for the given template parameters. That's all that is needed to determine if a set of template parameters is valid for a particular use, so that's all concepts provide.

If you need some kind of default functionality, you will have to use alternate C++ mechanisms to do that. The simplest being a utility function that has its own constraint:

template<typename T>
concept ByteBufferReadUntil = ByteBuffer<T> &&
  requires(T bb) //Add parameters as appropriate
  {
    { t.read_until() } noexcept -> std::same_as<bool>;
  };

template<ByteBuffer T>
  requires ByteBufferReadUntil<T>
bool read_byte_buffer_until(T &bb)
{
  return t.read_until();
}

template<ByteBuffer T>
bool read_byte_buffer_until(T &bb)
{
  //default implementation for `t.read_until(...)`
}

So whenever you want to do read_until for an arbitrary ByteBuffer, you call read_byte_buffer_until.

Concepts are really just constraints on types, so it doesn't make sense to add a "default implementation" to a concept. Instead, what you can do is to provide a default implementation for read_until, and use that unless some ByteBuffer type provides its own implementation.

First you can write a separate concept that checks for read_until:

template <typename T>
concept Readable = requires (T t) {
 { t.read_until() } noexcept -> std::same_as<void>;  // takes no arguments, and returns void for demonstration
};

and then you can provide a default implementation for types that don't provide this function:

template <typename T>
void read_until_impl(T t) {
   std::cout << "default implementation";
}

template <Readable T>
void read_until_impl(T t) {
    t.read_until();  // call provided member function
}

and then some function that uses a ByteBuffer type could call the function like this:

template <ByteBuffer T>
void use(T t) {
    read_until_impl(t);
}

Here's a demo.

As already mentioned, C++20 Concepts really don't provide any mechanism to help with this problem at all. They're just predicates that constrain templates. Nothing more.

What I'll offer instead is a more concise alternative: use if constexpr:

template <ByteBuffer BB>
void read_until(BB& buffer) {
    if constexpr (requires { buffer.read_until(); }) {
        buffer.read_until();
    } else {
        while (buffer.has_byte()) {
            buffer.read_byte();
        }
    }
}

Or sometimes I like using function composition to make this clearer, using something like Boost.Hof:

auto read_until = first_of(
    [](ByteBuffer auto& bb) requires requires { bb.read_until(); } {
        // specialized impl
    },
    [](ByteBuffer auto& bb) {
        // default impl
    });

This can be overload too, since one is more constrained than the other, I just like using first_of since it just makes it that much clearer (and plus would work in C++17 with just a trailing-return-type-based constraint).

Concepts are not base classes. In fact, concepts have no explicit or implicit relation to any of their template arguments.

Concepts do precisely one thing: verify whether a particular set of template parameters is appropriate to use when instantiating a particular template. That's all.

Concepts contain a sequence of expressions and terms that ought to be valid for the given template parameters. That's all that is needed to determine if a set of template parameters is valid for a particular use, so that's all concepts provide.

If you need some kind of default functionality, you will have to use alternate C++ mechanisms to do that. The simplest being a utility function that has its own constraint:

template<typename T>
concept ByteBufferReadUntil = ByteBuffer<T> &&
  requires(T bb) //Add parameters as appropriate
  {
    { t.read_until() } noexcept -> std::same_as<bool>;
  };

template<ByteBuffer T>
  requires ByteBufferReadUntil<T>
bool read_byte_buffer_until(T &bb)
{
  return t.read_until();
}

template<ByteBuffer T>
bool read_byte_buffer_until(T &bb)
{
  //default implementation for `t.read_until(...)`
}

So whenever you want to do read_until for an arbitrary ByteBuffer, you call read_byte_buffer_until.

Related