Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

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::span parameter accepts vector, 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-extent std::span<int, 3> is 8, with the size in the type.
  • first, last, and subspan produce 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_bytes reinterprets any span as bytes, for serialisation and C APIs.
  • std::string_view is the same idea for text, with the same lifetime rules.

Questions about this lesson

Log in to ask a question.