Data Structures · Arrays and Sequences
What a Slice Descriptor Holds and Why It Is Three Words
The pointer, length and capacity triple that makes a slice cheap to pass, how subslicing shares storage, and where the shared-buffer aliasing shows up.
A view of a contiguous range needs three facts to be useful: where it starts, how many elements it covers, and how far the underlying storage extends. Some languages store all three; some store only the first two and rely on the storage’s own bookkeeping for the third. The difference determines whether a view can be appended to in place, whether a runtime bounds check can be eliminated, and whether two views derived from one buffer can interfere with each other.
Two words or three
A Rust slice reference &[T] is a fat pointer: a machine word of address and a machine word of length,
16 bytes on x86-64. There is no capacity component, because a &[T] is a borrow of a range that already
exists; it cannot grow the storage it points into. Growing is the job of Vec<T>, which is a
three-word value: pointer, length, capacity. Slicing a Vec with a range produces a &[T] borrowing
the selected elements, and the length of that borrow is fixed for its lifetime.
Go’s slice value is a three-word struct — data pointer, length, capacity — passed by value. The
specification defines a slice as a descriptor for a contiguous segment of an underlying array and gives
the three-index slicing form that sets each of the three, which is what makes the capacity component
observable to a program rather than an implementation detail. A slice can therefore be passed to a
function and appended to inside that function with the effect visible only when the capacity sufficed. C++20’s
std::span<T> is a pointer and a length, 16 bytes on the same platform, and a std::span cannot be
grown either. Three languages, two designs, and the dividing line is whether the view owns the right to
extend into spare storage.
The fat pointer has a practical consequence: a Rust &[T] or a C++ std::span<T> parameter occupies two
words, and a Go slice occupies three. Passing a view is therefore never free in the way passing a bare
pointer is, though it is cheaper than passing a pointer and a length as separate arguments, because the
register allocator can spill the pair or triple as a unit. In Go on x86-64 a slice header stored in
memory is 24 bytes. C++ resolves it by making span a class with pointer and size members, which the ABI
passes in registers under the usual calling conventions.
Why capacity is separate from length
Length is what a bounds check compares against. Capacity is what append compares against to decide
whether it can write in place. Keeping them separate means a slice can describe a shorter window into a
larger allocation, and that window can be extended back toward the allocation’s end without allocating.
buf := make([]byte, 0, 8) // len 0, cap 8, one allocation
buf = append(buf, 'a', 'b') // len 2, cap 8, same array
head := buf[:1] // len 1, cap 8: shares storage
tail := buf[1:] // len 1, cap 7: starts one byte in
The cap of a slice produced by s[low:high] is cap(s) - low, not high - low. The slice starts at
low, and the storage available from that point to the end of the original allocation is the original
capacity minus the offset. This rule is what allows tail above to have capacity 7 while its length is
- It is also what makes repeated re-slicing safe: each derived slice’s capacity is computed from the parent’s, so the invariant “the described range lies within the allocation” is preserved by construction.
The three-index form s[low:high:max] sets the capacity to max - low, which caps how far a callee can
append before it must allocate. This is the mechanism that turns a slice into an enforced read-only
window for callers that might otherwise write past the intended boundary. Without it, a function
receiving s[0:2] from an 8-element buffer can append six elements and overwrite the caller’s data in
positions 2 through 7.
Aliasing and the write that goes sideways
Because slices share storage, two slices over the same array are not independent. Writing through one is visible through the other if their ranges overlap. This is not a defect; it is the definition of a view. It becomes a defect when a function copies a slice header into a local, appends, and expects the caller’s slice to change.
func fill(s []int) {
s = append(s, 99) // may reallocate; caller's header unchanged
s[0] = 7 // always visible: same backing array
}
func main() {
s := make([]int, 1, 4) // len 1, cap 4
fill(s)
// s[0] == 7, but len(s) is still 1: the append had room,
// so it wrote s[1] = 99 into the caller's array unseen.
}
Two facts produce the confusing outcome. The header is passed by value, so the callee’s len change
never reaches the caller. The backing array is shared, so the callee’s write does reach it. The result
is a caller whose slice has length 1 and an array whose second element is 99 — data written but not
reachable through the caller’s view. Rust’s borrow rules make the equivalent program a compile error:
appending requires &mut Vec, and a mutable borrow excludes all other borrows of the same storage for
its duration.
C++ std::span has the same sharing and none of the enforcement. Two spans over one std::vector can
be passed to two functions that write, and the type system permits it. The practical mitigation is
constraint on the API: take std::span<const T> when the function only reads, since a mutable
std::span<T> parameter documents that the callee may write the range.
Bounds checks and what removes them
A slice access with a runtime length check costs a compare and a predictable branch. On current cores
that is a small fraction of a cycle when the branch predictor gets it right, and the predictor does get
it right in loops that iterate to len(s). The check matters most when it prevents vectorization: a
compiler cannot widen a loop if it cannot prove every iteration is in range.
Compilers remove checks when the induction variable is provably bounded by the slice length, which is
why the idiom for i := 0; i < len(s); i++ { use(s[i]) } compiles to a loop with the check hoisted or
eliminated, while for i := 0; i < n; i++ { use(s[i]) } with n from elsewhere keeps it. In Rust the
same effect is achieved by iterating s.iter() rather than indexing, and the iterator’s length is tied
to the slice’s. Neither language removes the check by default for an index computed from arbitrary
arithmetic; the proof obligation is on the shape of the loop.
A slice’s length being a field rather than a terminator has a second effect: there is no sentinel scan. A C string function must read until a zero byte, so its cost depends on the data and its safety depends on a byte being present. A slice-based function receives the count, so a missing terminator is not possible and a buffer without one is not a special case. Traversal over the range then behaves as the linear address stream described in Sequential Scans, Hardware Prefetch, and the Page Walk.
Slicing, substrings, and the shared-buffer leak
The same descriptor pattern appears in string types that are views rather than owners. A substring that shares its parent’s buffer keeps the entire parent allocation alive: dropping the parent string object does not free the buffer if a substring still references it. A program that parses a 1 MiB document and retains a 20-byte field from it holds 1 MiB. The mitigation is to copy the small piece and drop the large one, which converts a shared view into an owned allocation at the cost of one copy.
Java’s String.substring shared the backing char[] through Java 6, so a small substring pinned the
whole parent array; from Java 7 update 6 the method always copies into a new array, trading the
allocation for predictable retention. The change is a good illustration that the descriptor design and
the retention policy are separable decisions: sharing is cheap at creation and expensive at retention,
copying is the reverse.
Length as an invariant rather than a convention
The reason a slice can be passed across a module boundary without a length argument is that the length
travels with the pointer. This is the property C arrays lack: void f(int *a, size_t n) requires the
caller to supply n correctly, and a caller that supplies the wrong n produces a function that reads
or writes out of range with no local evidence of the mistake.
The cost of carrying the length is that the descriptor must be updated consistently. In Go, append
returns a new header precisely because the callee cannot mutate the caller’s copy; forgetting to assign
the result is a compile error, since append’s result is used and discarded. In Rust, Vec::push takes
&mut self, so the update is enforced by the borrow. In C++, std::vector::push_back mutates the object
in place, so no assignment is needed and no enforcement is needed either.
Resizing the descriptor also means the address can change. A slice header’s pointer is not stable across a reallocation of the underlying storage, so any raw address derived from it must be recomputed. The allocation behaviour behind that reallocation is covered in Growth Policies in Dynamic Arrays; the point here is that the descriptor is a snapshot, not a live reference to the container.
References
- The Go Programming Language Specification — Slice types — The Go Authors
- The Rust Reference — Slice types and pointer metadata — The Rust Project
- C++ standard, [views.span] — ISO/IEC 14882 draft