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> replaces const std::vector<T>& and (const T*, size_t) for contiguous read-only data
  • std::string_view replaces const 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