C++ Module 11 – Iterators, algorithms and ranges
Writing your own C++ ranges and views
Write C++ ranges and views of your own: an iterator and a sentinel, std::ranges::view_interface, and static_assert checks of the range concepts.
What you will learn
- Write an iterator and sentinel pair that models a range
- Derive a view from std::ranges::view_interface and use what it adds
- Prove with static_assert which range concepts a type models
- Identify which iterator category an iterator reaches from the operations it defines
Before you start
What you’ll learn · Before you start
What you will learn
- Write an iterator and sentinel pair that models a range
- Derive a view from std::ranges::view_interface and use what it adds
- Prove with static_assert which range concepts a type models
- Identify which iterator category an iterator reaches from the operations it defines
Before you start
On this page
The Collatz sequence of 27 has 112 values, the last of them 1, and nothing tells you that length without computing every value. Writing your own C++ ranges turns such a sequence into something every loop, algorithm and view accepts, computed as it is read, with two small types: an iterator: An object that marks a position in a sequence: *it reads the element there and ++it moves to the next one. Every standard container hands out begin(), its first element, and end(), the position just past its last; what else an iterator can do depends on its category. More in “C++ iterators, iterator categories and sentinels”, which produces the elements one at a time, and a sentinel: An object that marks where a range ends by a condition rather than by a position: an iterator is compared with it to ask whether the end has been reached, as with one that equals any pointer at a '\0'. Since C++20 its type may differ from the iterator's. More in “C++ iterators, iterator categories and sentinels”, which recognises where they end.
To the standard library, anything whose begin() returns an iterator and whose end() returns a sentinel that
compares equal to that iterator at the end is a range. A view: A range that does not own its elements: it refers to elements stored elsewhere or computes them, is cheap to copy, and does its work only while it is iterated, as std::views::filter and std::views::iota do. More in “C++ views and pipelines: filter, transform, iota” adds a promise about cost:
copying or moving it does not depend on how many elements it has. Deriving from std::ranges::view_interface makes
a class a view and gives it members such as empty() and front(), and
static_assert(std::ranges::forward_range<MyView>) proves at compile time that the type meets the concept you
claim, before any code depends on it.
A range is a begin() and an end() that meet at the end
A range is checked with concept: A named compile-time test of template arguments, such as std::integral<T>, defined as template <typename T> concept Name = condition;. Used in a template's declaration, it makes the template accept only the types that satisfy it, and a type that fails is reported at the call. More in “C++20 concepts and requires-clauses”, and each concept names a few expressions that must compile:
std::ranges::range<R>:ranges::begin(r)gives an iterator andranges::end(r)gives a sentinel for it. Member functionsbegin()andend()are the usual way to provide them.std::sentinel_for<S, I>: the sentinel can be copied and default-constructed, andit == scompiles. The sentinel may be a type of its own, such asstd::default_sentinel_t, which carries no data at all.std::input_iterator<I>:*itreads an element,++itsteps to the next one, and the iterator names itsvalue_typeanddifference_type. A nestediterator_concepttag says which category it aims for.std::ranges::view<V>: a range that is movable and declared a view, which deriving fromview_interfacedoes. Since P2325R3, a view no longer has to be default-constructible.
Writing your own C++ ranges therefore starts with the iterator: everything else follows from what it can do. Each iterator category: The set of operations an iterator supports: input (read each element once, moving forwards), forward, bidirectional, random access and contiguous (the elements sit side by side in memory). Each category adds operations to the one before it, and an algorithm names the weakest it can work with. More in “C++ iterators, iterator categories and sentinels” adds operations, and each one makes more algorithms and views available:
The iterator categories, each adding to the one above it
Text description of the diagram
The figure stacks the five iterator categories of C++20, each one including everything above it.
- Input: an iterator that reads with *it and steps with ++it, in one pass.
- Forward adds that copies of an iterator replay the same values, and that two iterators can be compared with ==. The CollatzView of this lesson sits at this level, and its box is highlighted.
- Bidirectional adds stepping back with --it.
- Random access adds jumps: it + n, it[n], the distance it - it and comparisons such as <.
- Contiguous adds that the elements sit side by side in memory.
A dashed box below the stack notes that end() may return a sentinel, a type of its own rather than an iterator.
CollatzView, the view of the next section, sits at forward on this ladder: copies of its iterator replay the same values and can be compared, but it cannot step back.
Checkpoint
Check: What the library asks of a range and its iterator
A sentinel ends the Collatz sequence without computing where
The Collatz sequence of a number halves it when it is even and replaces it with 3n + 1 when it is odd, until it
reaches 1. Nobody knows in advance where it ends, which makes it a natural case for a sentinel: end() does not
have to compute the last position, it only has to recognise it. The iterator stores the current value and a flag
that becomes true after it has produced 1, and the sentinel compares equal to an iterator whose flag is set:
// The Collatz sequence of a start value as a view: the iterator computes the next value on ++, and a sentinel ends
// the sequence once it has passed 1. Deriving from view_interface makes it a view and adds front() and empty().
#pragma once
#include <cstdint>
#include <iterator>
#include <ranges>
class CollatzView : public std::ranges::view_interface<CollatzView> {
public:
class iterator {
public:
using iterator_concept = std::forward_iterator_tag;
using value_type = std::uint64_t;
using difference_type = std::ptrdiff_t;
iterator() = default;
explicit iterator(std::uint64_t start) : value_(start) {}
std::uint64_t operator*() const { return value_; }
iterator& operator++() {
if (value_ == 1) {
done_ = true; // 1 was the last value: now the iterator equals the sentinel
} else {
value_ = value_ % 2 == 0 ? value_ / 2 : 3 * value_ + 1;
}
return *this;
}
iterator operator++(int) {
iterator before = *this;
++*this;
return before;
}
bool operator==(const iterator&) const = default;
bool operator==(std::default_sentinel_t) const { return done_; }
private:
std::uint64_t value_ = 1;
bool done_ = false;
};
CollatzView() = default;
explicit CollatzView(std::uint64_t start) : start_(start) {}
iterator begin() const { return iterator{start_}; }
std::default_sentinel_t end() const { return {}; }
private:
std::uint64_t start_ = 1;
};
// The promises, checked by the compiler: if a requirement is missing, the build stops here.
static_assert(std::forward_iterator<CollatzView::iterator>);
static_assert(std::sentinel_for<std::default_sentinel_t, CollatzView::iterator>);
static_assert(std::ranges::forward_range<CollatzView>);
static_assert(std::ranges::view<CollatzView>);
static_assert(!std::ranges::sized_range<CollatzView>); // Using CollatzView: printing it, measuring it, and putting it in a pipeline with the standard views.
#include <algorithm>
#include <cstdint>
#include <print>
#include <ranges>
#include "collatz.hpp"
int main() {
std::println("{}", CollatzView{6});
CollatzView c{27};
std::println("27: {} values, largest {}, first {}", std::ranges::distance(c), std::ranges::max(c), c.front());
auto odd_tens = c | std::views::filter([](std::uint64_t x) { return x % 2 == 1; }) |
std::views::transform([](std::uint64_t x) { return x * 10; }) | std::views::take(5);
std::println("{}", odd_tens);
} from session import run
# C++20 ranges, so both compilers build the same file.
run("""
clang++ -std=c++23 -Wall -Wextra collatz.cpp -o collatz && ./collatz
g++-16 -std=c++23 -Wall -Wextra collatz.cpp -o collatz && ./collatz
""") Output
$ clang++ -std=c++23 -Wall -Wextra collatz.cpp -o collatz && ./collatz [6, 3, 10, 5, 16, 8, 4, 2, 1] 27: 112 values, largest 9232, first 27 [270, 410, 310, 470, 710] $ g++-16 -std=c++23 -Wall -Wextra collatz.cpp -o collatz && ./collatz [6, 3, 10, 5, 16, 8, 4, 2, 1] 27: 112 values, largest 9232, first 27 [270, 410, 310, 470, 710]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 collatz_session.py
The sequence of 6 takes eight steps to reach 1, and the sequence of 27 has 112 values with a peak of 9232, numbers
that the program computed without storing a single value. Step through the calls that any loop over
CollatzView{6} makes, a range-for loop as much as std::println:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- begin() and end(). A loop starts with begin(), an iterator whose value_ is 6 and whose done_ is false, and end(), a std::default_sentinel_t that holds no data at all. Nothing has computed where the sequence stops.
- Is this the end?. Before each value the loop compares the iterator with the sentinel. The iterator answers itself: its operator== with std::default_sentinel_t returns done_, which is false.
- The first value. *it returns value_, so the loop receives 6, the first value printed.
- 6 is even. ++it computes the next value from the current one: 6 is even, so value_ becomes 3. The sequence is computed one step at a time and never stored.
- 3 is odd. The loop compares, reads 3 and steps again: 3 is odd, so value_ becomes 3 × 3 + 1 = 10.
- The same three calls. Compare, read, step: the loop repeats the same three calls for 10, 5, 16, 8, 4 and 2. Each ++ halves an even value or triples an odd one and adds 1.
- 1 arrives. done_ is still false, so the comparison says no, and the loop reads 1, the ninth value.
- ++ after 1. At 1 the sequence has ended, so ++it computes nothing new: it sets done_ to true, and from now on the iterator compares equal to the sentinel.
- The end. The comparison now returns true and the loop stops after [6, 3, 10, 5, 16, 8, 4, 2, 1]. end() never computed the last position; the sentinel only recognised it when the iterator got there.
- Counting is walking. Nothing knows the length in advance, so std::ranges::distance(c) for CollatzView{27} makes the same calls to count the values, 112 of them, and std::ranges::max walks them again to find 9232.
Four details make it work:
begin()andend()areconst, so the view can also be iterated through aconstreference, the way many functions take their ranges.- The iterator defines
operator==withstd::default_sentinel_t, and C++20 derives!=and the reversed operand order from it, so one comparison covers every test the loops and algorithms write. - It also has a default constructor, compares iterators with each other and has a postfix
++that returns the old position, which is whatstd::forward_iteratorrequires in addition to an input iterator. std::ranges::distancewalks the whole sequence to count it, because nothing knows its size in advance; thestatic_assertthat it is not asized_rangedocuments that.
Key idea
end() returns a sentinel that only recognises the end: the iterator’s operator== with it answers from the
iterator’s own state, so the sequence is never computed ahead of the loop.
Checkpoint
Check: An iterator and a sentinel for a sequence that ends on a condition
A base class makes it a view, and assertions prove its concepts
Because CollatzView is a view, the standard views compose with it: filter, transform and take turned the
sequence of 27 into its first five odd values times ten, computed lazily as println asked for them. front() came
from std::ranges::view_interface: A class template of <ranges> that a view derives from, passing its own type: it declares the class a view and gives it members such as empty() and front(), built from the class's own begin() and end(), so the class writes none of them., which builds it from begin().
The five assertions at the end of collatz.hpp state what the type promises, and the compiler checks each against
the members of CollatzView. Step through them in order:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The iterator. The first assertion checks the iterator on its own: *it, ++it and it++, the member types value_type and difference_type, a default constructor and it == it. CollatzView::iterator defines every one of them.
- The sentinel. The second asks whether std::default_sentinel_t can mark the end of that iterator: it can be copied and default-constructed, and it == std::default_sentinel compiles through the operator== that returns done_.
- The view as a range. The third looks at CollatzView itself: begin() returns that iterator and end() that sentinel, so it is a forward range, whose copies of iterators replay the same values.
- The promise about cost. The fourth checks that CollatzView is a view: it can be moved, and deriving from std::ranges::view_interface declared it one. Copying it copies one number, whatever the length of the sequence.
- A promise it does not make. The last assertion states what the type does not do: it has no constant-time size, so nobody expects size(), and std::ranges::distance walks the 112 values of 27 to count them.
- All five hold. Both compilers accepted all five, so the build went on to main. Had a member been missing, the build would have stopped at that assertion, next to the class, before any code used CollatzView.
Five more ideas build on this one. Bidirectional iterators add --it. Random-access iterators add it + n,
it[n] and it - it, which give a range its size. A
range adaptor closure: An object such as std::views::take(2) that takes a range and returns a view, so that r | c means c(r): the form that lets adaptors chain with | into a pipeline. gives an adaptor of your own the | syntax. The
debug mode: A checked build of a standard library, such as libstdc++ with -D_GLIBCXX_DEBUG or libc++'s debug hardening mode, whose containers and iterators check how they are used and stop the program at the first misuse. It is for testing, because it slows code down. More in “std::deque, std::list and iterator invalidation” of GCC’s library checks every step an iterator makes. A borrowed range is
one whose iterators stay valid after the range object itself is gone.
Checkpoint
Check: view_interface and the static_assert proofs
Practise: iterators, sentinels and views
These questions cover the range concepts, the Collatz sentinel and the five assertions; each answer explains itself at once.
Practice that adapts to you
Practise this lesson
9 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Forward, bidirectional and random-access views, proved and broken Pro: opens with a Pro, Premium or Ultimate pass
Each step up the iterator ladder costs a few operations and pays back with algorithms and members that only work from that step on. This part climbs from an endless forward view to a random-access view that binary search can use, and reproduces what both compilers report when one operation is missing.
In this part:
- An endless Fibonacci view, forward and multi-pass
- A random-access view that binary search can use
- One missing operator, reproduced: what each compiler reports
6 runnable examples (C++, Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A stride view for libc++, tested against GCC 16's views::stride Pro: opens with a Pro, Premium or Ultimate pass
The libc++ 22 of Apple clang 21 has no views::stride, so this part builds one: a forward view that never steps past
the end, with the pipe syntax of the standard adaptors, checked against GCC 16’s views::stride on 294 cases.
In this part:
- Your turn: every n-th element on libc++
- The stride view, its iterator and its adaptor object
- Checked against std::views::stride on both compilers
- Stepping past the end, reproduced
- What the standard view does that this one does not
5 runnable examples (C++, C++ header, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Borrowed ranges and dangling iterators, reproduced Pro: opens with a Pro, Premium or Ultimate pass
An algorithm called on a temporary range returns an iterator into something that is about to be destroyed. This part shows how the library turns that bug into a compile error, and when a view of your own may safely declare that its iterators outlive it.
In this part:
- What find returns for a range that is about to die
- Declaring a view of your own borrowed
2 runnable examples (C++, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What is the difference between a range and a view in C++20, and why is std::vector a range but not a view?
A range is anything whose begin() returns an iterator and whose end() returns a sentinel for it, so a vector, a std::list and a pair of iterators wrapped in a subrange are all ranges. A view is a range with an extra promise about cost: copying or moving it, and destroying the copies, does not depend on how many elements it has, and it is declared a view, usually by deriving from std::ranges::view_interface. Views such as views::filter or a string_view refer to elements or compute them, so they are cheap to pass around by value. A std::vector owns its elements: copying one copies every element, so it is a range but not a view, and a pipeline that starts from a vector wraps it in a view first.
-
Mid to senior
A range-for loop over your custom container works, but std::ranges::sort and views::reverse reject it with long errors. How do you find what the iterator is missing, and which operations does each iterator category require?
-
Mid to senior
Design a view over the lines of a text buffer that ends at the first empty line. Would end() return an iterator or a sentinel, and why? What changes for the code that uses the view?
-
Mid to senior
A hand-written stride view advances its iterator with std::ranges::next(it, n). Its tests use vectors whose sizes are multiples of n and pass, but production crashes now and then. Explain the bug, how you would reproduce it, and how std::views::stride avoids it.
Key takeaways
- A range is a
begin()that returns an iterator and anend()that returns a sentinel comparing equal to it at the end, and the operations of the iterator decide which category on the ladder it reaches. - A sentinel of its own type, such as
std::default_sentinel_t, ends a sequence on a condition, soend()never has to compute where the sequence stops. - Make
begin()andend()const, defineiterator_concept,value_typeanddifference_type, and let oneoperator==with the sentinel cover every comparison. - Deriving from
std::ranges::view_interfacemakes the class a view with members such asfront(), andstatic_asserts of the concepts next to the class make a missing operation fail there, before any code uses it.
Terms in this lesson
- range adaptor closure
- An object such as std::views::take(2) that takes a range and returns a view, so that r | c means c(r): the form that lets adaptors chain with | into a pipeline.
- std::ranges::view_interface
- A class template of <ranges> that a view derives from, passing its own type: it declares the class a view and gives it members such as empty() and front(), built from the class's own begin() and end(), so the class writes none of them.
References
- Working Draft, Programming Languages: C++ (N4950), 25 Iterators library and 26 Ranges library (ISO/IEC JTC1/SC22/WG21)
- P2325R3: Views should not be required to be default constructible (ISO/IEC JTC1/SC22/WG21)
- P2387R3: Pipe support for user-defined range adaptors (ISO/IEC JTC1/SC22/WG21)
- P1899R3: stride_view (ISO/IEC JTC1/SC22/WG21)
- libc++ C++23 Status (LLVM Project)
- The GNU C++ Library Manual: Debug Mode (GNU Project)
- AddressSanitizer (Clang documentation) (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress