Skip to content

Array

Array<T> turns a block of storage into a list you can append to. It does not allocate, it does not own its memory, and it never grows. You give it a buffer and a capacity; it tracks how much of that buffer is in use.

A Span<T> says: here are N elements. An Array<T> says: here is room for N elements, and M of them are live.

Storage and occupancy are two different facts, and most code needs both.

When you allocate room for 512 draw commands, you do not have 512 draw commands. You have room for 512 and, at this moment, 37 real ones. A Span<T> cannot express that, because its size is its only number. Code built on a raw span therefore carries a separate count variable beside it, and must keep the two in step by hand.

Array<T> holds both numbers in one value and maintains the invariant count <= capacity.

// Two facts, two variables, kept in step by hand.
Span<DrawCommand> storage = arena.Push<DrawCommand>(512);
usize count = 0;
if (count < storage.size)
{
storage[count] = command;
count += 1;
}
// One value that knows both.
Array<DrawCommand> commands =
Array<DrawCommand>::FromSpan(arena.Push<DrawCommand>(512));
commands.Append(command);

Array<T> never allocates and never frees. The allocation happens at the call site, where it is visible:

Array<Token> tokens = Array<Token>::FromSpan(arena.Push<Token>(1024));

The array’s lifetime is bounded by its storage. If the storage came from an arena, rewinding that arena invalidates the array. If it came from a stack buffer, the array dies with the frame.

Because the array holds no allocator, it works over any storage: an arena push, a stack array, a fixed field inside a struct, a sub-range of a larger buffer. A reader of the call site also sees where the memory came from, without opening the array’s implementation.

An append to a full array is a programming error, not an event the array handles by allocating. There is no growth factor, no reallocation, and no hidden allocator call. Two consequences follow:

  • Pointers and spans into an array stay valid. A container that grows by reallocating invalidates every pointer into it. Array<T> never reallocates, so you may hold a T* into it, or hand out a ToSpan() and keep appending.
  • No append can allocate. So no append can fail for lack of memory, and none takes an unpredictable amount of time. Append costs the same every time.

The cost: you must know a bound on the element count before you allocate. When you genuinely do not, Array<T> is the wrong type — see Choosing a sequence type.

Each mutating operation comes in a strict form and a checked form. Which you use depends on whether a full array is a bug in your code or an expected condition.

Form On success When full Use it when
Append(value) Adds the element Asserts — this is a bug You sized the storage, and overflow means you got it wrong
TryAppend(value) Returns a pointer to the slot Returns nullptr Input decides the count, and a full buffer is a normal outcome

The batch forms follow the same split. AppendSpan asserts; TryAppendSpan returns false and adds nothing.

Pop removes the last element and returns it. Order is preserved, since nothing else moves.

RemoveSwap removes an element at any index by moving the last element into its place. It runs in constant time and does not preserve order.

// Remove every dead entity in a single pass.
for (usize i = 0; i < entities.size(); )
{
if (entities[i].is_dead)
{
entities.RemoveSwap(i); // do not advance: a new element now sits at i
}
else
{
i += 1;
}
}

There is no order-preserving remove. It would shift every following element, and for a bag of things where position carries no meaning, that cost buys nothing. When order does matter, sort at the point where order is needed.

You have You want Use
A known count, filled once A range to read or write Span<T>
A known bound, filled incrementally Append, then read Array<T>
No bound, built once then read Append without a ceiling ChunkList<T>
No bound, needs one contiguous block Append, index, grow to gigabytes VirtualArray<T>

Choosing a container works through the question behind that table — who owns the storage — and covers the maps as well.

Array<T> accepts trivially-copyable element types only, checked at compile time. It moves elements with a block copy and destroys nothing, which is what makes RemoveSwap and Clear constant-time operations.

Array<const T> does not compile: a read-only view is Span<const T>.

Parameters, assertions and invariants are documented in basalt/core/Array.h.

Signature Purpose
Array<T>::FromSpan(storage) Wraps a span as an empty array whose capacity is the span’s size.
Signature Purpose
Append(value) Adds one element. Asserts when full.
TryAppend(value) Adds one element. Returns the slot, or nullptr when full.
AppendSpan(values) Adds a block of elements. Asserts when they do not fit.
TryAppendSpan(values) Adds a block of elements, or nothing. Returns whether it fit.
Signature Purpose
operator[](index) Element access. Asserts on an out-of-range index.
ToSpan() A span over the live elements only.
size() The live element count.
IsEmpty() / IsFull() Occupancy tests.
GetRemainingCapacity() Free slots left.
begin() / end() Range-for support over the live elements.
Signature Purpose
Pop() Removes and returns the last element. Asserts when empty.
RemoveSwap(index) Removes an element in constant time. Does not preserve order.
Clear() Sets the live count to zero. Keeps the storage.
Field Purpose
data The borrowed storage. Never owned, never freed by the array.
count Live elements. Always <= capacity.
capacity Fixed when the array is built. Never grows.