Speculations on arenas and custom strings in C++
Speculations on arenas and custom strings in C++
My techniques with arena allocation and strings are oriented around C. I’m always
looking for a better way, and lately I’ve been experimenting with building them using
C++ features. What are the trade-offs? Are the benefits worth the costs? In this article I
lay out my goals, review implementation possibilities, and discuss my findings.
Following along will require familiarity with those previous two articles.
Some of C++ is beyond my mental capabilities, and so I cannot wield those parts
effectively. Other parts I can wrap my head around, but it requires substantial effort and
the inevitable mistakes are difficult to debug. So a general goal is to minimize contact
with that complexity, only touching a few higher-value features that I can use
confidently.
Existing practice is unimportant. I’ve seen where that goes. Like the C standard library,
the C++ standard library offers me little. Its concepts regarding ownership and memory
management are irreconcilable (move semantics, smart pointers, etc.), so I have to
build from scratch anyway. So absolutely no including C++ headers. The most valuable
features are built right into the language, so I won’t need to include library definitions.
In theory constexpr could be useful, but it keeps falling short when I try it out, so I’m
ignoring it. I’ll elaborate in a moment.
Minimal template use. They blow up compile times and code size, they’re noisy, and in
practice they make debug builds (i.e. -O0) much slower (typically ~10x) because there’s
no optimization to clean up the mess. I’ll only use them for a few foundational purposes,
such as allocation. (Though this article is about the fundamental stuff.)
No methods aside from limited use of operator overloads. I want to keep a C style, plus
methods just look ugly without references: obj->func() vs. func(obj). (Why are we
still writing -> in the 21st century?) Function overloading can instead differentiate
“methods.” Overloads are acceptable in moderation, especially because I’m paying for it
(symbol decoration) whether or not I take advantage.
https://nullprogram.com/blog/2024/04/14/ 1/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
Allocation
Let’s start with allocation. Since writing that previous article, I’ve streamlined arena
allocation in C:
typedef struct {
byte *beg;
byte *end;
} arena;
static byte *alloc(arena *a, size objsize, size align, size count)
{
assert(count >= 0);
size pad = (uptr)a->end & (align - 1);
assert(count < (a->end - a->beg - pad)/objsize); // oom
return memset(a->end -= objsize*count + pad, 0, objsize*count);
}
(As needed, replace the second assert with whatever out of memory policy is
appropriate.) Then allocating, say, a 10k-element hash table (i.e. to keep it off the
stack):
With C++, I initially tried placement new with the arena as the “place” for the allocation:
This exposes the constructor, but everything else about it is poor. It relies on complex,
finicky rules governing new overloads, especially for alignment handling. It’s difficult to
tell what’s happening, and it’s too easy to make mistakes that compile. That doesn’t
even count the mess that is array new[].
I soon learned it’s better to replace the new macro with a template, which can actually
see what it’s doing. I can’t call it new in C++, so I settled on make instead:
https://nullprogram.com/blog/2024/04/14/ 2/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
template<typename T>
static T *make(arena *a, size count = 1)
{
assert(count >= 0);
size objsize = sizeof(T);
size align = alignof(T);
size pad = (uptr)a->end & (align - 1);
assert(count < (a->end - a->beg - pad)/objsize); // oom
a->end -= objsize*count + pad;
T *r = (T *)a->end;
for (size i = 0; i < count; i++) {
new ((void *)&r[i]) T{};
}
return r;
}
object *o = make<object>(&scratch);
Due to placement new, merely for invoking the constructor, these objects aren’t just
zero-initialized, but value-initialized. It can only construct objects that define an empty
initializer, but in exchange unlocks some interesting possibilities:
struct mat3 {
f32 data[9] = {
1, 0, 0,
0, 1, 0,
0, 0, 1,
};
};
struct list {
node *head = 0;
node **tail = &head;
};
When a zero-initialized state isn’t ideal, objects can still initialize to a more useful state
straight out of the arena. The second case is even self-referencing, which is specifically
supported through placement new. Otherwise you’d need a special-written copy or
move constructor.
make could accept constructor arguments and perfect forward them to a constructor.
However, that’s too far into the dark arts for my comfort, plus it requires a correct
https://nullprogram.com/blog/2024/04/14/ 3/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
Update 1: One of those experts, Jonathan Müller, kindly reached out to say that a static
cast is sufficient. This is easy to do:
Update 2: I later realized that because I do not care about copy or move semantics, I
also don’t care about perfect forwarding. I can simply expand the parameter pack
without casting or &&. I also don’t want the extra restrictions on braced initializer
conversions, so better to use parentheses with new.
One small gotcha: placement new doesn’t work out of the box, and you need to provide
a definition. That means including <new> or writing one out. Fortunately it’s trivial, but
the prototype must exactly match, including size_t:
Strings
typedef struct {
u8 *data;
size len;
} s8;
https://nullprogram.com/blog/2024/04/14/ 4/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
Since it doesn’t own the underlying buffer — region-based allocation has already solved
the ownership problem — this is what C++ long-windedly calls a std::string_view.
In C++ we won’t need the countof macro for strings, but it’s still generally useful.
Converting it to a template, which is theoretically more robust (rejects pointers), but
comes with a non-zero cost:
The reference — here a reference to an array — is unavoidable, so it’s one of the rare
cases. The same concept applies as an s8 constructor to replace the macro:
struct s8 {
u8 *data = 0;
size len = 0;
s8() = default;
template<size N>
s8(const char (&s)[N]) : data{(u8 *)s}, len{N-1} {}
};
I’ve explicitly asked to keep a default zero-initialized (empty) string since it’s useful —
and necessary to directly allocate strings using make, e.g. an array of strings. const is
required because string literals are const in C++, but it’s immediately stripped off for
the sake of simplicity. The new constructor allows:
s8 version = "1.2.3";
b32 operator==(s8 s)
{
return len==s.len && (!len || !memcmp(data, s.data, len));
}
Now this works, and it’s cheap and fast even in debug builds:
https://nullprogram.com/blog/2024/04/14/ 5/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
s8 key = ...;
if (key == "HOME") {
// ...
}
That’s more ergonomic than the macro and comparison function. operator[] also
improves ergonomics, to subscript a string without going through the data member:
u8 &operator[](size i)
{
assert(i >= 0);
assert(i < len);
return data[i];
}
By the way, these assertions I’ve been using are great for catching mistakes quickly
and early, and they complement fuzz testing.
I’m not sold on it, but an idea for the future: C++23’s multi-index operator[] as a slice
operator:
Then:
I could keep going with, say, iterators and such, but each will be more specialized and
less useful. (I don’t care about range-based for loops.)
https://nullprogram.com/blog/2024/04/14/ 6/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
The new string stuff is neat, but I hit a wall trying it out: These fancy constructors do not
reliably construct at compile time, not even with a constexpr qualifier in two of the
three major C++ implementations. A static lookup table that contains a string is likely
constructed at run time in at least some builds. For example, this table:
Requires run-time construction in real world cases I care about, requiring C++ magic
and linking runtime gunk. The constructor is therefore a strict downgrade from the
macro, which works perfectly in these lookup tables. Once a non-default constructor is
defined, I’ve been unable to find an escape hatch back to the original, dumb, reliable
behavior.
Update: Jonathan Müller points out the reinterpret cast is forbidden in a constexpr
function, so it’s not required to happen at compile time. After some thought, I’ve figured
out a workaround using a union:
struct s8 {
union {
u8 *data = 0;
const char *cdata;
};
size len = 0;
template<size N>
constexpr s8(const char (&s)[N]) : cdata{s}, len{N-1} {}
// ...
}
In all three C++ implementations, in all configurations, this reliably constructs strings at
compile time. The other semantics are unchanged.
Other features
Having a generic dynamic array would be handy, and more ergonomic than my dynamic
array macro:
https://nullprogram.com/blog/2024/04/14/ 7/8
11/15/24, 1:23 PM Speculations on arenas and custom strings in C++
template<typename T>
struct slice {
T *data = 0;
size len = 0;
size cap = 0;
slice<T> = default;
template<size N>
slice<T>(T (&a)[N]) : data{a}, len{N}, cap{N} {}
T &operator[](size i) { ... }
}
template<typename T>
slice<T> append(arena *, slice<T>, T);
On the other hand, hash maps are mostly solved, so I wouldn’t bother with a generic
map.
prints8(bufout *, s8);
printi32(bufout *, i32);
printf64(bufout *, f64);
printvec3(bufout *, vec3);
Would hide that stuff behind the scenes in the symbol decoration:
print(bufout *, s8);
print(bufout *, i32);
print(bufout *, f64);
print(bufout *, vec3);
C++ has better null pointer semantics than C. Addition or subtraction of zero with a null
pointer produces a null pointer, and subtracting null pointers results in zero. This
eliminates some boneheaded special case checks required in C, though not all:
memcpy, for instance, arbitrarily still does not accept null pointers even in C++.
The static data problem is a real bummer, but perhaps it’s worth it for the other features.
I still need to put it all to the test in a real, sizable project.
https://nullprogram.com/blog/2024/04/14/ 8/8