C++ • Collections and STL

Using Iterators — Use iterators to traverse containers (begin/end)

Master the uniform abstraction that decouples algorithms from data structures across the entire STL.

Programming braces with a command terminal and binary digits.
Inside this lesson 10 sections 5 questions 6 figures

SECTION 1

Historical Context & Motivation

Before the Standard Template Library existed, C and early C++ programmers traversed arrays with raw pointer arithmetic and hand-rolled loops for every custom data structure—linked lists, trees, hash tables—each requiring its own traversal logic. This tight coupling between algorithms and data structures meant that sorting code written for an array could not be reused on a linked list without a complete rewrite. The combinatorial explosion of M algorithms × N containers demanded M × N implementations, a maintenance nightmare that grew worse with every new container or algorithm added to a codebase.

1979 • Bjarne Stroustrup Begins C++

Stroustrup starts developing "C with Classes" at Bell Labs, retaining C's pointer-based iteration model and manual memory management style.

1988 • Alexander Stepanov's Generic Programming

Stepanov and Musser formalize the idea that algorithms should operate on abstract sequences defined by iterator pairs, decoupling traversal from representation.

1994 • STL Proposal Accepted

Stepanov presents the Standard Template Library to the C++ standards committee. The iterator abstraction with begin/end pairs becomes central to the library's architecture.

1998 • C++98 Standardized

The first ISO C++ standard ships with the STL fully integrated. Every standard container exposes begin() and end() member functions, establishing iterators as a first-class language idiom.

2011 • C++11 Modernizes Iterators

Range-based for loops, auto type deduction, and free-standing std::begin()/std::end() functions simplify iterator usage and extend the pattern to C-style arrays.

The fundamental question that iterators answer is deceptively simple: how can we write a single algorithm that works across any container, without knowing the container's internal layout? The answer lies in providing a uniform interface—the iterator—that generalizes the notion of a pointer to work with vectors, lists, maps, sets, and even I/O streams. By defining the half-open range [begin, end), the STL collapses M × N complexity into M + N: M algorithms and N containers, connected through a shared iterator concept.

SECTION 2

Core Principles & Definitions

An iterator is a generalized pointer: an object that refers to an element within a container and supports operations such as dereferencing (to access the element), incrementing (to advance to the next element), and comparison (to detect when traversal is complete). Every standard container provides at least two member functions— begin() and end()—that return iterators defining the half-open range [first, one-past-last). This half-open convention is not arbitrary; it elegantly handles empty ranges (where begin equals end), avoids off-by-one errors, and enables natural loop termination conditions.

1

begin() — Start of Range

Returns an iterator pointing to the first element in the container. For an empty container, begin() == end().
2

end() — Past-the-End Sentinel

Returns an iterator pointing one past the last element. Dereferencing this iterator is undefined behavior; it exists solely as a boundary marker.
3

Dereferencing (*it)

The unary * operator yields a reference to the element the iterator currently designates, exactly like dereferencing a raw pointer.
4

Increment (++it)

Advances the iterator to the next element in sequence. Prefix increment (++it) is preferred over postfix (it++) to avoid unnecessary copies.
5

Comparison (it != end)

Equality and inequality operators allow comparison between two iterators. The canonical loop condition is it != container.end().
✦

KEY TAKEAWAY

Think of an iterator as a bookmark in a book. The bookmark doesn't care whether the book is hardcover or paperback (vector or list)—it simply marks your current place. You can look at the current page (dereference), flip to the next page (increment), and check whether you've reached the back cover (compare with end). The begin() bookmark starts at the first page; the end() bookmark sits just past the last page—a position you can detect but never read.

SECTION 3

Visual Explanation — The Iterator Model

Half-Open Range: [begin(), end())std::vector<int> v = {10, 20, 30, 40, 50};10[0]20[1]30[2]40[3]50[4]∅begin()end()++it++it++it++it== end()for (auto it = v.begin();it != v.end();++it) {std::cout << *it << " ";} // Output: 10 20 30 40 50
The diagram illustrates a std::vector<int> with five elements. The begin() iterator (cyan) points to the first element, while the end() iterator (violet) points to the dashed sentinel position one past the last element. Green arcs show successive ++it increments; the red dashed arc marks the termination condition when the iterator compares equal to end().

The critical insight encoded in this diagram is the half-open range convention. By designating end() as a past-the-end sentinel rather than the last valid element, the number of elements in any range [first, last) is simply last − first. An empty container satisfies begin() == end() without any special-case logic. This design eliminates the ambiguity that plagued earlier "inclusive range" approaches, where an empty range required a separate flag or invalid index. Moreover, the pattern generalizes: whether the underlying container is a contiguous array, a doubly-linked list, or a balanced tree, the loop for (auto it = c.begin(); it != c.end(); ++it) remains syntactically and semantically identical.

SECTION 4

How Iterators Work Under the Hood

Iterators in C++ are realized through operator overloading. Each container defines a nested class (or type alias) named iterator that overloads the operators operator*, operator++, operator!=, and (for random-access iterators) operator[] and operator+. Because these operators share the same syntax as raw pointer operations, generic algorithms written with iterators compile identically whether the underlying type is a simple pointer into an array or a complex object wrapping a tree node.

Key Member Functions & Free Functions

ExpressionReturnsSemantics
c.begin()iteratorIterator to the first element
c.end()iteratorIterator to one-past-the-last element
c.cbegin()const_iteratorLike begin(), but prevents modification of elements
c.cend()const_iteratorConst version of end()
c.rbegin()reverse_iteratorReverse iterator to the last element
c.rend()reverse_iteratorReverse iterator to one-before-the-first
std::begin(c)iterator / pointerFree function; works for containers and C arrays
std::end(c)iterator / pointerFree function; works for containers and C arrays

Common iterator access functions in the STL

💡

Why prefer prefix ++it over postfix it++?

Postfix increment must create a copy of the iterator before advancing it (to return the old value), whereas prefix increment modifies the iterator in place and returns a reference. For lightweight iterators like std::vector<T>::iterator (which is typically a pointer), the compiler optimizes away the difference. However, for heavier iterator objects—such as those wrapping tree or hash-table nodes—prefix increment avoids an unnecessary copy, and it is considered idiomatic modern C++ to always use ++it.

Since C++11, the free-standing functions std::begin() and std::end() provide a uniform entry point that dispatches to c.begin() for STL containers and to pointer arithmetic for raw C arrays. This allows template functions to accept both int arr[5] and std::vector<int> with identical syntax, further strengthening the iterator abstraction.

SECTION 5

Iterator Categories & Container Compatibility

Not all iterators are created equal. The STL defines a hierarchy of iterator categories that specify which operations an iterator supports. Each category is a refinement of the previous one, adding more capabilities. Understanding these categories is essential because STL algorithms impose minimum iterator requirements: std::sort demands random-access iterators, while std::find needs only input iterators.

Iterator Category HierarchyInput IteratorRead, single-pass (++, *, ==)Output IteratorWrite, single-pass (++, *)Forward IteratorRead/Write, multi-passBidirectional IteratorAdds decrement (--)Random Access IteratorAdds +n, −n, [], <, >Containers:istream_iteratorforward_listlist, mapvector, deque, array
The STL iterator hierarchy forms a refinement chain. Each lower category supports all operations of the categories above it plus additional ones. Input iterators provide single-pass read access; forward iterators add multi-pass guarantees; bidirectional iterators add backward traversal; and random-access iterators support constant-time jumps, subscripting, and ordering comparisons.
ContainerIterator Categorybegin()/end() ReturnsKey Operations
std::vector<T>Random AccessT* or wrapper++, --, +n, −n, [], <
std::deque<T>Random AccessChunk-aware wrapper++, --, +n, −n, [], <
std::list<T>BidirectionalNode pointer wrapper++, --, *, ==
std::forward_list<T>ForwardSingly-linked node wrapper++, *, ==
std::map<K,V>BidirectionalTree-node wrapper++, --, * → pair<K,V>
std::unordered_set<T>ForwardBucket/node wrapper++, *, ==

Common containers and their iterator categories

SECTION 6

Worked Example — Traversing and Transforming a Vector

Let us walk through a complete, realistic example that demonstrates iterator-based traversal, element mutation, and interoperation with an STL algorithm. We will work with a std::vector<int> containing student exam scores and compute the sum, find the maximum, and then normalize each score to a percentage.

Traversing, Summing, and Normalizing Scores

  1. 1

    Step 1 — Declare and Initialize the Container

    We declare a vector of integer scores and initialize it with brace-enclosed values: std::vector<int> scores = {72, 85, 91, 60, 78}; The vector now contains five elements. Its begin() points to 72 and its end() points one past 78.
    scores = {72, 85, 91, 60, 78}, size = 5
  2. 2

    Step 2 — Compute the Sum Using an Iterator Loop

    We use a standard iterator loop to accumulate the total: int total = 0; for (auto it = scores.begin(); it != scores.end(); ++it) { total += *it; } The loop starts at begin(), dereferences each iterator with *it to read the score, adds it to total, and increments with ++it. When it == scores.end(), the loop terminates.
    total = 72 + 85 + 91 + 60 + 78 = 386
  3. 3

    Step 3 — Find the Maximum Using std::max_element

    The STL algorithm std::max_element accepts a pair of iterators and returns an iterator to the largest element: auto maxIt = std::max_element(scores.begin(), scores.end()); int maxScore = *maxIt; Note that the algorithm returns an iterator, not a value—we dereference it to obtain the integer.
    maxIt points to index 2; *maxIt = 91
  4. 4

    Step 4 — Normalize Scores In-Place via Iterators

    We create a vector of doubles and use iterators to divide each score by the maximum, storing the result: std::vector<double> normalized; for (auto it = scores.cbegin(); it != scores.cend(); ++it) { normalized.push_back(static_cast<double>(*it) / maxScore * 100.0); } Here we use cbegin() and cend() to emphasize that we do not modify the original scores.
    normalized ≈ {79.12, 93.41, 100.00, 65.93, 85.71}
  5. 5

    Step 5 — Equivalent Range-Based For Loop (C++11)

    Since C++11, the range-based for loop provides syntactic sugar that the compiler desugars into an iterator loop: for (const auto& score : scores) { std::cout << score << " "; } Internally, this expands to a begin()/end() loop. Any container (or object) that provides begin() and end() can be used with range-for.
    Output: 72 85 91 60 78

SECTION 7

Iterators vs. Index-Based Access vs. Range-For

C++ programmers often face a stylistic and functional choice among three traversal strategies. Understanding the trade-offs helps you select the right tool for each situation and write code that is both idiomatic and efficient.

CriterionIndex-Based (for i)Explicit IteratorRange-Based For
Container GeneralityOnly random-access (vector, deque, array)All standard containersAll containers with begin()/end()
Element Accessc[i]*itNamed variable (auto& e)
Erase During TraversalPossible but error-prone (index shift)Safe with it = c.erase(it)Not possible—undefined behavior
STL Algorithm Compat.Requires manual adaptationNative—algorithms take iterator pairsNo direct interop (but can call algorithms separately)
Reverse TraversalDecrement index from size−1Use rbegin()/rend()Not directly supported
ReadabilityFamiliar to C programmersVerbose but preciseMost concise; recommended for simple loops

Comparison of traversal approaches in C++

✦

KEY TAKEAWAY

Think of explicit iterator loops as the universal adapter in a power-tools analogy: they work with every socket (container) in the toolbox, they let you reverse direction, and they integrate directly with all STL power tools (algorithms). Index-based loops are like a specialized wrench—fast and intuitive for bolts of a known size, but useless on others. Range-for is the cordless drill—convenient and handles most jobs, but lacks the fine control needed for advanced tasks like mid-loop erasure or algorithm composition.

SECTION 8

Connection to Ranges, Concepts, and Modern C++

While the begin()/end() iterator pair remains foundational, the C++20 standard introduced Ranges (the <ranges> header) as a higher-level abstraction that builds upon iterators. A range is any object that provides begin() and end(), exactly as we have discussed, but ranges allow composable view pipelines using the pipe operator |. Mastering classical iterators is prerequisite to understanding ranges.

FeatureClassical Iterators (C++98/11)Ranges (C++20)
RepresentationPair of iterators: (begin, end)Single range object (encapsulates both)
CompositionNesting algorithm calls (inside-out)Pipe chains: v | filter(p) | transform(f)
Lazy EvaluationNo—each algorithm materializes resultsYes—views are computed on demand
Error MessagesCryptic template errors on mismatchImproved via concept constraints
Underlying MechanismIterator pairs directlyStill uses iterators internally

Classical iterators vs. C++20 Ranges

C++20 also formalized iterator requirements using concepts such as std::input_iterator, std::forward_iterator, std::bidirectional_iterator, and std::random_access_iterator. These concepts replace the informal "iterator traits" documentation with compile-time constraints, producing clear error messages when a programmer supplies an iterator of insufficient category. The trajectory is clear: iterators are not going away—they are being strengthened and wrapped in higher-level abstractions that make them safer and more composable.

ℹ

Looking Ahead: C++23 and Beyond

C++23 extends ranges with std::views::zip, std::views::enumerate, and std::generator (a coroutine-based lazy range). All of these rely on the same begin()/end() iterator protocol. Investing time in understanding iterators now pays dividends as the standard library evolves.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain why the STL uses a half-open range [begin, end) rather than a closed range [begin, last]. What specific advantages does the half-open convention provide for empty containers and for computing the number of elements in a range?
Show answer
The half-open range [begin, end) has three key advantages. First, an empty range is naturally represented by begin == end, requiring no special sentinel value or flag. Second, the number of elements in a range is simply end − begin (for random-access iterators), a clean formula with no off-by-one correction. Third, when partitioning a range into two halves [begin, mid) and [mid, end), the boundary element 'mid' is the start of the second subrange and one-past-the-end of the first—there is no ambiguity about which subrange contains it. These properties make half-open ranges elegant for recursive algorithms like merge sort and binary search.

Question 2: BASIC CALCULATION

Given std::vector<double> temps = {98.6, 100.4, 99.1, 97.8, 101.2};, write a loop using explicit iterators (not range-for) that computes and prints the average temperature. Show the complete code including necessary headers.
Show answer
The solution uses begin()/end() to traverse the vector, accumulating a sum, then divides by the size. The code is: #include <iostream> #include <vector> int main() { std::vector<double> temps = {98.6, 100.4, 99.1, 97.8, 101.2}; double sum = 0.0; for (auto it = temps.begin(); it != temps.end(); ++it) { sum += *it; } double avg = sum / temps.size(); std::cout << "Average: " << avg << std::endl; // 99.42 return 0; } The sum is 98.6 + 100.4 + 99.1 + 97.8 + 101.2 = 497.1, divided by 5 gives 99.42. Using auto deduces the iterator type as std::vector<double>::iterator, and prefix ++it avoids unnecessary copies.

Question 3: INTERMEDIATE

You have a std::list<int> containing {3, 7, 2, 9, 4, 6, 1}. Write code that uses iterators to remove all even numbers from the list. Explain why you cannot use index-based access for std::list and why you must capture the return value of erase().
Show answer
std::list provides bidirectional iterators, not random-access iterators, so operator[] is not available—there is no O(1) index-based jump in a linked list. The erase() member function invalidates the iterator that was erased, so we must capture the return value (an iterator to the next valid element) to continue traversal safely. The code is: std::list<int> nums = {3, 7, 2, 9, 4, 6, 1}; for (auto it = nums.begin(); it != nums.end(); ) { if (*it % 2 == 0) { it = nums.erase(it); // erase returns next valid iterator } else { ++it; } } // nums is now {3, 7, 9, 1} Note the absence of ++it in the loop header—incrementing happens either through the erase return value or the explicit ++it in the else branch. This pattern is fundamental for safe mutation during traversal.

Question 4: APPLIED

You are building a text analysis tool. Given a std::map<std::string, int> mapping words to their frequency counts, write a function using iterators that finds and returns the word with the highest frequency. Your function signature should be std::string mostFrequent(const std::map<std::string, int>& wordFreq). Handle the case where the map is empty.
Show answer
Since std::map provides bidirectional iterators, we cannot use std::max_element with a random-access requirement—but fortunately std::max_element only requires input iterators, so it works. However, we need a custom comparator because max_element would compare pairs lexicographically by default. Here is a manual iterator approach: std::string mostFrequent(const std::map<std::string, int>& wordFreq) { if (wordFreq.empty()) return ""; auto bestIt = wordFreq.cbegin(); for (auto it = wordFreq.cbegin(); it != wordFreq.cend(); ++it) { if (it->second > bestIt->second) { bestIt = it; } } return bestIt->first; } Key observations: we use cbegin()/cend() because the parameter is const. Dereferencing a map iterator yields a std::pair<const Key, Value>, so it->first is the word and it->second is the count. The empty check prevents dereferencing an invalid iterator. This pattern generalizes to any associative container.

Question 5: CRITICAL THINKING

A colleague writes a generic function template: template <typename Container> void printAll(const Container& c) { for (int i = 0; i < c.size(); ++i) std::cout << c[i] << " "; } Critique this implementation. Identify at least three problems and rewrite it to be fully generic using iterators, explaining each improvement.
Show answer
Problem 1: The function uses operator[], which requires random-access iterators and therefore fails to compile for std::list, std::set, std::map, and other non-random-access containers. Problem 2: c.size() returns std::size_t (unsigned), but i is declared as int (signed); comparing signed and unsigned types triggers a compiler warning and could cause bugs for very large containers. Problem 3: The function cannot be used with C-style arrays because they lack a .size() member and operator[]. A corrected version: template <typename Container> void printAll(const Container& c) { for (auto it = std::cbegin(c); it != std::cend(c); ++it) std::cout << *it << " "; } This version works with every standard container and with C-style arrays (via std::cbegin/std::cend). The auto keyword deduces the correct const_iterator type. A range-for version would be even more concise, but the iterator version demonstrates the mechanics and allows future extension (e.g., skipping every other element).

SUMMARY

Summary — Iterators as the Glue of the STL

Summary

An iterator is a generalized pointer that provides a uniform way to traverse any C++ container. Every standard container exposes begin() (pointing to the first element) and end() (pointing one past the last element), defining a half-open range [begin, end). This convention elegantly handles empty containers, simplifies element counting, and enables clean range partitioning. The three fundamental iterator operations are dereferencing (*it) to access an element, incrementing (++it) to advance, and comparison (it != end) to detect termination.

Iterators fall into a category hierarchy—input, output, forward, bidirectional, and random-access—each adding capabilities. Containers like std::vector provide random-access iterators, while std::list provides bidirectional iterators. Free functions std::begin() and std::end() extend the pattern to C-style arrays. The range-based for loop (C++11) is syntactic sugar over iterators, and C++20 Ranges build on them with composable, lazy view pipelines. Mastering the begin/end iterator protocol is the foundation upon which all STL usage rests.

Varsity Tutors • C++ • Using Iterators — Use iterators to traverse containers (begin/end)