Skip to content
C Codeloom
C++

C++ Iterators and Ranges Tutorial

A practical walk through C++ iterator categories, the ranges library, views, and how to compose pipelines that are both safe and zero-overhead.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • The five iterator categories and what they guarantee
  • How begin/end pairs power every STL algorithm
  • The ranges library and views in C++20
  • How lazy view composition avoids allocations
  • Migration tips from iterator pairs to ranges

Prerequisites

  • Basic familiarity with std::vector and std::sort

What and Why

Iterators are how C++ generalizes “a position in a sequence.” Every standard algorithm takes a pair of iterators, and every container exposes some. C++20’s ranges library adds a higher-level layer on top: you can pass a whole container, compose lazy views, and chain transformations without writing for-loops.

This combination gives you Python-like expressiveness with C++ performance: pipelines compile down to the same loops you would have written by hand.

Mental Model

An iterator behaves like a generalized pointer. Different iterator categories support different operations.

input            : read once, ++; e.g. std::istream_iterator
forward          : read many times, ++;       e.g. std::forward_list
bidirectional    : ++, --;                    e.g. std::list, std::map
random-access    : +n, -n, [], <;             e.g. std::vector, raw pointers
contiguous       : random-access + memory layout; e.g. std::array, std::vector
Iterator categories (subset)

A range is anything you can call begin() and end() on. Views are cheap, lazy ranges that wrap other ranges.

Hands-on Example

Classic iterator usage and the ranges equivalent.

#include <algorithm>
#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> v{5, 2, 8, 1, 9, 3, 7};

    // Iterator-based: sort, then print evens doubled
    std::sort(v.begin(), v.end());
    for (auto it = v.begin(); it != v.end(); ++it)
        if (*it % 2 == 0) std::cout << (*it * 2) << ' ';
    std::cout << '\n';

    // Ranges-based: same idea
    std::ranges::sort(v);
    auto pipeline = v
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * 2; });
    for (int x : pipeline) std::cout << x << ' ';
    std::cout << '\n';

    // Lazy: nothing is computed until we iterate
    auto first_three = pipeline | std::views::take(3);
    std::cout << std::ranges::distance(first_three) << '\n';
}

Notice that views never copy the underlying data. They store iterators and apply transformations on demand. When you stop iterating, work stops.

Common Pitfalls

  • Dangling iterators: storing vec.end() and then growing the vector invalidates it. Each container documents which operations invalidate which iterators; read those rules.
  • Treating filter/transform iterators as random-access: they are not. Algorithms that need < or +n will not compile, which is good news but sometimes surprising.
  • Returning views from functions: a view that captures a temporary container can dangle. Return owning containers across boundaries; use views internally.
  • Off-by-one with end(): end() is one past the last element, not the last element. Dereferencing it is undefined.
  • Mixing signed and unsigned arithmetic: container size() returns an unsigned type. Comparing it to a signed loop variable triggers warnings and bugs. Use std::ssize or range-for.

Practical Tips

Prefer range-for and ranges algorithms over manual iterator loops. They are shorter and harder to get wrong:

for (auto& x : container) ...;       // mutate in place
std::ranges::for_each(c, fn);        // explicit functional style

For partial work, std::views::take(n) and std::views::drop(n) give you slicing without copying.

When you really need a concrete container from a view, materialize it explicitly with std::ranges::to<std::vector> (C++23) or by constructing from iterators in C++20:

std::vector<int> out(pipeline.begin(), pipeline.end());

Custom containers benefit from exposing iterators: define begin(), end(), an iterator type, and the right category tag. Once those exist, every standard algorithm works on your type.

For debugging, remember that most iterator bugs are use-after-invalidation. When in doubt, recompute the iterator after any structural change.

Wrap-up

Iterators are the foundation; ranges are the ergonomic layer on top. Together they let you express “transform this sequence” in a way that is both declarative and zero-cost. Start by replacing simple loops with std::ranges algorithms, then compose views as your comfort grows. The result is code that reads like an outline and runs like a hand-written loop.