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::vector, std::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 ofT— 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
A 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 (first, last, subspan).
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.