C++11 C++17: sentinel types
Range-based for is not magic reserved for standard containers — it is a small, documented protocol, and any type that implements it becomes iterable. This page implements the protocol three ways: on a type you own, on a type you can't modify, and with a sentinel where the end of the range is a condition rather than a position.
The protocol
From the loop's expansion, the compiler needs three things:
beginandend— member functionsr.begin()/r.end()if they exist, otherwise free functionsbegin(r)/end(r)found by argument-dependent lookup (ADL).- The returned iterator must support
operator*(produce the element),operator++(advance, prefix form), andoperator!=against whateverendreturned. - Nothing else. No tags, no base classes, no standard headers.
A minimal range you own
A lazy integer range — it stores two ints and manufactures values on the fly:
#include <print>
// The half-open range [first, last), generated lazily.
class NumberRange {
public:
class iterator {
public:
explicit iterator(int value) : value_{value} {}
int operator*() const { return value_; }
iterator& operator++() { ++value_; return *this; }
bool operator!=(const iterator& other) const { return value_ != other.value_; }
private:
int value_;
};
NumberRange(int first, int last) : first_{first}, last_{last} {}
iterator begin() const { return iterator{first_}; }
iterator end() const { return iterator{last_}; }
private:
int first_;
int last_;
};
int main() {
for (int v : NumberRange{1, 6}) {
std::print("{} ", v); // 1 2 3 4 5
}
std::println("");
}
That's the entire cost of entry: ~20 lines. Both accessors are const, so const NumberRange& parameters iterate too. For a container that owns elements, the same shape applies with operator* returning T& — and you typically provide a const overload of begin/end returning a const-element iterator so read-only access stays read-only.
For real libraries, one more step is worth it: give the iterator the standard member aliases (value_type, difference_type, reference, plus post-increment and ==). That upgrades it from "works with range-for" to satisfying std::ranges::range, which unlocks the entire ranges library: NumberRange{1, 6} | std::views::filter(...).
A type you can't modify
Third-party and C-style types can't grow member functions, so use the free-function half of the protocol. The functions must live in the same namespace as the type — that is what ADL means:
#include <cstddef>
#include <print>
namespace vendor {
// Imagine this comes from a header you do not control.
struct Buffer {
int* data;
std::size_t size;
};
// Adapters in the type's own namespace, so ADL finds them.
int* begin(Buffer& b) { return b.data; }
int* end(Buffer& b) { return b.data + b.size; }
const int* begin(const Buffer& b) { return b.data; }
const int* end(const Buffer& b) { return b.data + b.size; }
}
int main() {
int storage[]{10, 20, 30};
vendor::Buffer buf{storage, 3};
for (int v : buf) std::print("{} ", v);
std::println("");
}
Raw pointers are already perfectly good iterators — *, ++, and != all work — which is why this adapter needs no iterator class at all. Note the pitfall: putting begin/end in your namespace (or globally) does not work reliably; ADL searches the namespace of the argument's type.
Sentinel ranges: when the end is a condition
C++17 relaxed the protocol: begin and end may return different types. That turns "end" from a position into a predicate — exactly right for ranges whose length you don't know up front, like a null-terminated string, a socket stream, or a token sequence:
#include <print>
// End is not a place; it is the condition "*p == '\0'".
struct CStringSentinel {};
class CStringIterator {
public:
explicit CStringIterator(const char* p) : p_{p} {}
char operator*() const { return *p_; }
CStringIterator& operator++() { ++p_; return *this; }
bool operator!=(CStringSentinel) const { return *p_ != '\0'; }
private:
const char* p_;
};
class CString {
public:
explicit CString(const char* s) : s_{s} {}
CStringIterator begin() const { return CStringIterator{s_}; }
CStringSentinel end() const { return {}; }
private:
const char* s_;
};
int main() {
for (char c : CString{"wow"}) std::print("[{}]", c);
std::println("");
}
Without a sentinel, iterating a C string means an up-front strlen — a full extra pass — just to manufacture an end position. The sentinel folds the termination test into operator!=, one pass total. This same design is load-bearing across std::ranges, where std::default_sentinel and unbounded ranges (std::unreachable_sentinel) are everyday tools.
Guidelines
- Prefer member
begin/endon types you own; use ADL free functions to adapt types you don't. - Always provide
constiteration; loops overconst Type&are the common case. - A minimal iterator is three operators — start there, and add the standard aliases when you want ranges-library compatibility.
- Reach for a sentinel whenever computing the end position costs a pass over the data.
- Don't hand-write an iterator for a type that just wraps a container — forward
begin()/end()to the member and be done.