The problem: functions that take too much ownership
A function that processes a buffer often looks like this:
1void process(const std::vector<float>& data);
This works — but it’s over-constrained. The caller must have a std::vector.
An array, a ring buffer’s internal storage, a DMA buffer on the stack — none of
these can be passed without a copy or a reinterpret.
Or the raw-pointer alternative:
1void process(const float* data, size_t len);
This works with any contiguous storage — but now you’re back to raw pointers with
no bounds information baked into the type, and nothing stops the caller from passing
a wrong len.
std::span<const float> is the right tool: a non-owning view over any contiguous
sequence of floats, with size embedded in the type, zero overhead, and bounds
checking in debug builds.
std::span — a view over contiguous data
std::span<T> is a pair of (pointer, length). It’s non-owning — it doesn’t
allocate or copy. It’s a window into existing memory.
1#include <span>
2
3void process(std::span<const float> data) {
4 for (float v : data) { // range-for works
5 handle(v);
6 }
7 float first = data[0]; // operator[] with bounds check in debug
8 size_t n = data.size();
9 const float* ptr = data.data(); // raw pointer escape if needed
10}
The function now accepts any contiguous source:
1std::vector<float> vec = {1, 2, 3};
2process(vec); // implicit conversion
3
4float arr[] = {1, 2, 3};
5process(arr); // works
6
7std::array<float, 3> a = {1, 2, 3};
8process(a); // works
9
10float dma_buf[DMA_SIZE];
11process(std::span(dma_buf, DMA_SIZE)); // DMA buffer — no copy
One function signature — all contiguous storage.
Static vs dynamic extent
1std::span<float> // dynamic extent — size stored at runtime
2std::span<float, 8> // static extent — size known at compile time, sizeof = one pointer
Use static extent when the size is always fixed — it removes the stored length and makes the span cheaper to pass.
1void processAdc(std::span<const uint16_t, 4> channels) {
2 // Always exactly 4 channels — enforced at compile time
3}
4
5std::array<uint16_t, 4> buf = {0};
6processAdc(buf); // OK — sizes match
Subspans
1std::span<float> full = buf;
2auto first_half = full.subspan(0, full.size() / 2);
3auto last_half = full.subspan(full.size() / 2);
4auto first_three = full.first(3);
5auto last_three = full.last(3);
Useful for splitting a DMA double-buffer without pointer arithmetic:
1void processHalf(std::span<const uint16_t> half);
2
3// In DMA callback:
4std::span<const uint16_t> buf(dmaBuf, DMA_BUF_SIZE);
5processHalf(buf.first(DMA_BUF_SIZE / 2)); // HT callback
6processHalf(buf.last (DMA_BUF_SIZE / 2)); // TC callback
std::string_view — a view over character data
std::string_view is std::span<const char> with string-specific operations:
find, substr, starts_with, ends_with, etc.
1#include <string_view>
2
3void log(std::string_view msg) {
4 uart_send(msg.data(), msg.size());
5}
6
7log("boot complete"); // string literal — no allocation
8log(std::string("hello")); // std::string — no copy
9log(buf.data()); // char array — no copy
Replacing const std::string&
The classic rule: replace const std::string& parameters with std::string_view:
1// Before: forces caller to have a std::string
2void setName(const std::string& name);
3
4// After: accepts anything
5void setName(std::string_view name);
The only exception: if the function stores the string (into a member, a map, etc.),
it must copy into a std::string anyway — the parameter type doesn’t matter much
in that case, though string_view is still more general.
Safe substring without allocation
1std::string_view parse(std::string_view input) {
2 auto pos = input.find(':');
3 if (pos == std::string_view::npos) return input;
4 return input.substr(pos + 1); // zero-copy substring
5}
6
7std::string_view value = parse("key:value");
8// value points into the original string — no allocation
The lifetime trap
Both span and string_view are non-owning. They must not outlive the data
they point to. This is the primary footgun.
1std::string_view danger() {
2 std::string s = "hello";
3 return s; // UB — s is destroyed, view dangling
4}
5
6std::span<int> also_danger() {
7 int arr[] = {1, 2, 3};
8 return arr; // UB — arr is destroyed on return
9}
The rule: span and string_view should be function parameters and local
variables, not return types or class members (unless the lifetime is explicitly
managed and documented).
1// OK — span lives within the scope of the buffer
2void process() {
3 uint8_t buf[64];
4 fill(buf);
5 auto view = std::span(buf); // same scope — safe
6 analyze(view);
7}
8
9// WRONG — member view into a temporary
10class Parser {
11 std::string_view view_; // dangerous — what does it point to?
12};
Embedded use cases
Parsing protocol frames without copying:
1struct Frame {
2 uint8_t cmd;
3 std::span<const uint8_t> payload;
4};
5
6Frame parseFrame(std::span<const uint8_t> raw) {
7 return { raw[0], raw.subspan(1) };
8 // payload points into raw — zero copy
9}
Writing to a pre-allocated output buffer:
1size_t serialize(const SensorData& d, std::span<uint8_t> out) {
2 if (out.size() < sizeof(SensorData)) return 0;
3 std::memcpy(out.data(), &d, sizeof(d));
4 return sizeof(d);
5}
Uniform handling of flash strings and RAM strings:
1void sendResponse(std::string_view msg) {
2 for (char c : msg) uart_putc(c);
3}
4
5sendResponse("OK\r\n"); // literal in flash
6sendResponse(buildError(code)); // built in RAM
When NOT to use them
| Situation | Use instead |
|---|---|
| Storing the string beyond the call | std::string |
| Non-contiguous data (list, deque) | Range / iterator pair |
| Owning a buffer | std::vector or std::array |
| Passing ownership | unique_ptr or move semantics |
| Null-terminated string required by C API | const char* (from .data()) |
Summary
std::span<const T>replacesconst std::vector<T>&and(const T*, size_t)for contiguous read-only datastd::string_viewreplacesconst std::string&for any read-only string- Both are zero-overhead — one pointer and one size, passed in registers
- Both are non-owning — never store them as members without managing lifetime
- Static extent
span<T, N>eliminates the size field entirely when N is fixed
What’s next
- Container selection guide — when vector, array, deque, or list fits best
- Move semantics and zero-copy — ownership transfer without span
- Lock-free queues — passing spans between producer and consumer