Lesson 11 of 12
Lesson 7.5 showed the problem: passing an array to a function decays it to a pointer, and the length is gone. The traditional fix is to pass the length alongside, and hope the caller passes the right one.
std::span<T> is a pointer and a length, travelling together.
One function, every contiguous source
#include <array>
#include <iostream>
#include <span>
#include <vector>
// One function, every contiguous source — and the length travels with it.
int sum(std::span<const int> values) {
int total = 0;
for (int v : values) {
total += v;
}
return total;
}
void scale(std::span<int> values, int factor) {
for (int& v : values) {
v *= factor;
}
}
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
std::array<int, 3> arr = {10, 20, 30};
int raw[] = {100, 200};
std::cout << "vector: " << sum(vec) << "\n";
std::cout << "array: " << sum(arr) << "\n";
std::cout << "raw: " << sum(raw) << "\n";
// A sub-range, with no copying.
std::cout << "first 3 of vector: " << sum(std::span(vec).first(3)) << "\n";
std::cout << "last 2 of vector: " << sum(std::span(vec).last(2)) << "\n";
std::cout << "middle: " << sum(std::span(vec).subspan(1, 3)) << "\n";
// A span is a VIEW: writing through it writes the original.
scale(vec, 10);
std::cout << "after scale: ";
for (int v : vec) std::cout << v << " ";
std::cout << "\n";
std::span<int> s(vec);
std::cout << "size " << s.size() << ", size_bytes " << s.size_bytes()
<< ", empty " << std::boolalpha << s.empty() << "\n";
std::cout << "sizeof(std::span<int>) = " << sizeof(std::span<int>) << "\n";
// A fixed-extent span knows its size in the type.
std::span<int, 3> fixed(arr);
std::cout << "fixed extent sizeof = " << sizeof(fixed) << "\n";
return 0;
}
vector: 15
array: 60
raw: 300
first 3 of vector: 6
last 2 of vector: 9
middle: 9
after scale: 10 20 30 40 50
size 5, size_bytes 20, empty false
sizeof(std::span<int>) = 16
fixed extent sizeof = 8
sum accepts a std::vector, a std::array, and a raw array with no overloads and no template.
The conversion is implicit, so callers write nothing.
What it replaces
// int sum(const int* data, std::size_t count); // two arguments that can disagree
// int sum(const std::vector<int>& v); // rejects arrays
// template <typename C> int sum(const C& c); // accepts anything, including a list
The first is the C way and the bug is obvious once stated: nothing connects data to count. Pass
a length that is too large and you read past the end — lesson 11.3's heap-buffer-overflow.
The second forces every caller to own a std::vector, which means copying data that was already
contiguous.
The third compiles for a std::list, then fails deep inside the body when something needs
contiguity. A std::span parameter says "contiguous, and I will not resize it" in the signature.
const in the right place
Two different things, and the distinction matters:
// std::span<const int> — cannot modify the elements
// const std::span<int> — cannot repoint the span, but CAN modify the elements
Take std::span<const int> when the function only reads. It accepts const containers, documents
the intent, and lets the compiler check it. scale above takes std::span<int> precisely because
it writes.
This is the same distinction as const int* versus int* const from lesson 7.4.
Fixed extent
std::span<T, N> fixes the length at compile time:
// std::span<int> 16 bytes: a pointer and a size
// std::span<int, 3> 8 bytes: a pointer; the size is in the type
The output above confirms both. A fixed-extent span is a compile-time promise about the length, and it costs nothing to carry. Use it when a function genuinely requires exactly N elements — a 3D vector, an RGB triple, a fixed-size header — and the compiler will reject a wrongly-sized argument at the call site.
The dynamic extent is the default and the common case.
The trap: a span does not own anything
A span is a view (lesson 13.9), so everything true of a dangling reference is true of it:
#include <iostream>
#include <span>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
std::span<int> s(v);
std::cout << "before: " << s[0] << " size " << s.size() << "\n";
v.push_back(4); // reallocates: the span now points at freed memory
std::cout << "vector size is now " << v.size() << "\n";
std::cout << "span still reports size " << s.size() << "\n";
std::cout << "reading s[0]: " << s[0] << "\n"; // use-after-free
return 0;
}
Built with -fsanitize=address, that program stops at the read:
==2815==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
#0 ... in main span_dangle.cpp:14
0x502000000010 is located 0 bytes inside of 12-byte region [0x502000000010,0x50200000001c)
Two failures in one, and both are silent without a sanitizer.
The pointer dangles. push_back reallocated, freeing the block the span points into. This is
lesson 13.1's iterator invalidation — a span holds a pointer, so it invalidates identically.
The length is stale. The span still reports 3 while the vector holds 4. A span records the length at construction and never learns about changes.
The rules that follow:
- Never store a span as a class member unless you control the source's lifetime.
- Never return a span to a local.
- Never hold a span across an operation that can reallocate the container.
- Pass spans down the call stack, not up or sideways.
That last one is the practical summary: a span is an excellent parameter type and a poor member or return type.
Where it earns its place
Function parameters over contiguous data. This is 90% of its use, and it is the right default for any function reading a buffer.
Sub-ranges without copying. first(n), last(n), and subspan(offset, count) produce a new
span over the same memory. Splitting a packet into a header and a payload costs nothing.
Bridging to C APIs. s.data() and s.size() are exactly what a C function wants, and
std::as_bytes(s) reinterprets any span as std::span<const std::byte> for serialisation.
Reducing template bloat. Lesson 12.5 measured a separate instantiation per array size. A
std::span<const int> parameter collapses all of them into one function.
std::string_view is the same idea for text, and predates it by one standard — everything in this
lesson about lifetimes applies to it identically.
Try it
Write a fixed-size packet parser: given a std::span<const std::byte>, split it into a 4-byte
header and the rest, and return a struct of parsed fields plus a span over the payload. Then
demonstrate the dangling hazard by returning a span over a local buffer, and describe what ASan
reports.
Checkpoint
std::span<T>is a pointer plus a length, so the size cannot get separated from the data.- One
std::spanparameter acceptsvector,array, and raw arrays with no overloads. std::span<const int>forbids writing elements;const std::span<int>forbids repointing.sizeof(std::span<int>)is 16; a fixed-extentstd::span<int, 3>is 8, with the size in the type.first,last, andsubspanproduce sub-ranges with no copying.- A span does not own — reallocating the container leaves it dangling and the wrong length.
- ASan reports a stale span as
heap-use-after-free, the same as an invalidated iterator. - Pass spans down the call stack; do not return them or store them as members.
operator[]is unchecked — a span knows its length but does not enforce it.std::as_bytesreinterprets any span as bytes, for serialisation and C APIs.std::string_viewis the same idea for text, with the same lifetime rules.
Questions about this lesson
Log in to ask a question.