CodeRigor

FREE LESSON

std::vector, std::span and std::string_view

Own sequences with std::vector, pass them around with std::span and std::string_view, and never let a view outlive what it looks at.

Three types cover most of the sequence handling in modern C++:

Type Owns its elements? Typical use
std::vector<T> yes — allocates, copies, frees storing data, returning results
std::span<T> / std::span<const T> no — pointer + length parameters: "give me some contiguous Ts"
std::string_view no — pointer + length parameters: "give me some characters"

The rule of thumb: own with vector/string, borrow with span/string_view. A view is only as valid as the thing it points into — that one sentence explains almost every bug in this lesson.

Own with vector, borrow with span
The vector owns the heap buffer. A span is just a pointer and a length into the same elements: copying it copies two words, not the data.
Own with vector, borrow with span
The vector owns the heap buffer. A span is just a pointer and a length into the same elements: copying it copies two words, not the data.

1. std::vector as a user

Construction: parentheses vs braces

Braces prefer the std::initializer_list constructor whenever it can be used:

C++
#include <print>
#include <string>
#include <vector>

int main() {
    std::vector<int> a(3, 1);   // (count, value): three 1s
    std::vector<int> b{3, 1};   // initializer list: the two elements 3 and 1
    std::println("{} {}", a, b);

    // If the braced values can't form an initializer_list<T>, braces fall back
    // to the other constructors: 3 isn't a std::string, so this is (count, value).
    std::vector<std::string> c{3, "x"};
    std::println("{}", c);
}

Use parentheses when you mean "count", braces when you mean "these elements".

Size, capacity, reserve

size() is how many elements exist; capacity() is how many fit before the vector must reallocate. reserve(n) raises capacity without creating elements; resize(n) creates (or destroys) elements.

reserve(8) vs resize(8)
reserve only raises capacity: still no elements, and v[0] is out of range. resize creates eight value-initialised ints.
reserve(8) vs resize(8)
reserve only raises capacity: still no elements, and v[0] is out of range. resize creates eight value-initialised ints.
C++
#include <print>
#include <vector>

int main() {
    std::vector<int> v;
    v.reserve(100);
    std::println("size {} capacity>=100 {}", v.size(), v.capacity() >= 100);
    v.resize(3);
    std::println("size {} contents {}", v.size(), v);
}

Exact capacities after growth are implementation-defined — don't write code that depends on them.

[] vs at

v[i] doesn't check i; an out-of-range index is undefined behaviour. v.at(i) throws std::out_of_range. Most code uses [] with indices that are correct by construction (loop bounds), and builds with library assertions (-D_GLIBCXX_ASSERTIONS, or C++26's hardened standard library mode) during development to turn mistakes into immediate, loud failures.

Removing elements: std::erase_if

Erasing while iterating is a classic trap (see §4). Since C++20 there's a one-liner that does it right, in O(n):

C++
#include <print>
#include <vector>

int main() {
    std::vector<int> v{3, -1, -2, 4, -5};
    auto removed = std::erase_if(v, [](int x) { return x < 0; });
    std::println("removed {}, left {}", removed, v);
    std::erase(v, 4);   // remove all elements equal to 4
    std::println("{}", v);
}

2. std::span: borrowing contiguous elements

A function that only reads a sequence shouldn't care whether it lives in a std::vector, a std::array or a C array. std::span<const T> accepts all of them, without copying:

C++
#include <array>
#include <print>
#include <span>
#include <vector>

long long sum(std::span<const int> xs) {
    long long s = 0;
    for (int x : xs) s += x;
    return s;
}

int main() {
    std::vector<int> v{1, 2, 3};
    std::array<int, 2> a{10, 20};
    int c[] = {100, 200, 300};
    std::println("{} {} {}", sum(v), sum(a), sum(c));
    std::println("{}", sum(std::span(v).subspan(1)));   // a view of a view: {2, 3}
}
  • std::span<const int> — read-only view. Accepts const and non-const containers.
  • std::span<int> — read-write view. Can't be made from a const container:
C++
#include <span>
#include <vector>

void zero(std::span<int> xs) { for (int& x : xs) x = 0; }

int main() {
    const std::vector<int> v{1, 2, 3};
    zero(v);   // error: a span<int> would let zero() modify a const vector
}

Slicing is O(1): first(n), last(n), subspan(offset, count) return new spans over the same elements. Note subspan(1, 3) means offset 1, three elements — not [1, 3).

Slices share the elements
Each slice is a new (pointer, length) over the same six ints — O(1), no copies. subspan(1, 3) is offset 1, count 3.
Slices share the elements
Each slice is a new (pointer, length) over the same six ints — O(1), no copies. subspan(1, 3) is offset 1, count 3.

Static vs dynamic extent. std::span<int> has its size at run time; std::span<int, 3> has it in the type. Building a fixed-extent span from a vector (whose size isn't known at compile time) needs an explicit conversion, and it's your job to make sure the size matches.

Bounds. s[i] is unchecked like vector::operator[]; C++26 adds s.at(i), which throws.

3. std::string_view: borrowing characters

std::string_view is a span for characters, with the string API you'd expect (substr, find, starts_with, comparison...). Taking std::string_view parameters means callers can pass a std::string, a string literal, or a slice of something else — without allocating:

C++
#include <print>
#include <string>
#include <string_view>

bool is_comment(std::string_view line) {
    while (!line.empty() && line.front() == ' ') line.remove_prefix(1);
    return line.starts_with("//");
}

int main() {
    std::string owned = "   // hello";
    std::println("{} {} {}", is_comment(owned), is_comment("int x;"), is_comment("//"));
    std::string_view whole = "key=value";
    auto eq = whole.find('=');
    std::println("[{}] [{}]", whole.substr(0, eq), whole.substr(eq + 1));   // no copies
}

Two traps:

  • Not null-terminated. sv.data() points at the first character, but there may be no '\0' after the last. Never pass sv.data() to a C API that expects a C string; make a std::string(sv) first.
  • Dangling. A string_view into a temporary dies with the temporary:
C++
#include <string>
#include <string_view>

std::string make() { return "a fairly long string, longer than SSO"; }

int main() {
    std::string_view sv = make();   // the std::string is destroyed at the ;
    return static_cast<int>(sv.size());   // size is fine, but reading sv[0] would be UB
}
std::string_view sv = make();
start / 1
The view keeps a pointer and a length — not the string. When the temporary dies at the ;, its heap buffer is freed under the view.
std::string_view sv = make();
The view keeps a pointer and a length — not the string. When the temporary dies at the ;, its heap buffer is freed under the view.

The compiler accepts this (GCC may warn). AddressSanitizer catches the read when you actually touch the characters — which is why our grader runs with it.

4. Iterator invalidation, as a user

A vector's elements move when it reallocates. Anything pointing into it — iterators, pointers, references, spans — is then dangling. As a user, the rules to remember:

Operation Invalidates
push_back/emplace_back/insert when size() == capacity() everything
same, with spare capacity only end() (and for insert, positions at/after the insert point)
reserve(n) with n > capacity(), shrink_to_fit() everything
erase(it) it and everything after it
pop_back() the last element and end()

The correct way to erase in a loop uses the iterator erase returns:

erase returns the next valid iterator
start / 4
Erasing shifts the tail left, so the old it would point at the next element — or past the end. Continue from the iterator erase returns.
erase returns the next valid iterator
Erasing shifts the tail left, so the old it would point at the next element — or past the end. Continue from the iterator erase returns.
C++
#include <print>
#include <vector>

int main() {
    std::vector<int> v{-1, -2, 3, -4, 5};
    for (auto it = v.begin(); it != v.end();) {
        if (*it < 0)
            it = v.erase(it);   // erase returns the next valid iterator
        else
            ++it;
    }
    std::println("{}", v);
}

(It's O(n²) in the worst case; std::erase_if is O(n) and shorter. Use the loop only when you need to do something else per element.)

5. Worked example: splitting without copying

Split a line into fields, returning views into the input — no allocation per field:

C++
#include <print>
#include <string_view>
#include <vector>

std::vector<std::string_view> split(std::string_view s, char delim) {
    std::vector<std::string_view> out;
    while (true) {
        auto pos = s.find(delim);
        out.push_back(s.substr(0, pos));       // substr clamps npos to "rest"
        if (pos == std::string_view::npos) return out;
        s.remove_prefix(pos + 1);              // drop the field and the delimiter
    }
}

int main() {
    std::string_view line = "ana,,bo,cy";
    for (auto field : split(line, ','))
        std::println("[{}]", field);
}
split("ana,,bo,cy", ',')
Four fields, each a view into line: ana, an empty field between the two commas, bo, cy. Nothing is copied.
split("ana,,bo,cy", ',')
Four fields, each a view into line: ana, an empty field between the two commas, bo, cy. Nothing is copied.

Why it's correct: each iteration emits exactly one field (possibly empty), and the loop ends after emitting the field that has no delimiter after it — so "a,b" gives 2 fields, "a," gives "a" and "", and "" gives one empty field. The returned views point into the caller's buffer, so they're valid exactly as long as that buffer is. The caller decides; split doesn't need to.

Faded example — your turn

Write count_fields(std::string_view s, char delim) returning how many fields split would produce, without building a vector. What's the answer for the empty string, and why?

C++
std::size_t count_fields(std::string_view s, char delim) {
    return /* ??? */;
}
Answer

std::ranges::count(s, delim) + 1. Every delimiter separates two fields, so there's always one more field than delimiters — including 1 for "".

6. Choosing parameter and return types

You need Parameter Return
read some ints std::span<const int> —
modify some ints in place std::span<int> —
add/remove elements std::vector<int>& —
read some text std::string_view —
produce new data — std::vector<T> / std::string (moved out, cheap)
slices of the caller's data — std::span / std::string_view — document the lifetime

Never return a view into something the function owns (a local, a member of a temporary): the view outlives it.

Summary

  • Own with vector/string; borrow with span/string_view.
  • v(3, 1) is three ones; v{3, 1} is two elements.
  • span<const T> and string_view are the default read-only parameter types.
  • A view is valid only while its target is alive and hasn't reallocated.
  • string_view isn't null-terminated.
  • Erase with std::erase_if, or with it = v.erase(it) in a loop.

Extensions after the foundations

Packed booleans explains why vector references are proxies and cannot be borrowed as span. mdspan adds logical shape and strides to borrowed multidimensional storage. Container contracts practices node identity, transfer and invalidation with the actual standard APIs.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Why contiguous std::vector should be your default container, with measurements against list and map.

The canonical tour of string_view pitfalls: temporaries, missing null terminators and views that outlive their string.

Short, concrete rules for passing string_view and span without dangling.

Why cache-friendly contiguous storage beats node-based containers — the performance argument behind vector-first. Watch 0:00–45:00.

Game-dev context (JavaScript examples, language-agnostic idea): a visual explanation of cache lines and why contiguous arrays beat linked structures — the case for vector-first.

Benchmarks std::array and span against C arrays and vector: contiguous, non-owning views cost nothing.

A short intro to string_view as a cheap non-owning view of string data, and its lifetime caveat.