A Buffers Library for C++20: Part 1
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
concept
s 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:
- 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
orbyte_addressof
, whereas searching for another cast expression will be a bit less obvious. In the case of implicit casting tovoid*
, it is likely invisible! - 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. byte_pointer
restricts itself to types that arebuffer_safe
. If you were to attempt to form a pointer-to-std::byte
for a non-trivial object via a traditionalreinterpret_cast
, the compiler would happily let you do so, possibly to your own demise. Withbyte_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 thereinterpret_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 bys
bytes, and shrink the size bys
. This is remarkably useful, as we’ll see later.- Non-member
operator+(*_buffer b, size_type s)
just makes a copy ofb
and appliesoperator+=(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 ifsize() == 0
remove_prefix(size_type s)
- Advance overs
bytes of the underlying buffer, shrinkingsize()
as appropriate.remove_suffix(size_type s)
- Decreasesize()
bys
(There is no overloaded operator counterpart, as this is less often needed).operator[](size_type s)
- Get a reference to thestd::byte
at offsets
from the beginning of the buffer.first(size_type s)
- Obtain a new buffer that references the firsts
bytes ofthis
.last(size_type s)
- Obtain a new buffer that references the lasts
bytes ofthis
.split(size_type part)
- Obtain two new buffers, as-if byfirst(part)
andlast(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
:
- Search for a member function on the object with the same name as the customization point.
- Search for a free function that is visible through ADL.
- If neither, and the type meets sufficient requirements, perform a base implementation.
- 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-else
s 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:
std::memcpy
is notconstexpr
, so this definition ofbuffer_copy
cannot beconstexpr
.std::memcpy
has the precondition that the source/destination are disjoint. This allows the implementation to perform optimized buffer copying, but it means thatbuffer_copy
is unusable in the case that the caller cannot guarantee that the buffers are actually disjoint.- The caller cannot limit the number of bytes copied.
- The caller cannot provide their own copying operation that might be more
well-tuned than
std::memcpy
(It could happen!) - We branch on
n_copy
, since callingmemcpy
with anullptr
is undefined behavior, and we may have been given an empty buffer with.data()
ofnullptr
. 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.