Lesson 1 of 12
The standard library has roughly 15 containers and roughly 100 algorithms. Writing each algorithm for each container would be 1500 functions.
It is not. There are about 100, because algorithms do not take containers — they take iterators.
The idea
#include <iostream>
#include <list>
#include <vector>
// One function, any container — because it takes iterators, not a container.
template <typename Iterator>
void print_range(Iterator first, Iterator last) {
for (Iterator it = first; it != last; ++it) {
std::cout << *it << " ";
}
std::cout << "\n";
}
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
std::list<int> l = {10, 20, 30};
int raw[] = {100, 200};
print_range(v.begin(), v.end());
print_range(l.begin(), l.end());
print_range(raw, raw + 2); // a pointer is an iterator
// Half of a vector.
print_range(v.begin(), v.begin() + 3);
return 0;
}
1 2 3 4 5
10 20 30
100 200
1 2 3
One function, four completely different sources. A std::vector stores its elements contiguously;
a std::list scatters them across the heap with pointers between them; a raw array is neither
class. print_range does not know or care.
The last call is the one that shows the real payoff: a range is not a container. v.begin() to
v.begin() + 3 is the first half of a vector, and every algorithm in the library accepts it.
What an iterator is
An iterator is anything that behaves like a pointer. Concretely, an iterator supports:
| Operation | Meaning |
|---|---|
| *it | the element it refers to |
| ++it | move to the next one |
| it != other | are these two different positions |
| it = other | copyable and assignable |
That is the whole minimum. A raw pointer satisfies all four, which is why print_range(raw, raw+2)
works — and it is not a coincidence. Iterators were designed by generalising the pointer.
Half-open ranges
end() points one past the last element, not at it. Dereferencing end() is undefined
behaviour.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {10, 20, 30, 40, 50};
auto it = v.begin();
std::cout << *it << "\n"; // dereference
++it; // advance
std::cout << *it << "\n";
// end() is one PAST the last element. Dereferencing it is undefined.
std::cout << "distance: " << (v.end() - v.begin()) << "\n";
std::cout << "last element: " << *(v.end() - 1) << "\n";
// The half-open range [begin, end) makes the empty case work without
// a special check: begin == end means empty.
std::vector<int> empty;
std::cout << "empty is empty: " << std::boolalpha
<< (empty.begin() == empty.end()) << "\n";
// Writing through an iterator.
for (auto i = v.begin(); i != v.end(); ++i) {
*i += 1;
}
for (int x : v) {
std::cout << x << " ";
}
std::cout << "\n";
// A const_iterator cannot write.
for (auto i = v.cbegin(); i != v.cend(); ++i) {
std::cout << *i << " ";
// *i = 0; // would not compile
}
std::cout << "\n";
// Reverse iterators walk backwards with the same ++.
for (auto i = v.rbegin(); i != v.rend(); ++i) {
std::cout << *i << " ";
}
std::cout << "\n";
return 0;
}
10
20
distance: 5
last element: 50
empty is empty: true
11 21 31 41 51
11 21 31 41 51
51 41 31 21 11
The half-open convention [begin, end) looks arbitrary and is not. Three things fall out of it:
end - beginis the size. With an inclusive end it would beend - begin + 1.- Empty needs no special case.
begin == endmeans empty, with no off-by-one. - Ranges compose. The end of one range is the beginning of the next, exactly.
The same convention appears in Python slices and half-open intervals in mathematics, for the same reasons.
Note rbegin()/rend(): a reverse iterator walks backwards, but you still write ++. The
direction is in the iterator's type, not at the call site, so the same algorithm sorts forwards or
backwards depending on which iterators you hand it.
The four flavours a container gives you
| Member | Returns | Can write? |
|---|---|---|
| begin() / end() | iterator | yes |
| cbegin() / cend() | const_iterator | no |
| rbegin() / rend() | reverse_iterator | yes, backwards |
| crbegin() / crend() | const_reverse_iterator | no, backwards |
Calling begin() on a const container gives you a const_iterator anyway. The explicit c
versions exist for when the container is not const but you want to promise you will not write —
which is worth doing, because the compiler then enforces it.
Range-for is iterators
for (int x : v) { ... }
is, near enough, this:
// {
// auto __first = v.begin();
// auto __last = v.end();
// for (; __first != __last; ++__first) {
// int x = *__first;
// ...
// }
// }
Which is why range-for works on anything with begin() and end() — including your own types:
#include <cstddef>
#include <iostream>
// A minimal container with a minimal iterator, to show what range-for needs.
class Countdown {
int start;
public:
explicit Countdown(int s) : start(s) {}
class Iterator {
int value;
public:
explicit Iterator(int v) : value(v) {}
int operator*() const { return value; }
Iterator& operator++() { --value; return *this; }
bool operator!=(const Iterator& other) const { return value != other.value; }
};
Iterator begin() const { return Iterator(start); }
Iterator end() const { return Iterator(0); }
};
int main() {
// range-for needs exactly: begin(), end(), operator*, operator++, operator!=
for (int n : Countdown(5)) {
std::cout << n << " ";
}
std::cout << "\n";
return 0;
}
5 4 3 2 1
Countdown stores nothing and owns nothing. Its "iterator" is an int that counts down. Yet
range-for accepts it, because range-for asks for behaviour, not for a container.
That is the whole design in one example: the library is built on what a type can do, not on what it is.
Iterator invalidation
An iterator points into a container's storage. If the container moves that storage, the iterator is left pointing at freed memory — a dangling pointer (lesson 8.5) by another name.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
v.reserve(3);
std::cout << "capacity " << v.capacity() << "\n";
auto it = v.begin();
std::cout << "before: " << *it << "\n";
v.push_back(4); // capacity exceeded: reallocates, invalidating `it`
// Reading *it now is a use-after-free. ASan reports:
// heap-use-after-free ... READ of size 4
std::cout << "size " << v.size() << " capacity " << v.capacity() << "\n";
return 0;
}
capacity 3
before: 1
size 4 capacity 6
Add std::cout << *it after the push_back and build with -fsanitize=address:
==5600==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
#0 ... in main invalidation.cpp:16
This is Module 11's tooling meeting Module 13's content. The bug is invisible without a sanitizer — the plain build printed a plausible-looking number.
Which operations invalidate what:
| Container | Invalidated by |
|---|---|
| vector | any reallocation (push_back, insert, resize); erase invalidates from the erase point on |
| deque | insert or erase anywhere invalidates all iterators |
| list, forward_list | only iterators to the erased element |
| map, set | only iterators to the erased element |
| unordered_map, unordered_set | rehashing invalidates all iterators |
The pattern: node-based containers are stable, contiguous ones are not. That is a real reason
to choose std::list or std::map occasionally, despite their worse cache behaviour (lesson
13.12).
The safe habits are to not hold an iterator across a modification, and to use the return value of
erase, which is the iterator to the next element:
// for (auto it = v.begin(); it != v.end(); ) {
// if (should_remove(*it)) {
// it = v.erase(it); // erase returns the next valid iterator
// } else {
// ++it;
// }
// }
Try it
Write a Range class so that for (int i : Range(1, 10, 2)) yields 1, 3, 5, 7, 9. Then write
one template function sum_range(first, last) and use it on a std::vector, a std::list, a
raw array, and your Range.
Checkpoint
- Algorithms take iterators, not containers — which is why 100 algorithms cover 15 containers.
- An iterator is anything supporting
*it,++it,it != other, and copying. - A raw pointer is an iterator; iterators were designed by generalising the pointer.
end()is one past the last element; dereferencing it is undefined.- Half-open ranges make size a subtraction, empty a comparison, and ranges composable.
cbegin/cendgive read-only iterators;rbegin/rendwalk backwards with the same++.- Range-for works on anything with
begin()andend(), including your own types. - Modifying a container can invalidate its iterators — a dangling pointer under another name.
- Node-based containers (
list,map) are stable; contiguous ones (vector) are not. erasereturns the next valid iterator; use it rather than guessing.
Questions about this lesson
Log in to ask a question.