Learn C++ STL

Lesson 4 of 6 · What the STL Is, and Why It Changes How You Write C++

Module 0 · What the STL Is, and Why It Changes How You Write C++

Iterators and Algorithms: How the Pieces Connect

FreeReading

In this lesson

  • Say what an iterator is, and what begin() and end() point at.
  • Explain why a range is written [begin, end), and why that makes empty ranges and loops easy.
  • Run the same four algorithms on a vector and on a deque with the same lines of code.

Zara wrote a careful search for a vector of exam marks. She tested it on an empty vector first, as she always does, and it passed. Then her team switched the marks to a deque, and she sighed: she would have to write the search again.

She did not. Her search was std::find, and std::find does not know what a vector is. It only knows how to walk from one iterator to another. Change the container and the same line keeps working.

This lesson is about that walk. It connects every container in Lesson 3 to every algorithm in this track. So it is the one picture worth seeing move.

You already know one iterator: the pointer

In C you walked an array with a pointer. You started at the first element, read it with *p, moved with ++p, and stopped at a pointer one past the last element. That pointer is an iterator: something that points at an element and can move to the next one.

In STL code that walk always has the same shape. Here it is with every part named; Example 1 then shows it with plain pointers.

The iterator walk

for (auto it = c.begin(); it != c.end(); ++it) { use(*it); }
  • auto it = c.begin(): start at the first element. auto lets the compiler write the iterator's long type.
  • it != c.end(): keep going until you reach the slot past the last element. Use !=, not <, because not every iterator can be compared with <.
  • ++it: step to the next element, whatever "next" means for this container.
  • *it: the element the iterator points at.
Example 1: a pointer is an iterator
#include <iostream>

int main()
{
    int marks[] = {72, 45, 90, 61};
    int *first = marks;
    int *last = marks + 4;
    std::cout << "marks:";
    for (int *p = first; p != last; ++p) std::cout << ' ' << *p;
    std::cout << '\n';
}
marks: 72 45 90 61

last points one past 61, at a slot that holds nothing of ours. The loop never reads it; it only compares against it. Keep that in mind, because the STL does exactly the same.

Run in Compiler

begin() and end(): the same walk over any container

Every STL container hands out two iterators. begin() points at the first element. end() points one past the last element, the same empty slot as last above. You never read from end(); you only stop when you reach it.

A container's iterator is not always a plain pointer. A list iterator follows a link to the next node, and a deque iterator may hop from one block of memory to the next. But all of them answer the same three requests: read (*it), move (++it), and compare (it != end).

Here is the walk, moving. Step through it on the vector first, then on the deque. Watch the last step: the iterator lands on end(), the loop stops, and nothing is read there.

The iterator starts at begin() on 72 and steps to 45, 90 and 61. Then it reaches end(), the empty slot past 61, and the loop stops without reading it. On the deque the four values sit in two blocks of two. The iterator makes the same five stops, hopping from the first block to the second. (A real deque's blocks hold far more than two; two makes the hop visible.)

Example 2: walking a vector with an iterator
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v = {72, 45, 90, 61};
    std::cout << "v:";
    for (auto it = v.begin(); it != v.end(); ++it) std::cout << ' ' << *it;
    std::cout << '\n';
    std::cout << "size from the iterators: " << (v.end() - v.begin()) << '\n';
}
v: 72 45 90 61
size from the iterators: 4

The loop is Example 1's loop with new names. And end() - begin() is 4, the number of elements, exactly as last - first was 4 for the pointers. That subtraction is the reason end() sits one past the last element.

Run in Compiler

The half-open range [begin, end)

Two iterators, first and last, describe a range: every element from first up to, but not including, last. Mathematicians write that as [first, last), a half-open range: the square bracket means "included" and the round one means "not included".

The half-open range: begin() on the first element, end() one past the last 72 45 90 61 nothing 0 1 2 3 4 begin() end() [begin, end) holds 4 elements, and end() - begin() = 4 nothing An empty container: begin() == end(), both on this slot. The range is empty and every loop simply runs zero times.
Figure 1. The half-open range. Every element is inside it, end() is just outside it, and an empty range is the case where both point at the same place.

Why not make end() point at the last element instead? Three reasons, and you have already seen two of them.

  • The count is a subtraction. end() - begin() is the number of elements, with no + 1 to remember.
  • Empty needs no special case. In an empty container, begin() == end(). The loop condition is false at once, and every algorithm does nothing, correctly. Zara's empty-vector test passes without any extra code.
  • "Not found" has a home. std::find returns end() when the value is not there, a position that can never be a real answer.

So a range always means "from here, up to but not including there". Every algorithm in the track takes its range this way.

One algorithm, every container

An algorithm receives two iterators and walks between them. It never receives the container. So it cannot tell, and does not care, whether the elements sit in a vector, a deque or a plain array. The only thing it needs is an iterator that can do what the algorithm asks of it.

Example 3: std::sort on a plain C array
#include <algorithm>
#include <iostream>

int main()
{
    int marks[] = {72, 45, 90, 61};
    std::sort(marks, marks + 4);
    std::cout << "sorted:";
    for (int m : marks) std::cout << ' ' << m;
    std::cout << '\n';
}
sorted: 45 61 72 90

No container at all, just two pointers, and std::sort is happy. That is the strongest proof that an algorithm only sees iterators: a pointer into a C array is one.

Run in Compiler

Now four algorithms on a vector. sort puts the range in order, and find returns an iterator to a value (or end()). count counts a value, and reverse turns the range back to front.

Example 4: four algorithms on a vector
#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> c = {61, 72, 45, 90, 45};
    std::sort(c.begin(), c.end());
    auto it = std::find(c.begin(), c.end(), 72);
    std::cout << "72 found: " << (it != c.end()) << '\n';
    std::cout << "45 count: " << std::count(c.begin(), c.end(), 45) << '\n';
    std::reverse(c.begin(), c.end());
    std::cout << "reversed:";
    for (int x : c) std::cout << ' ' << x;
    std::cout << '\n';
}
72 found: 1
45 count: 2
reversed: 90 72 61 45 45

(it != c.end()) is true, and std::cout prints true as 1. The sort put the marks in rising order, and the reverse turned them into falling order.

Run in Compiler
Example 5: the same four lines on a deque

Two changes only: the header and the word vector on the declaration. Every algorithm line is the same, character for character.

#include <algorithm>
#include <deque>
#include <iostream>

int main()
{
    std::deque<int> c = {61, 72, 45, 90, 45};
    std::sort(c.begin(), c.end());
    auto it = std::find(c.begin(), c.end(), 72);
    std::cout << "72 found: " << (it != c.end()) << '\n';
    std::cout << "45 count: " << std::count(c.begin(), c.end(), 45) << '\n';
    std::reverse(c.begin(), c.end());
    std::cout << "reversed:";
    for (int x : c) std::cout << ' ' << x;
    std::cout << '\n';
}
72 found: 1
45 count: 2
reversed: 90 72 61 45 45

The same output. Inside, the deque keeps its elements in separate blocks, and its iterator hops between them, as the widget showed. The algorithms never notice. This is Zara's search, changing containers without changing a line.

Run in Compiler

Part of a container is a range too

Because an algorithm takes any two iterators, you can hand it part of a container. v.begin() + 3 is "three steps in", so [v.begin(), v.begin() + 3) is the first three elements.

Example 6: sorting only the first three
#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> v = {90, 72, 61, 45, 10};
    std::sort(v.begin(), v.begin() + 3);
    std::cout << "first three sorted:";
    for (int x : v) std::cout << ' ' << x;
    std::cout << '\n';

    std::vector<int> empty;
    std::sort(empty.begin(), empty.end());
    std::cout << "empty: begin == end is " << (empty.begin() == empty.end()) << '\n';
}
first three sorted: 61 72 90 45 10
empty: begin == end is 1

Only 90, 72 and 61 moved; 45 and 10 were outside the range and stayed put. And sorting an empty vector is safe: the range has no elements, so there is nothing to do.

Run in Compiler

That gives the one rule this whole track keeps: an algorithm never knows which container it is walking. It knows two iterators, and the job.

Where this is used

  • Every C++ program that calls an algorithm. std::sort(v.begin(), v.end()) is the shape you will see in LLVM, Chromium and every contest solution. Once you can read a range, you can read all of them.
  • C's qsort, the contrast. qsort works only on a contiguous array, and it compares through void pointers you cast by hand. It cannot sort a linked list at all. The iterator is what lets one C++ algorithm go where qsort cannot.
  • Java's two sorts. Java has Arrays.sort for arrays and Collections.sort for lists, two entry points for one job. The STL's single std::sort works on both shapes because it takes iterators, not a container.
  • Rust's iterators. Rust's standard library also builds its loops and many of its algorithms on iterators. It is the same idea, "something that yields the next element", in a newer language.

Common mistakes

1. Reading end().

for (auto it = v.begin(); it <= v.end(); ++it)
    std::cout << *it << ' ';

GCC 12 compiles this without a word, at the Playground's flags and with -Wall -Wextra. The last pass reads *v.end(), the empty slot, which is undefined behaviour: it may print a junk number, crash, or seem fine today and fail tomorrow. This is Bob's off-by-one, in iterator form. The condition is always it != v.end().

2. Sorting a list with std::sort.

std::list<int> l = {3, 1, 2};
std::sort(l.begin(), l.end());

GCC 12 prints a long message from inside the library. The line that matters is error: no match for 'operator-' (operand types are 'std::_List_iterator<int>' and 'std::_List_iterator<int>'). std::sort needs to jump around the range and subtract iterators, and a list iterator can only step one node at a time. A list sorts itself instead: l.sort();. Module 5 and Module 11 explain why.

3. Mixing iterators from two containers.

std::vector<int> a = {3, 1, 2};
std::vector<int> b = {9, 8};
std::sort(a.begin(), b.end());

This compiles with no message at all: both are vector iterators, so the types match. But a.begin() to b.end() is not a range, and the sort walks off into memory it does not own. A range's two ends always come from the same container. When you copy a line and change one name, check both.

Brain teaser

Every example in this lesson compares with v.end(), but none of them ever writes *v.end(). What would *v.end() give you for std::vector<int> v = {72, 45, 90, 61};, and why does this lesson never write it?

Look at Figure 1 and ask what is in the dashed box. Then ask whether the C standard let you read marks[4] of a four-element array.

Exercise 1Easy

Open Example 4 in the Playground and change the vector to a std::string holding "banana". Change the find to look for 'n' and the count to count 'a', and print the string at the end instead of the loop.

Check yourself. You should change only the declaration, the two values and the printing. The algorithm calls stay the same. The reversed sorted string is nnbaaa.

Exercise 2Medium

Using Example 6 as a model, sort only the last three elements of {90, 72, 61, 45, 10}. Write down the two iterators of your range before you run it.

Rules. Use v.end() for one end of the range. Do not count from the front.

Check yourself. The output is 90 72 10 45 61. Your range is [v.end() - 3, v.end()), and it holds end() - (end() - 3), which is 3 elements.

Exercise 3Hard

Zara wants to know what std::find returns on an empty vector. Without running anything, write down what it returns and how her code should test it. Then explain why no special "empty" check is needed.

Rules. Use the words "half-open" and "end()" in your answer.

Check yourself. On an empty vector begin() == end(), so find checks no element and returns end(), meaning "not found". The usual test, it != v.end(), is false, which is the right answer. Then run it on the Playground to confirm.

Common doubts

  • Is an iterator just a pointer?

    A pointer is one kind of iterator, and a vector's iterator behaves almost exactly like one. Other containers need smarter iterators: a list iterator follows links, a map iterator walks a tree in sorted order. They all look the same to you, which is the point.

  • Why is it called end() if it is not the end element?

    Think of it as "the end of the range", like a finish line, not "the last runner". The last element is at end() - 1 for a vector, or v.back().

  • When do I write an iterator loop instead of a range-for?

    When you need the position itself: to erase the element, to remember where you are, or to hand part of the range to an algorithm. For simply reading every element, the range-for for (int x : v) is shorter and walks the same iterators for you.

  • Can an iterator stop working?

    Yes. If a vector grows and moves its elements to new memory, old iterators into it point at the old place and must not be used. This is called invalidation. Lesson 5 shows you where the reference says when it happens, and Module 11 shows it moving.

Key takeaways

  • An iterator points at an element and can read it, move to the next, and compare with another iterator.
  • begin() points at the first element; end() points one past the last and is never read.
  • A range is half-open, [begin, end): the count is end - begin, and an empty range has begin == end.
  • An algorithm takes two iterators, never a container, so the same lines work on a vector, a deque or a C array.
  • A list cannot be sorted with std::sort, because its iterator can only step one node at a time.

Next you will open the reference every C++ programmer keeps open, and learn to read the cost it promises for every one of these operations.

End of lesson 4

Mark it done, and your progress moves with you.

Next: How to Read cppreference, and What O(log n) Promises