C++ iterators categories adapters custom iterator tutorial
|

C++ Iterators: Categories, Adapters & Custom Iterator Guide

What Are Iterators?

Iterators are generalized pointers that provide a uniform way to traverse elements in any STL container. They’re the bridge between containers and algorithms — algorithms don’t know which container holds the data, and containers don’t know which algorithms will process it. Iterators connect them with a common interface: dereference (*it), advance (++it), and compare (it != end).

This design means M containers × N algorithms only need M + N implementations instead of M × N. std::sort works with vector, array, deque, and any custom container that provides the right iterator type — all without knowing anything about those containers.

Iterator Basics

#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {10, 20, 30, 40, 50};

    // begin() points to first element, end() points past the last
    auto it = v.begin();   // points to 10
    auto last = v.end();   // points past 50 (not dereferenceable!)

    // Dereference — access the element
    std::cout << *it << "\n";      // 10

    // Advance
    ++it;
    std::cout << *it << "\n";      // 20

    // Modify through iterator
    *it = 25;
    std::cout << v[1] << "\n";     // 25

    // Iterator loop (equivalent to range-based for)
    for (auto it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << " ";
    }
    std::cout << "\n";

    // Range-based for is syntactic sugar for:
    // auto __begin = v.begin();
    // auto __end = v.end();
    // for (; __begin != __end; ++__begin) {
    //     auto& elem = *__begin;
    // }

    // Arrow operator for class elements
    std::vector<std::string> words = {"hello", "world"};
    auto wit = words.begin();
    std::cout << wit->size() << "\n";  // 5 (hello.size())
}

Iterator Categories

Iterators form a hierarchy of capabilities. Each category adds operations on top of the previous one:

// Input Iterator — read once, forward only
// Operations: *it (read), ++it, it != end
// Used by: std::istream_iterator
// Containers: none directly (stream input)

// Output Iterator — write once, forward only
// Operations: *it = val, ++it
// Used by: std::ostream_iterator, std::back_inserter

// Forward Iterator — read/write, forward, multi-pass
// Operations: *it (read/write), ++it, it != end, copy/compare
// Containers: forward_list, unordered_set, unordered_map

// Bidirectional Iterator — + backward
// Operations: all Forward ops + --it
// Containers: list, set, map, multiset, multimap

// Random Access Iterator — + jumps, arithmetic
// Operations: all Bidirectional ops + it += n, it -= n, it[n], it1 - it2, it1 < it2
// Containers: vector, deque, array, string

// Contiguous Iterator (C++20) — guarantees contiguous memory
// Operations: all Random Access ops + contiguous memory guarantee
// Containers: vector, array, string (NOT deque)

// Algorithm requirements:
// std::find      → Input Iterator (reads forward)
// std::copy      → Input + Output
// std::replace   → Forward Iterator (multi-pass)
// std::reverse   → Bidirectional Iterator (needs --)
// std::sort      → Random Access Iterator (needs jumps)

If you pass a bidirectional iterator to std::sort (which needs random-access), you’ll get a compile error. This is why std::list has its own .sort() member function — the generic std::sort can’t work with list’s bidirectional iterators.

Const and Reverse Iterators

#include <vector>

std::vector<int> v = {1, 2, 3, 4, 5};

// Regular iterator — read and write
auto it = v.begin();
*it = 10;  // OK

// Const iterator — read only
auto cit = v.cbegin();
// *cit = 10;  // ERROR: can't modify through const_iterator
std::cout << *cit << "\n";  // OK to read

// Reverse iterator — iterate backwards
for (auto rit = v.rbegin(); rit != v.rend(); ++rit) {
    std::cout << *rit << " ";  // 5 4 3 2 1
}

// Const reverse iterator
for (auto crit = v.crbegin(); crit != v.crend(); ++crit) {
    std::cout << *crit << " ";
}

// Convert reverse to forward iterator
auto rit = v.rbegin();
std::advance(rit, 2);       // points to 3 (in reverse)
auto fwd = rit.base();      // forward iterator to element AFTER 3 → points to 4
// Note: .base() returns iterator to element AFTER the reverse iterator's element

// Common pattern: erase with reverse iterator
// v.erase(std::next(rit).base());  // erase the element rit points to

Iterator Utility Functions

#include <iterator>
#include <vector>
#include <list>

std::vector<int> v = {10, 20, 30, 40, 50};
std::list<int> lst = {10, 20, 30, 40, 50};

// std::advance — move iterator forward/backward
auto it = v.begin();
std::advance(it, 3);              // O(1) for random-access
std::cout << *it << "\n";       // 40

auto lit = lst.begin();
std::advance(lit, 3);             // O(n) for bidirectional — walks 3 steps

// std::next / std::prev (C++11) — non-modifying
auto it2 = std::next(v.begin(), 2);    // returns iterator to 3rd element
auto it3 = std::prev(v.end());         // returns iterator to last element
auto it4 = std::next(v.begin());       // default step is 1

// std::distance — count elements between iterators
auto dist = std::distance(v.begin(), v.end());  // 5
// O(1) for random-access, O(n) for forward/bidirectional

// std::begin / std::end — work with raw arrays too!
int arr[] = {1, 2, 3, 4, 5};
auto arr_begin = std::begin(arr);  // int*
auto arr_end = std::end(arr);      // int* past last element
auto arr_size = std::distance(arr_begin, arr_end);  // 5

// std::size (C++17)
auto sz = std::size(v);    // 5
auto asz = std::size(arr); // 5

// std::ssize (C++20) — returns signed size
auto ssz = std::ssize(v);  // ptrdiff_t, not size_t

Iterator Adapters

Iterator adapters wrap containers or other iterators to modify their behavior:

#include <iterator>
#include <vector>
#include <algorithm>

std::vector<int> src = {1, 2, 3, 4, 5};

// back_inserter — calls push_back on assignment
std::vector<int> dest;
std::copy(src.begin(), src.end(), std::back_inserter(dest));
// dest = {1, 2, 3, 4, 5}

// front_inserter — calls push_front (for deque, list)
std::list<int> lst;
std::copy(src.begin(), src.end(), std::front_inserter(lst));
// lst = {5, 4, 3, 2, 1} — reversed because each goes to front

// inserter — inserts at a specific position
std::vector<int> v = {10, 50};
std::copy(src.begin(), src.end(), std::inserter(v, std::next(v.begin())));
// v = {10, 1, 2, 3, 4, 5, 50}

// move_iterator — moves instead of copies
std::vector<std::string> words = {"hello", "world", "foo"};
std::vector<std::string> moved;
std::copy(std::make_move_iterator(words.begin()),
          std::make_move_iterator(words.end()),
          std::back_inserter(moved));
// moved = {"hello", "world", "foo"}
// words = {"", "", ""}  — moved-from

// reverse_iterator — already covered above
// Useful in algorithms:
// Sort descending
std::sort(v.begin(), v.end());
// Or iterate backwards with algorithms
auto rit = std::find(v.rbegin(), v.rend(), 3);  // find last occurrence

Stream Iterators

#include <iterator>
#include <iostream>
#include <vector>
#include <algorithm>
#include <sstream>

// ostream_iterator — write to output stream
std::vector<int> v = {1, 2, 3, 4, 5};
std::copy(v.begin(), v.end(), std::ostream_iterator<int>(std::cout, " "));
// Output: 1 2 3 4 5

std::cout << "\n";

// istream_iterator — read from input stream
std::istringstream input("10 20 30 40 50");
std::vector<int> data(
    std::istream_iterator<int>(input),
    std::istream_iterator<int>()  // end-of-stream sentinel
);
// data = {10, 20, 30, 40, 50}

// Combine: read from stdin, sort, write to stdout
// std::vector<int> nums(
//     std::istream_iterator<int>(std::cin),
//     std::istream_iterator<int>());
// std::sort(nums.begin(), nums.end());
// std::copy(nums.begin(), nums.end(),
//           std::ostream_iterator<int>(std::cout, "\n"));

Writing a Custom Iterator

You can write iterators for your own data structures. Here’s a minimal forward iterator for a simple range:

#include <iterator>
#include <iostream>

class IntRange {
    int start_, end_;
public:
    IntRange(int start, int end) : start_(start), end_(end) {}

    class iterator {
        int current_;
    public:
        // Iterator traits (required for STL compatibility)
        using iterator_category = std::forward_iterator_tag;
        using value_type = int;
        using difference_type = std::ptrdiff_t;
        using pointer = const int*;
        using reference = int;

        explicit iterator(int val) : current_(val) {}

        int operator*() const { return current_; }
        iterator& operator++() { ++current_; return *this; }
        iterator operator++(int) { auto tmp = *this; ++current_; return tmp; }
        bool operator==(const iterator& other) const { return current_ == other.current_; }
        bool operator!=(const iterator& other) const { return current_ != other.current_; }
    };

    iterator begin() const { return iterator(start_); }
    iterator end() const { return iterator(end_); }
};

int main() {
    // Use in range-based for
    for (int i : IntRange(1, 6)) {
        std::cout << i << " ";  // 1 2 3 4 5
    }
    std::cout << "\n";

    // Use with STL algorithms
    IntRange range(1, 11);
    auto it = std::find(range.begin(), range.end(), 7);
    if (it != range.end()) {
        std::cout << "Found: " << *it << "\n";  // Found: 7
    }

    int sum = 0;
    for (int n : IntRange(1, 101)) sum += n;
    std::cout << "Sum 1-100: " << sum << "\n";  // 5050
}

C++20 Sentinel Model

C++20 ranges introduced the sentinel concept — the end of a range doesn’t need to be the same type as the iterator. This enables more flexible termination conditions:

#include <ranges>
#include <iostream>
#include <string>

// C-string example: the sentinel is '\0', not another pointer
// In C++20 ranges, std::ranges::find handles this:
// const char* str = "hello";
// auto it = std::ranges::find(str, '\0', 'l');

// std::unreachable_sentinel — for when you KNOW the search will succeed
// Skips the end check entirely (dangerous but fast)

// Views use sentinels naturally:
for (int i : std::views::iota(1) | std::views::take(5)) {
    std::cout << i << " ";  // 1 2 3 4 5
}
// iota(1) is an infinite range — take(5) provides the sentinel

// Ranges algorithms accept iterator + sentinel
std::string s = "Hello, World!";
auto it = std::ranges::find(s, ',');
// it is an iterator, s.end() would be the sentinel

Common Mistakes

Dereferencing end(). end() points past the last element and must never be dereferenced. Always check it != container.end() before using *it.

Iterator invalidation. Modifying a container (insert, erase, push_back) can invalidate existing iterators. Vector invalidates all iterators on reallocation. List only invalidates erased iterators. Know the rules for your container.

Using wrong iterator category. Passing a bidirectional iterator to an algorithm that requires random-access gives cryptic compile errors. Check the algorithm’s requirements.

Off-by-one with reverse_iterator::base(). rit.base() returns a forward iterator to the element after *rit. This confuses many developers when erasing with reverse iterators.

Forgetting std::advance is O(n) for non-random-access. std::advance(list_it, 1000) walks 1000 nodes one by one. It’s not like vector_it += 1000 which is O(1).

Summary

Iterators are generalized pointers that decouple containers from algorithms. They form a hierarchy: Input → Forward → Bidirectional → Random Access → Contiguous, each adding capabilities. Utility functions (std::advance, std::next, std::distance) work generically across categories. Iterator adapters (back_inserter, move_iterator, stream iterators) extend iterator capabilities. C++20 introduces sentinels for flexible range termination. In the next lesson, you’ll master sorting and searching algorithms — the workhorses of the STL that rely on iterators to process data efficiently.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *