#076 – Using std::span for contiguous memory views

C++ inherited an ugly tradition from C: when a function needs to operate on a sequence of values, it takes a pointer and a size as separate parameters. The pointer is typed; the size is just a number; nothing in the language ties them together; and the caller has to remember to pass them consistently. Half the buffer-overflow bugs in C-derived codebases sit at exactly this interface.

C++20’s std::span is the type-safe replacement: a single object that bundles a pointer and a size, accepts any contiguous sequence (std::vectorstd::array, raw arrays, pointer + size pairs), and adds compile-time bounds-checking helpers without owning the underlying memory.

Three things to take away:

  • std::span<T> is a non-owning view over a contiguous range of T — pointer plus size, no allocation, no lifetime extension.
  • Use it for function parameters that should accept any contiguous sequence; it replaces the C-style pointer/size pair and the over-specific const std::vector<T>&.
  • The dynamic-extent form (std::span<T>) stores a runtime size; the static-extent form (std::span<T, N>) encodes the size in the type and is one pointer wide.

The problem std::span solves

A function that processes a sequence of integers has, until C++20, three unsatisfying ways to take its argument:

// 1. C-style pointer/size pair — error-prone, decoupled
void process(int* data, std::size_t size);

// 2. Reference to a specific container — forces the caller's choice
void process(const std::vector<int>& data);

// 3. Templated to accept any range — works, but every call site
//    instantiates a new function, and the API loses its type
template <typename Range>
void process(const Range& data);

The pointer/size form is what most C interfaces expose — and the one where a single mismatched call leads to memory corruption. The vector form forces the caller to either be holding a vector or copy their data into one. The template form works but pushes the call into the header, deduces a different function for every container type, and gives the reader no information about the parameter’s structural requirements.

std::span is the fix:

#include <span>
void process(std::span<int> data);

int rawArr[5]                 = { 1, 2, 3, 4, 5 };
std::array<int, 5> stdArr     = { 1, 2, 3, 4, 5 };
std::vector<int>   vec        = { 1, 2, 3, 4, 5 };

process(rawArr);              // OK — array decays to span
process(stdArr);              // OK — std::array converts to span
process(vec);                 // OK — vector converts to span
process({ rawArr, 3 });       // OK — pointer + size, first three only

One signature, four call shapes, all of them safe. The size travels with the pointer, the type system enforces contiguousness, and the function body uses range-based-for and size() like any modern container.

How it’s built

std::span is essentially a pointer-size pair:

template <class T, std::size_t Extent = std::dynamic_extent>
class span {
    T*          m_ptr;
    std::size_t m_size;   // present only when Extent == dynamic_extent
    // ...
};

Internally it carries a pointer and (for the dynamic-extent case) a size. There is no allocation, no copy, no reference counting — constructing a span from a vector is essentially free. The interface is the contiguous-container interface: size()empty()data()operator[]front()back()begin()end(), plus three slicing operations (firstlastsubspan).

Mutability follows from the element type:

void readOnly(std::span<const int> data);   // cannot modify elements
void readWrite(std::span<int>      data);   // can modify elements

This is the practical difference between std::span and std::string_view: a string_view is read-only by design, because string literals are read-only. A span is read-write unless you ask for const elements. For passing buffers to functions that fill them — networking reads, file I/O, compute kernels — std::span<std::byte> is the canonical modern shape.

Static and dynamic extent

The second template parameter controls whether the size is known at compile time:

int arr[] = { 1, 2, 3, 4, 5 };

std::span<int>          dyn { arr };   // dynamic extent: size at runtime
std::span<int, 5>       sta { arr };   // static extent: size in the type

static_assert(sizeof(dyn) == 16);      // pointer + size_t (typically)
static_assert(sizeof(sta) == 8);       // pointer only — size is in the type

Static-extent spans are smaller (no need to store the size separately) and let the compiler verify size-related constraints at compile time. Dynamic-extent spans are more flexible — they accept inputs of any size and are the right default for function parameters that don’t have a fixed-size requirement.

A static-extent span converts to a dynamic-extent span freely; the reverse is not allowed without an explicit subspan or construction call, since narrowing a dynamic size to a fixed one is potentially lossy.

What std::span does not own

The most important property of std::span is the one in its job description: it does not own anything. The lifetime of the underlying storage is the caller’s problem. This makes spans cheap to copy and pass around — but it means a span that outlives its source dangles, with all the consequences dangling pointers usually have.

The most common bug is binding a span to a temporary:

std::vector<int> getData();

std::span<int> s = getData();   // BUG: temporary destroyed at end of expression
for (int x : s) { ... }         // span now dangles — undefined behaviour

The temporary vector returned by getData() is destroyed at the end of the full expression. The span captured a pointer into its now-deallocated buffer. The loop reads freed memory.

The rule is simple: a span is safe for the lifetime of the range it views, and not one statement longer. Pass spans into functions; do not store them in members or return them from functions whose locals own the data they reference.

Slicing without copying

A frequent C-style pattern is “process this sub-range” — typically with another pair of pointers, or an offset and length. std::span does this with three named operations, all of which produce a new span without copying:

std::span<int> data{ vec };

auto head = data.first(3);          // first 3 elements
auto tail = data.last(2);           // last 2 elements
auto mid  = data.subspan(2, 4);     // 4 elements starting at index 2

Each result is a span over the same underlying storage as data; constructing one is one pointer arithmetic operation and a size assignment. Compare to std::vector::insert slicing or std::string::substr — both of which copy. Span slicing is closer to pointer arithmetic in cost, but the result is type-safe and bounds-aware.

Replacing the C-style pair, in practice

The pattern below is the single highest-leverage refactor std::span enables. Wherever you see a pointer/size pair, collapse it:

// Before: error-prone C-style API
void hash_buffer(const std::byte* data, std::size_t size);

// After: same shape, single argument, type carries the size
void hash_buffer(std::span<const std::byte> data);

Callers can pass vectors, arrays, raw buffers, sub-ranges of all of the above, and the function body can read data.size() without trusting the caller to have passed a correct count. For a public API, this single change moves a class of buffer-bounds bugs from “possible” to “structurally prevented.”

Takeaway

std::span is the modern C++ replacement for the C-style “pointer plus size” parameter. It views any contiguous range without owning it, accepts vectors, arrays, raw arrays, and explicit pointer/size constructions through one signature, and moves the size into the type system where the compiler can help enforce it.

Keep spans short-lived — they don’t extend lifetimes — and prefer std::span<const T> for read-only views. The rule is simple: anywhere your C++ function would have taken a pointer and a size, take a std::span instead.