Span
Span<T> is a pointer and a count: {T* data; usize size;}, and nothing else. It views a contiguous range of elements it does not own, does not allocate and never frees. Almost every Basalt call that takes or returns a run of elements spells it this way.
The read-only span is Span<const T>. A const Span<T> is a different thing — a span you cannot reseat, whose elements you can still write.
A span points at storage something else owns
Section titled “A span points at storage something else owns”Nothing about a span says where its bytes came from or how long they live. That is the caller’s knowledge, and the span carries none of it.
| The span you hold | Comes from | Valid until |
|---|---|---|
Span<T> |
arena.Push<T>(count) |
The arena is rewound past it, cleared or released |
Span<T> / Span<const T> |
Array<T>::ToSpan(), VirtualArray<T>::ToSpan() |
The backing storage changes or goes away |
Span<V> |
DenseHashMap<K, V>::GetValues() |
The next insert or erase |
Span<T> |
ChunkListFlatten(arena, list), HashTrieFlatten(arena, trie) |
The destination arena is rewound past it |
Span<T> |
VirtualArray<T>::AppendUninitialized(count) |
The array is cleared or released |
So the ordinary lifetime question is about the owner, and a span answers none of it for you. The arena answer is usually the short one: a span over arena memory stays valid until you rewind past it, because later pushes never move earlier data.
An empty span is also the “it failed” answer
Section titled “An empty span is also the “it failed” answer”Span<T>() is data == nullptr, size == 0, and it is what every span-returning allocation hands back when it cannot get memory: Arena::Push<T>, ChunkListFlatten, HashTrieFlatten, and VirtualArray<T>::AppendUninitialized and its Try form.
const Span<Token> tokens = arena.Push<Token>(count);if (tokens.size == 0){ return Error(ParseError::OutOfMemory);}Arena::Push reports failure as an empty span rather than as a Result — the allocation path is far too hot to pay for one per call, so it asserts in a debug build and hands back an empty span in release. Where you cannot assume the allocation succeeded, size is the check. Where you can, a zero count is also what a zero-length request gives you, and iterating either is a no-op.
const protects the handle; the element type carries the promise
Section titled “const protects the handle; the element type carries the promise”A const Span<T> is the analogue of T* const: data and size cannot be reseated, and the elements stay writable. It is not the analogue of const T*.
That is why every accessor on Span is const and returns DataT& — indexing a span reseats nothing. A range-for over a const Span<f32> yields writable f32&.
void Scale(const Span<f32> values, const f32 factor) // const: this never reseats the view{ for (f32& value : values) // ... and writes through it, which is the point { value *= factor; }}The read-only role lives one level down, in the element type:
// Does not do what it looks like:// the caller's elements are writable// through this parameter.void Sum(const Span<f32> values);// Read-only, and the promise is visible// to every holder of the type.void Sum(Span<const f32> values);One keyword therefore means one thing, and const Span<T> is simply the normal spelling of an output span — which matters because Basalt’s conventions ask for const on any by-value parameter a function does not mutate.
A read-only span also buys reach a writable one cannot have: Span<const T> can view a string literal, a constexpr array, or a field of a const struct. Span<T> cannot, and making it do so would need the const_cast the codebase forbids.
Span<T> converts to Span<const T>, once and one way
Section titled “Span<T> converts to Span<const T>, once and one way”Pass a Span<f32> where a Span<const f32> is expected and it binds with no cast at the call site.
const Span<f32> input = arena.Push<f32>(count);const Span<f32> output = arena.Push<f32>(count);// ...Sin(input, output); // Sin takes (Span<const f32>, Span<f32>)The converting constructor is constrained to exactly this: the target element type is the source’s plus a top-level const. That is std::span’s rule, and its narrowness is load-bearing. An unconstrained version would also accept a Span<Derived> as a Span<Base>, which keeps the derived stride while reading base-sized elements and returns rubbish from the second element on.
The reverse does not exist. Adding const widens a promise; removing it is a const_cast under another name, and Span<const T> would stop being worth having if it could be downgraded silently.
What the type does, and what it does not
Section titled “What the type does, and what it does not”The whole member surface is the two fields, three constructors, operator[], begin and end. Every member is constexpr, so a span over a constexpr array is usable during constant evaluation — which is what makes the constexpr search functions in Sort.h reachable rather than decorative.
There is no Subspan, First, Last, Front, Back or IsEmpty. A sub-range is the two-argument constructor:
const Span<u8> body(header.data + header_size, header.size - header_size);Nothing is bounds-checked, in debug or in release. An index past size reads or writes outside the range, and the two-argument constructor takes the pointer and the count on trust — exactly as assigning the two fields would.
Operations over spans are free functions rather than members: Sin(Span<const f32>, Span<f32>), TransformPoint(const Mat4&, Span<const Vec3>, Span<Vec3>), FileRead(file, offset, Span<u8>), Array<T>::AppendSpan. The type stays a dependency-free pair of fields, and any module — including yours — adds operations without touching it.
String8 is next to Span<const u8>, not built on it
Section titled “String8 is next to Span<const u8>, not built on it”String8 is {const u8* data; usize size;} plus one claim the span does not make: the bytes are UTF-8. Structurally the two are the same shape, and they are still distinct types.
Reach for String8 when the bytes are text you will search, slice or compare; reach for Span<const u8> when they are bytes.
When a span is the wrong tool
Section titled “When a span is the wrong tool”| Situation | What to reach for |
|---|---|
| The range must own its storage | Array<T> over a buffer you supply, or VirtualArray<T> |
| The range must grow | VirtualArray<T>, or ChunkList<T> when the size is unknown |
| The range must outlive the arena it is in | Push a copy into a longer-lived arena |
| The elements are not contiguous | ChunkList<T>, flattened into a span when you need one |
| You want a read-only container | Not Array<const T>, which does not compile. Span<const T> is the read-only view |
| Bounds checking | Not this type. Check size yourself, or index through a container that asserts |
API summary
Section titled “API summary”Parameter contracts and the conversion constraint are documented in basalt/core/Span.h.
| Member | Purpose |
|---|---|
data |
First element. Null only for an empty span. |
size |
Number of elements, not bytes. |
Span() |
The empty span: null and zero. Also the “allocation failed” value from Arena::Push. |
Span(data, size) |
Views size elements from data. Nothing is checked. |
Span(const Span<U>&) |
Implicit Span<T> → Span<const T>. Constrained to adding a top-level const. |
operator[](index) |
The element at index, writable unless the element type is const. Unchecked. |
begin() / end() |
Range-for support. Equal to each other when the span is empty. |