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.
1. std::vector as a user
Construction: parentheses vs braces
Braces prefer the std::initializer_list constructor whenever it can be used:
#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 only raises capacity: still no elements, and v[0] is out of range. resize creates eight value-initialised ints.#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):
#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:
#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 aconstcontainer:
#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).
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:
#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 passsv.data()to a C API that expects a C string; make astd::string(sv)first. - Dangling. A
string_viewinto a temporary dies with the temporary:
#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
};, 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:
it would point at the next element — or past the end. Continue from the iterator erase returns.#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:
#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);
}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?
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 withspan/string_view. v(3, 1)is three ones;v{3, 1}is two elements.span<const T>andstring_vieware the default read-only parameter types.- A view is valid only while its target is alive and hasn't reallocated.
string_viewisn't null-terminated.- Erase with
std::erase_if, or withit = v.erase(it)in a loop.
Extensions after the foundations
Packed booleans explains why vector