In this modern day and age of high-falutin’ fancy-pantsy things like C++ Ranges, C++ Coroutines, C++ Modules, and this newfangled “rap music” that all the kids are into, we mustn’t forget our roots: The humble memcpy and void*!

After all, isn’t most of programming just copying bytes around? It’s been many decades since void* was introduced, but is it the best we can do?

“If you wish to make an apple pie from scratch,”

… you must first create memcpy!

In my work on dds, I’ve been reinventing many wheels, although I’ve successfully made use of some existing code, and drew a lot of inspiration from others.

Amongst the libraries I’ve been working on, I eventually needed a library that dealt with byte sequences: One of the lowest-level objects you’ll ever deal with while writing code. This sounds pretty silly, right? We have void*, std::memcpy, read(), and write(). What more could we possibly need?

This was originally going to be a one-off post, but it ended up exploding in size enough that it was too large for a single blog post (even by my standards)! This will be the first in a series about building a C++20 buffer-handling library.

Drawing Inspiration: Asio

Amongst C++’s most famous libraries is Asio (often used in the Boost variant). Beyond being famous, it is also infamous. It gets both a lot of love and a lot of hate, but what I see less frequently discussed is what I believe to be one of its hidden gems: It’s buffer APIs.

Asio is an old library, long predating many of the niceties of “modern C++,” so certain aspects of the codebase didn’t age as well as the others. As for the buffer APIs, I believe they are some of its best components, and I think they should be given serious consideration outside of any I/O related functionality. They are so useful, that I found it worthwhile to rebuild them from scratch with full C++20 support with several tweaks and extensions.

From the Bottom-Up: neo-buffer

Many of the new libraries I’ve been creating live in the neo namespace, my own humble attempt at building high-quality generic code upon our new-and-improved language. Amongst these is neo-buffer, my re-implementation of much of Asio’s buffer-oriented APIs, along with several extensions and tweaks that I hope we can find useful.

These posts will also outline some of my in-depth explorations into working with C++20’s Concepts. Asio also provides “concepts” for its APIs, but they pre-date the language feature and have not yet been implemented as real concepts in the Asio codebase. These posts will explore what similar concept definitions might look like and what kinds of algorithms, types, and utilities we can build on top of them.

Like this post itself, neo-buffer grew far beyond what I initially expected. I’ve been building the library as-if I intend to propose it for standardization. I can’t be sure that it’s something I will propose, but I’m hoping to have given enough rigor and care that it can be worthy of being a default-shipping component. I need more eyes on it than it has received so far, but I hope others will find the APIs and idioms useful.

A nominal concept: “buffer”

To start, lets define a “buffer” to be a sized and non-owning reference to a contiguous sequence of bytes in memory. I choose to use std::byte specifically, because it imposes no semantics, is allowed to alias other types, and makes casts explicit. Unlike char (and variants), it does not implicitly convert, does not provide arithmetic operations, and has a meaningful name. (We’re still able to perform bitwise operations on a std::byte, though.)

The choice of std::byte differs from Asio’s choice to use void* for its buffer types. (Asio long predates std::byte, so that choice was not even available.)

Pointers to std::byte

Let’s ask a question: What is safe to view and manipulate through aliasing pointers through std::byte?

If we have an object of type T that we wish to persist by writing its object representation bytes into a stream, and then later we wish to restore it by reading those bytes from the stream into the object representation of a T, with the expectation that T is equivalent before and after (with no undefined behavior, of course), what would be the requirements on T?

The answer is that T must be TriviallyCopyable. This is a “named requirement,” which are just old-skool versions of concepts from before we had them as a language feature. C++20 won’t include a concept to represent this, but we can make do by writing one ourselves based on the specification of TriviallyCopyable:

template <typename T>
concept trivially_copyable =
  std::copyable<T> &&
  std::is_trivially_copyable_v<T>;

Astute readers will note that this differs slightly from TriviallyCopyable in that it rejects C-style arrays, which do not satisfy copyable. While we could permit trivially_copyable to accept arrays, this would prevent trivially_copyable from refining copyable, which intuition tells us to expect.

A real concept: buffer_safe

We can say that a type is “buffer safe” if it is trivially_copyable. For our purposes, we can extend this to include C-style arrays of trivially_copyable types as well. Thus, we can define our buffer_safe concept:

template <typename T>
concept buffer_safe =
  trivially_copyable<T>
  || (std::is_array_v<T> && trivially_copyable<std::remove_all_extents_t<T>>);

Note: Even if a type is buffer safe, that does not mean it is buffer sensible. For example, manipulating class types through their object representation when those classes have pointer data members, although “safe”, is rarely desired, and we wouldn’t want to write a pointer to disk and then read it back later if the memory mappings of the program may have changed in the interim. I’ve been unable to determine a good way to mechanically detect whether a type is “buffer sensible,” so we must rely on some amount of programmer intuition and make things explicit rather than implicit.

Our First Function: byte_pointer

With our first concept defined, let’s define our first function: a glorified explicit cast:

auto byte_pointer(buffer_safe auto* ptr) {
  auto void_ptr = static_cast<void*>(ptr);
  return static_cast<std::byte*>(void_ptr);
}

auto byte_pointer(buffer_safe auto const* ptr) {
  auto void_ptr = static_cast<const void*>(ptr);
  return static_cast<const std::byte*>(void_ptr);
}

(The static_cast-dance is to satisfy constraints on constexpr, as a single reinterpret_cast is not allowed.)

EDIT: byte_pointer cannot be constexpr, as the static_cast from void* is by-definition not possibly a constant expression.

For brevity, I have omitted constexpr and noexcept from all code samples, but most of the code in this post is otherwise noexcept and constexpr ready.

With this, we can define a similar function that takes the address of an object:

template <typename T>
  requires buffer_safe<std::remove_cvref_t<T>>
auto byte_addressof(T&& obj) {
  return byte_pointer(std::addressof(obj));
}

Asio doesn’t have a byte_pointer function, so why do we want one? A few reasons:

  1. Inherently, manipulating objects through aliased pointers is risky business, and we want to be loud and noticable when we do it. You can find anywhere in your codebase that you are doing it by searching for byte_pointer or byte_addressof, whereas searching for another cast expression will be a bit less obvious. In the case of implicit casting to void*, it is likely invisible!
  2. In order to satisfy constexpr, we need to do the double-cast. If we had to do this everywhere we would make quite a mess.
  3. byte_pointer restricts itself to types that are buffer_safe. If you were to attempt to form a pointer-to-std::byte for a non-trivial object via a traditional reinterpret_cast, the compiler would happily let you do so, possibly to your own demise. With byte_pointer, we get an immediate diagnostic when we attempt those kinds of shenanigans. If you really wanted to do that unsafe cast, you can always break out the reinterpret_cast.

Another note on the choice of std::byte* over void*: Using void* allows implicit casting to occur whenever we attempt to form a buffer to alias an object for which it may be unsafe to do so. We can gain additional compile-time safety and clearer semantics by making these casts explicit.

Concrete Types: const_buffer and mutable_buffer

Asio defines two low-level types to represent buffers: const_buffer and mutable_buffer, and their names do just what they say on the tin:

  • const_buffer is a read-only view of a contiguous segment of memory. We use it as the source of data for buffer-oriented operations.
  • mutable_buffer is a writeable (and readable) reference to contiguous segment of memory. We use it as the destination of data for buffer-oriented operations.

I know what you’re probably thinking: “Vector, what about std::span?”

Span is great. Span is cool. It lets us do some neat things with contiguous pieces of memory. However, when we know that we’re dealing with arrays of bytes, it helps to use a more specialized abstraction. The additional genericity of std::span is not as useful for buffer-oriented operations as the utility of types dedicated to the task. For buffers, some of std::span’s features don’t make sense. In other cases, we want features that are not available in std::span.

The neo-buffer versions of const_buffer and mutable_buffer are a little different. Instead of storing void*, our version stores std::byte*. Additionally, we can easily add a few convenience APIs thanks to C++ Concepts.

If there’s one thing we’ve learned from many years in C++, implicit casts are tools of the devil to sneak subtle bugs into your code at every pass. Except for one cast that we rely one at every corner: T* -> const T* and T& -> const T&. Adding const to a reference is intuitive and safe. Casts in the other direction are spooky and dangerous, and must be done through the terrifying const_cast.

mutable_buffer and const_buffer share the same relationship: Implicit casts from mutable_buffer to const_buffer are implicit and safe, but casts in the other direction are forbidden.

These basis buffer types are themselves very simple. They encompass a pointer to some bytes and a size of the contiguous memory that they reference. The types have a few fundamental operations and member functions:

// The constructors:
const_buffer::const_buffer(const std::byte* p, size_type size);
mutable_buffer::mutable_buffer(std::byte* p, size_type size);

int integer = 42;
// Create a readonly view of the representation of `integer`
auto cbuf = const_buffer(byte_addressof(integer), sizeof integer);
// Create a writeable view of the representation of `integer`
auto mbuf = mutable_buffer(byte_addressof(integer), sizeof integer);

// Get the pointers to the data:
std::byte const* const_ptr = cbuf.data();
std::byte*       mut_ptr   = mbuf.data();

// Get the sizes of the buffers
auto cbuf_size = cbuf.size();
auto mbuf_size = mbuf.size();

A few fun facts about these types:

  • The buffer types are semiregular: They can be default-constructed (In which case they have a size() == 0), copied, and assigned.
  • The buffer types are trivially destructible.
  • Because this is a central abstraction, we can enable audit checks to bounds-check our entire buffer-operating layer.
  • They are contextually convertible to bool, to check whether they are non-empty. This can also be checked with the .empty() method.
  • They are explicitly convertible to/from std::span, std::basic_string, std::basic_string_view, std::array, std::vector, and many more (discussed later).

Advancing Buffers, and Other Nice Things™

Asio’s basic buffer types offer two more very useful APIs on these buffers:

  • *_buffer::operator+=(size_type s) will advance the internal data pointer by s bytes, and shrink the size by s. This is remarkably useful, as we’ll see later.
  • Non-member operator+(*_buffer b, size_type s) just makes a copy of b and applies operator+=(s) to it, and returns that copy.

neo-buffer also offers these APIs, and they behave identically. Usage is simple:

// Write all of the data in `b` into `f`.
void write_all(std::FILE* f, neo::const_buffer b) {
  // Keep writing until the buffer is empty.
  while (b) {
    // Write some data
    auto n_written = std::fwrite(b.data(), 1, b.size(), file);
    // Advance the buffer by how much we have written
    b += n_written;
    // Check that the write actually wrote anything
    if (n_written == 0) {
      throw std::runtime_error("writing the file failed!");
    }
  }
}

Those allergic to operator overloading will be happy to hear that neo-buffer also offers remove_prefix(size_type), which performs the same operation. In fact, the buffer types offer several APIs from std::span, along with a few others:

  • empty() - Check if size() == 0
  • remove_prefix(size_type s) - Advance over s bytes of the underlying buffer, shrinking size() as appropriate.
  • remove_suffix(size_type s) - Decrease size() by s (There is no overloaded operator counterpart, as this is less often needed).
  • operator[](size_type s) - Get a reference to the std::byte at offset s from the beginning of the buffer.
  • first(size_type s) - Obtain a new buffer that references the first s bytes of this.
  • last(size_type s) - Obtain a new buffer that references the last s bytes of this.
  • split(size_type part) - Obtain two new buffers, as-if by first(part) and last(size() - part).
  • equals_string(...) - Magic! Discussed later.
  • explicit operator T() - More magic! Discussed later.

Convenience Casts

I promised that this post would focus on C++ Concepts. So far we haven’t done much, but we’re about to really get into it.

Writing expressive generic code that can Do The Right Thing™ in the face of an infinite space of types has been very difficult. In a world where SFINAE-abuse has been our only mechanism of controlling overload sets, code that does tricks with it can quickly become inscrutable (not to mention the error messages).

One of the coolest but scariest things we can do in C++ is define conversions between types. Again, we have been bitten repeatedly by implicit casts, and we wish to avoid them as much as possible. Explicit casts, on the other hand, are more enticing.

Suppose I wish to support “convenience casts” on the buffer types, much like we have “convenience functions.” Sometimes less is more, as long as our intentions are clear (thus we want explicit casts, not implicit ones).

Suppose one case: I want it to be possible to construct a const_buffer from an arbitrary type for which the semantics of such a conversion is obvious. How would I define such a conversion constructor?

template <typename T>
explicit const_buffer(T&& thing) { ??? }

The above is obviously no good: It will eat everything! When the compiler performs overload resolution on the constructor set of the buffer types, we want to throw out this constructor when it is not applicable. We need to apply a constraint on T.

Finding Good Constraints

To define a concept, we should avoid starting with the constraints and designing types to satisfy them. Instead, we should look at existing types that do what we want and extract the concepts from the syntax and semantics that those types provide.

To define this conversion, let’s start with a type for which I know I want this conversion to be possible: std::string. I want it to be possible to construct a const_buffer cb from a std::string str, where the resulting buffer will have cb.data() == str.data() and cb.size() == str.size().

How could I define such a conversion? It is fairly simple to write it in terms of a delegating constructor:

explicit const_buffer(const std::string& str)
  : const_buffer(byte_pointer(str.data()),
                 str.size())
  {}

We simply convert the string’s .data() pointer into a pointer to std::byte, and form a buffer with the same size as that string.

Generalize

std::string is only a single specialization of std::basic_string. What about wstring, u16string, u32string, or u8string? We can convert our converting constructor into a template over basic_string:

template <typename Char,
          typename Traits,
          typename Allocator>
explicit const_buffer(const std::basic_string<Char, Traits, Allocator>& str)
  : const_buffer(byte_pointer(str.data()),
                 str.size() * sizeof(Char))
  {}

This is similar to the previous, but we must multiply the size of the buffer by the size of the character type, since a wstring of size N consumes N * sizeof(wchar_t) bytes.

This looks pretty good, but has a hiccup: Suppose some maniac passes a Char type that is not buffer_safe! In that case, the byte_pointer(str.data()) call will be invalid, and the constructor will become ill-formed but still remain in the overload set. We need to constrain it further:

template <buffer_safe Char,
          typename    Traits,
          typename    Allocator>
explicit const_buffer(const std::basic_string<Char, Traits, Allocator>& str)
  : const_buffer(byte_pointer(str.data()),
                 str.size() * sizeof(Char))
  {}

(Note the constraint on the Char template parameter.)

std::vector is neato burrito.

std::vector guarantees contiguous layout of its elements. We should also support that one. Such a converting constructor looks markedly similar to the conversion for basic_string:

template <buffer_safe T, typename Alloc>
explicit const_buffer(const std::vector<T, Alloc>& vec)
  : const_buffer(byte_pointer(vec.data()),
                 vec.size() * sizeof(T))
  {}

Yeah, I’d say std::vector is pretty cool. But you know what isn’t cool? Me vector<bool>. This converting constructor, even with its buffer_safe T protection, will be ill-formed (but still appear in the overload set) when given vector<bool> because that type has no .data() method!

We need to prevent vector<bool> from appearing. How could we do that?

template <buffer_safe T, typename Alloc>
  requires !std::same_as<T, bool>
explicit const_buffer(const std::vector<T, Alloc>& vec)
  : const_buffer(byte_pointer(vec.data()),
                 vec.size() * sizeof(T))
  {}

That may be tempting, but remember that we don’t want to define our constraints against specific types, but rather the semantics they have in common. What we really want is to require that there be a .data() method:

template <typename T>
concept has_data_method =
  requires(T t) {
    t.data();
  };

/// ...

template <buffer_safe T, typename Alloc>
  requires has_data_method<std::vector<T, Alloc>>
explicit const_buffer(const std::vector<T, Alloc>& vec)
  : const_buffer(byte_pointer(vec.data()),
                 vec.size() * sizeof(T))
  {}

This has_data_method is actually a bad concept, but we’re getting a bit closer to defining a good concept.

std::array is the bee’s knees.

Supporting std::array will be nice. Let’s define a constructor for that:

template <buffer_safe T, size_t N>
explicit const_buffer(const std::array<T, N>& arr)
  : const_buffer(byte_pointer(arr.data()),
                 arr.size() * sizeof(T))
  {}

Wow… that looks… almost identical to the std::vector version. We should now be noticing a pattern.

std::span is the cat’s pyjamas.

How ‘bout std::span?

template <buffer_safe T, size_t E>
explicit const_buffer(const std::span<T, E>& sp)
  : const_buffer(byte_pointer(sp.data()),
                 sp.size() * sizeof(T))
  {}

Hmm…

std::basic_string_view is A Very Good Thing™.

template <buffer_safe Char, typename Traits>
explicit const_buffer(std::basic_string_view<Char, Traits>& sv)
  : const_buffer(byte_pointer(sv.data()),
                 sv.size() * sizeof(Char))
  {}

Defining a Concept

Remember our has_data_method Very Bad Concept? It’s a hint at a more useful construct trying to break out.

In all of the above cases, we also asked for the .size() of the parameter. This is something that containers have in common. It’s so common, in fact, that a standard library function was added to extend support for it to C-arrays: std::size. What if we want a concept that represents ranges that also know their size? Well, we have one: std::ranges::sized_range.

What about that .data() method? Do we have one of those concepts? Yes: std::ranges::contiguous_range!

It looks like C++20 will be giving us most of what we need to write a good conversion function, but we’ll also need some other APIs that aren’t part of <ranges>.

Now we’re ready to declare our new concept. For neo-buffer, I’ve tentatively named this concept trivial_range. A more descriptive name may be buffer_safe_sized_contiguous_range, but that’s quite a mouthful.

It may be tempting to immediately define our new concept based on the standard library’s concepts:

// No good:
template <typename T>
concept trivial_range =
  ranges::contiguous_range<T> &&
  ranges::sized_range<T>;

but there is actually a bit more that we need. Note that the above definition will match std::vector<std::string>, but we do not want to form a buffer from that type, because std::string is not safe to manipulate through std::byte*. We need to constrain the range type T further.

Create a Basis

We can now define our new range type, which is a refinement of contiuous_range and sized_range:

// Better!
template <typename T>
concept trivial_range =
  ranges::contiguous_range<T> &&
  ranges::sized_range<T> &&
  // The range's data type should be buffer_safe.
  buffer_safe<ranges::range_value_t<T>>;

Add Further Refinements

The above concept will match a range regardless of the const-ness of its referred-to elements. It is safe to view a mutable range through a const_buffer, but not the other way around. For this reason, we may want to constrain a type to only match if the range provides a mutable view of its content, such as (non-const) std::string.

template <typename C>
concept mutable_trivial_range =
  // A `mutable_trivial_range` is
  // also a plain `trivial_range`
  trivial_range<C> &&
  requires (range_reference_t<C> ref) {
    // The byte_pointer type *must* be a
    // pointer to non-const `std::byte`
    { neo::byte_addressof(ref) }
      -> same_as<std::byte*>;
  };

For mutable_trivial_range, the result of byte_addressof must be exactly std::byte*. If the type is const std::byte*, we know that the range’s references do not allow modification through them.

Getting the byte-size of a range

You’ll notice that in our converting constructors, we multiplied the .size() by the size of the elements. This is a common operation enough to give it its own function:

size_t range_size_bytes(const trivial_range auto& c) {
  return ranges::size(c) * sizeof(ranges::range_value_t<decltype(c)>);
}

Applying the Concept

We can now throw away our half-dozen overloads of a converting constructor for a few types and replace it with a generic one that can support a world of types that we haven’t even imagined yet:

const_buffer(trivial_range auto&& c) noexcept
  : const_buffer(byte_pointer(ranges::data(c)),
                 range_size_bytes(c))
  {}

For mutable_buffer, we need to use our other concept that handles the case of mutable ranges:

mutable_buffer(mutable_trivial_range auto&& c) noexcept
  : mutable_buffer(byte_pointer(ranges::data(c)),
                   range_size_bytes(c))
  {}

const-correctness

When defining a concept, it is absolutely necessary that you take const-correctness into account. For trivial_range, we need to consider how we want to propagate const-ness.

For example, std::string_view’s .data() is always a pointer-to-const, regardless of whether the string_view itself is const or not. This is why this is known as a “view”.

On the other hand, std::span<T> for non-const T will never have a pointer-to-const for its .data(). (Also known as “shallow-const”.)

In the case of std::string, std::vector, and std::array, they propagate their const-ness down in their .data() methods. (Also known as “deep-const”.)

Suppose the following snippet:

void frombulate(trivial_range auto&&); // [1]
void frombulate(mutable_trivial_range auto&&); // [2]

void do_stuff(trivial_range auto&& c) {
  frombulate(c);
}

Which version of frombulate will be called by do_stuff? Don’t make the mistake to assume that it will be [1] unconditionally! While c is constrained to meet trivial_range, the actual type of c could also meet mutable_trivial_range.

When c meets mutable_trivial_range, both overloads [1] and [2] will be valid candidates for overload resolution. However, by the rules of constraint refinement, overload [2] is more constrained than [1], and is ruled to be a better match.

If I pass const std::string to do_stuff, then the result of .data() will come out as const char*, and the overload of byte_pointer will be chosen which returns const std::byte*. Thus c does not meet mutable_trivial_range, and frombulate [1] is selected

If I pass std::string, .data() becomes char*, and byte_pointer returns std::byte*. This meets mutable_trivial_range, and frombulate [2] is selected.

Creating a Customization Point

C++20’s Ranges comes with several customization-point objects. These are special callable objects that make it easier to implement customization points than the old method of relying on ADL or template specializations. Unfortunately, creating a customization point object can be tricky, especially as it sits on a boundary where current C++ implementations have bugs and diverging behavior.

Suppose that we want a customization-point that allows a user to provide a conversion of an object to a const_buffer or mutable_buffer. For our purposes, we’ll use the apt name “as_buffer”.

A customization-point object is just that: an object. More precisely, it is an invocable object that will be used as if it were a regular function, but its sole purpose is to enforce constraints and, if applicable, delegate to user-provided functions that implement the customized version of that API. Let’s start with the basics:

inline namespace cpo_detail {
  constexpr inline struct as_buffer_fn {

  } as_buffer;
}

There is now an object as_buffer of the type defined in the cpo_detail namespace. The actual type of the CPO is of little interest to the user, but we do want the user to be able to copy and pass around the customization point as an object.

Our customization point is not yet invocable, so we should decide how it is “customized.”

The current standard library’s customization points follow a similar pattern, and we can echo that with as_buffer:

  1. Search for a member function on the object with the same name as the customization point.
  2. Search for a free function that is visible through ADL.
  3. If neither, and the type meets sufficient requirements, perform a base implementation.
  4. If none of the above, the invocation is ill-formed.

We need to define an operator() for our customization point, and we need to constrain it so that it becomes non-viable if none of the requirements are met.

For each possible expression, we’ll define very basic concepts to match it (these are not part of the public API and are only used as part of the customization point).

template <typename T>
concept has_member_as_buffer =
  requires (T t) {
    { t.as_buffer() }
      -> std::convertible_to<const_buffer>;
  };

template <typename T>
concept has_adl_as_buffer =
  requires (T t) {
    { as_buffer(t) }
      -> std::convertible_to<const_buffer>;
  };

template <typename T>
concept as_buffer_check =
     has_member_as_buffer<T>
  || has_adl_as_buffer<T>
  || std::constructible_from<const_buffer, T>
  || std::constructible_from<mutable_buffer, T>;

We can now define a constrained operator():

struct as_buffer_fn{
  template <as_buffer_check T>
  auto operator()(T&& item) const {
    // 1: Prefer a member .as_buffer()
    if constexpr (has_as_buffer_member<T>) {
      return forward<T>(item).as_buffer();
    }
    // 2: Prefer next an ADL as_buffer(t)
    else if constexpr (has_adl_as_buffer<T>) {
      return as_buffer(forward<T>(item));
    }
    // 3: Next, prefer a conversion to mutable_buffer
    else if constexpr (std::constructible_from<mutable_buffer, T>) {
      return mutable_buffer(forward<T>(item));
    }
    // 4: None of the other options match, so assume we
    //    can convert to const_buffer
    else {
      return const_buffer(forward<T>(item));
    }
  }
};

The chain of constexpr if-elses will select the first of the matching customization expressions, so we are safe if more than one of the possible expressions is actually valid (We can “prefer” a member as_buffer to an ADL as_buffer).

Because of our prior explicit conversions, as_buffer will now accept any trivial_range as well:

std::string s;
auto buf = as_buffer(s);  // Okay!

This has an additional benefit: Suppose that we don’t know which of the two buffer types we actually want? What if our choice of const_buffer/mutable_buffer depends on the const-ness of the range? Well now we can simply use as_buffer() and get the correct type without fretting over it:

std::string_view     sview;
std::string          str;
std::vector<int>     vec;
std::span<int>       span;
std::span<const int> c_span;

// Always-const, because it is a view
as_buffer(sview)
  -> const_buffer;

// Deeply-const
as_buffer(str)
  -> mutable_buffer;
as_buffer(as_const(str))
  -> const_buffer;

// Same
as_buffer(vec)
  -> mutable_buffer;
as_buffer(as_const(vec))
  -> const_buffer;

// Shallow-const, so always mutable
as_buffer(span)
  -> mutable_buffer;
as_buffer(as_const(span))
  -> mutable_buffer;

// A span-of-const is a view
as_buffer(c_span)
  -> const_buffer;

Buffer Size Clamping

Another common operation is to “clamp” the size of a buffer that we might not know. That is, if given a buffer b of size N, but we can only operate on M bytes at most, we need to get a new buffer that refers to the same bytes as b, but is the shorter of N and M.

as_buffer, as a utility to create buffers from existing objects, is a good candidate to perform such an operation:

struct as_buffer_fn {
  template <as_buffer_check T>
  auto operator()(T&& item) const { /* ... */ }

  template <as_buffer_check T>
  auto operator()(T&& item, size_t max_size) const {
    // Use our main `operator()` to get the initial buffer
    auto buf = (*this)(item);
    if (buf.size() > max_size) {
      // Shrink the buffer to the first `max_size` bytes:
      buf = buf.first(max_size);
    }
    return buf;
  }
};

Buffers from Pointers

Similar to the problem of unknown const-ness on ranges, we may have a pointer to std::byte (of unknown const-ness), and a size_t of the buffer, so how do we know whether to create a const_buffer or a mutable_buffer? We could check the type inline, or we could delegate that responsibility to our as_buffer:

struct as_buffer_fn {
  // ...

  const_buffer operator()(const std::byte* ptr, size_t size) const {
    return const_buffer(ptr, size);
  }

  mutable_buffer operator()(std::byte* ptr, size_t size) const {
    return mutable_buffer(ptr, size);
  }
};

With these, our as_buffer becomes a one-stop-shop for generating buffers from “buffer-able” objects, be they ranges, byte pointers, or any type that provides an as_buffer.

Another Utility: Buffers to Trivial Objects

We’re set up to create buffers from contiguous ranges of buffer_safe objects, but what if we just want to create a buffer for a single object? We can do that pretty easily:

int value = 42;
auto buf = const_buffer(byte_addressof(value), sizeof value);

This is an incredibly common operation. It’s common enough to warrant a utility function:

auto trivial_buffer(buffer_safe auto&& obj) {
  auto ptr = byte_addressof(obj);
  auto size = sizeof obj;
  return as_buffer(ptr, size);
}

Because trivial_buffer uses byte_addressof and as_buffer, it also behaves correctly in preserving const-ness of its argument:

int i = 42;
const int ci = 1729;

trivial_buffer(i)
  -> mutable_buffer;

trivial_buffer(ci)
  -> const_buffer;

Later on, we’ll see why trivial_buffer can be more useful than meets the eye.

Our First Buffer-Algorithm: buffer_copy

At the beginning, I teased that we would be re-inventing std::memcpy. Well, here it comes.

buffer_copy is probably the most fundamental of all buffer-oriented algorithms, and one of the most fundamental operations in all of computing. It’s semantically equivalent memcpy, but we can do some tricks that improve over memcpy, including automatic bounds and nullptr checks. Here’s a simple definition:

size_t buffer_copy(mutable_buffer dest, const_buffer source) {
  const size_t n_copy = min(dest.size(), source.size());
  if (n_copy) {
    memcpy(dest.data(), source.data(), n_copy);
  }
  return n_copy;
}

This looks alright, but it has some downsides:

  1. std::memcpy is not constexpr, so this definition of buffer_copy cannot be constexpr.
  2. std::memcpy has the precondition that the source/destination are disjoint. This allows the implementation to perform optimized buffer copying, but it means that buffer_copy is unusable in the case that the caller cannot guarantee that the buffers are actually disjoint.
  3. The caller cannot limit the number of bytes copied.
  4. The caller cannot provide their own copying operation that might be more well-tuned than std::memcpy (It could happen!)
  5. We branch on n_copy, since calling memcpy with a nullptr is undefined behavior, and we may have been given an empty buffer with .data() of nullptr. This can hurt code size and costs cache size in the branch predictor.

For (1), we can do enough to branch on std::is_constant_evaluated() to perform a simple byte-wise copy for constexpr support.

For (2), we could use std::memmove, but this would be a pessimisation if the caller knows that the buffers are disjoint.

In all of the above cases, we can solve the potential issues by providing additional overloads. Firstly, let’s provide a max_copy parameter:

size_t buffer_copy(mutable_buffer dest,
                   const_buffer source,
                   size_t max_copy) {
  const size_t n_copy = min(max_copy, min(dest.size(), source.size()));
  // ...
  return n_copy;
}

// An overload that fills in `max_copy` for us:
size_t buffer_copy(mutable_buffer dest, mutable_buffer source) {
  return buffer_copy(dest, source, numeric_limits<size_t>::max());
}

This will allow the caller to limit the number of bytes copied regardless of the buffer sizes.

Low-Level Copying

Previously, we unconditionally called memcpy to copy our bytes, but it presents a few limitations. It would be nice if we could present the user options to replace how we do this operation. Of course, let’s concept-ify memcpy:

template <typename Fn>
concept bytewise_copy_func =
  invocable<Fn, byte*, const byte*, size_t>;

Now let’s provide our constexpr variants of memcpy that “do the right thing”:

constexpr void bytewise_copy_forwards(byte* out, byte* in, size_t n) {
  for (; n; --n) {
    *out++ = *in++;
  }
}

constexpr void bytewise_copy_backwards(byte* out, byte* in, size_t n) {
  auto d = out + n;
  auto s = in + n;
  for (; n; --n) {
    *--d = *--s;
  }
}

constexpr void bytewise_copy_safe(byte* out, byte* in, size_t n) {
  if (std::less<>{}(out, in)) {
    bytewise_copy_forwards(out, in, n);
  } else {
    bytewise_copy_backwards(out, in, n);
  }
}

Now, let’s put our low-level copy function into buffer_copy:

/**
 * Base implementation:
 */
constexpr
size_t buffer_copy(mutable_buffer dest,
                   const_buffer src,
                   size_t max_copy,
                   bytewise_copy_func auto copy) {
  const auto n_copy = min(max_copy, min(dest.size(), src.size()));
  copy(dest.data(), src.data(), n_copy);
  return n_copy;
}

// Convenience overloads:
/**
 * Given a dest, src, and max size:
 */
constexpr size_t
buffer_copy(mutable_buffer dest,
            const_buffer src,
            size_t max_copy) {
  // Default to using the safe-copy function:
  return buffer_copy(dest, src, max_copy, &bytewise_copy_safe);
}

/**
 * Given a dest, src, and a low-level byte copy function:
 */
constexpr size_t
buffer_copy(mutable_buffer dest,
            const_buffer src,
            bytewise_copy_func auto copy) {
  // Call the base version with the maximum size_t
  return buffer_copy(dest, src, numeric_limits<size_t>::max(), copy);
}

/**
 * Given a dest and a source only:
 */
constexpr size_t
buffer_copy(mutable_buffer dest, const_buffer src) {
  return buffer_copy(dest,
                     src,
                     numeric_limits<size_t>::max(),
                     &bytewise_copy_safe);
}

And now we have our base buffer_copy algorithm.

Wild Overkill?

I can hear a lot of readers scratching there head at the above. This is completely overkill, right? Can’t we can just call memcpy and be done with it?

In the simplest of cases: Yes.

It isn’t yet obvious why one would want a buffer_copy algorithm, but in the next posts we’ll be expanding our buffer-vocabulary, and memcpy will no longer be sufficient.

Wrapping Up Part 1

I initially planned on creating only a single monolithic post about a buffers library, but it quickly exploded in size. This post itself is very long, and there’s still so many things I want to cover:

  • Buffer ranges
  • Buffer tuples
  • Buffer range consumers
  • Other buffer algorithms
  • Dynamic buffer ranges
  • Buffer sinks and buffer sources
  • Byte-wise iteration
  • How this all might fit in the context of a modern input/output library

Alas, these all will have to wait for future installments.