C++ Custom Ranges

Introduction: “I want my custom container to be a range”

Problem scenarios

Have you built a domain-specific container or wrapper and found you cannot use for (auto x : myContainer) or std::ranges::sort() on it? Standard vector and array work with range-based for and ranges algorithms out of the box, but your own type may not compile.

// A domain-specific container we wrote
class SensorBuffer {
    std::vector<double> data_;
public:
    void push(double v) { data_.push_back(v); }
    double* raw_data() { return data_.data(); }
};
int main() {
    SensorBuffer buf;
    buf.push(1.0); buf.push(2.0);
    // ❌ Compile error: no begin/end
    // for (auto x : buf) { ... }
    // std::ranges::sort(buf);
}

Issues you see in production:

  • Log buffer: You want to traverse LogBuffer and process only the last N logs, but without begin/end you are stuck with manual indexing.
  • Network packet stream: You want ranges::find_if on PacketStream, but it is not a range so algorithms do not apply.
  • Slicing: You want to pass only part of a vector to ranges::sort without copying everything—without subrange you end up copying.
  • Filter/transform pipelines: You want | filter(...) | transform(...) on a domain type, but there is no adaptor.

Cause: SensorBuffer has no begin()/end(), so it does not model std::ranges::range. Fix: Provide begin/end (or a sentinel—a type marking the end for an iterator).

flowchart LR
  subgraph before["Before: not a range"]
    B1[SensorBuffer] --> B2[no begin/end]
    B2 --> B3[for-range ❌]
    B2 --> B4["ranges sort ❌"]
  end
  subgraph after["After: satisfies range"]
    A1[CustomRange] --> A2[begin/end provided]
    A2 --> A3[for-range ✅]
    A2 --> A4["ranges sort ✅"]
  end

Goals:

  • Design a type that satisfies std::ranges::range
  • Provide iterators (or sentinels)
  • Optionally use view_interface so the type behaves like a view

After reading this post you will:

  • Expose custom containers as ranges
  • Implement iterators and sentinels with minimal surface area
  • Cover range adaptors, sentinel-based ranges, and production patterns

Practical experience: This article is based on real problems and fixes from large C++ codebases. It includes pitfalls and debugging tips that textbooks often skip.

Range requirements

Minimum conditions

To be a range:

  • std::ranges::begin(r) is valid (returns an iterator)
  • std::ranges::end(r) is valid (returns an iterator or sentinel)
  • You can iterate with begin(r) and end(r)

Provide begin() / end() as members, or make begin / end discoverable via ADL. std::ranges::begin(r) calls r.begin() (or begin(r)) internally; end(r) works the same way. If both return the right types, std::ranges::range<MyRange> holds. A static_assert at compile time documents that your type is a range and keeps ranges::sort and for (auto x : myRange) working.

class MyRange {
public:
    auto begin() const { /* ... */ }
    auto end() const { /* ... */ }
};
static_assert(std::ranges::range<MyRange>);

Complete custom range implementation (begin/end iterators)

Overall structure

A custom range pairs an iterator type with a range type. Iterators need operator++, operator*, and operator== (or !=).

flowchart TB
  subgraph range[CustomRange]
    R_begin[begin]
    R_end[end]
  end
  subgraph iter[Iterator]
    I_inc[operator++]
    I_deref[operator*]
    I_eq[operator==]
  end
  R_begin --> I_inc
  R_end --> I_eq

Iterator implementation (satisfying input_iterator)

Below is a full custom iterator example. To model std::input_iterator, iterator_traits must line up correctly.

#include <iterator>
#include <ranges>
// 1. Iterator type
template <typename T>
class SliceIterator {
    T* ptr_ = nullptr;
    T* end_ = nullptr;
public:
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    using iterator_category = std::input_iterator_tag;
    SliceIterator() = default;
    SliceIterator(T* p, T* e) : ptr_(p), end_(e) {}
    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }
    SliceIterator& operator++() {
        ++ptr_;
        return *this;
    }
    SliceIterator operator++(int) {
        auto tmp = *this;
        ++*this;
        return tmp;
    }
    friend bool operator==(const SliceIterator& a, const SliceIterator& b) {
        return a.ptr_ == b.ptr_;
    }
};
// 2. iterator_traits specialization (often unnecessary in C++20)
template <typename T>
struct std::iterator_traits<SliceIterator<T>> {
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    using iterator_category = std::input_iterator_tag;
};
// 3. Range type
template <typename T>
class SliceRange {
    T* data_ = nullptr;
    std::size_t size_ = 0;
public:
    SliceRange(T* data, std::size_t size) : data_(data), size_(size) {}
    auto begin() const {
        return SliceIterator<T>(data_, data_ + size_);
    }
    auto end() const {
        return SliceIterator<T>(data_ + size_, data_ + size_);
    }
};
// Usage
#include <vector>
int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    SliceRange<int> slice(v.data(), 3);
    for (auto x : slice)
        std::cout << x << " ";  // 1 2 3
    static_assert(std::ranges::range<SliceRange<int>>);
}

Key points:

  • operator*, operator++, operator== are required
  • iterator_category affects algorithm selection (e.g. random_access_iterator enables ranges::sort)
  • begin() and end() return the same iterator type (they may differ when using a sentinel)

Full random_access_iterator (supporting ranges::sort)

ranges::sort needs a random_access_range. The following adds operator+, operator-, operator[], operator<, and friends.

#include <iterator>
#include <ranges>
template <typename T>
class RandomAccessSliceIterator {
    T* ptr_ = nullptr;
public:
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    using iterator_category = std::random_access_iterator_tag;
    RandomAccessSliceIterator() = default;
    explicit RandomAccessSliceIterator(T* p) : ptr_(p) {}
    T& operator*() const { return *ptr_; }
    T& operator[](difference_type n) const { return ptr_[n]; }
    RandomAccessSliceIterator& operator++() { ++ptr_; return *this; }
    RandomAccessSliceIterator operator++(int) {
        auto tmp = *this;
        ++ptr_;
        return tmp;
    }
    RandomAccessSliceIterator& operator--() { --ptr_; return *this; }
    RandomAccessSliceIterator operator--(int) {
        auto tmp = *this;
        --ptr_;
        return tmp;
    }
    RandomAccessSliceIterator& operator+=(difference_type n) {
        ptr_ += n;
        return *this;
    }
    RandomAccessSliceIterator& operator-=(difference_type n) {
        ptr_ -= n;
        return *this;
    }
    RandomAccessSliceIterator operator+(difference_type n) const {
        return RandomAccessSliceIterator(ptr_ + n);
    }
    RandomAccessSliceIterator operator-(difference_type n) const {
        return RandomAccessSliceIterator(ptr_ - n);
    }
    friend RandomAccessSliceIterator operator+(difference_type n,
            const RandomAccessSliceIterator& it) {
        return it + n;
    }
    difference_type operator-(const RandomAccessSliceIterator& other) const {
        return ptr_ - other.ptr_;
    }
    friend bool operator==(const RandomAccessSliceIterator& a,
            const RandomAccessSliceIterator& b) { return a.ptr_ == b.ptr_; }
    friend bool operator<(const RandomAccessSliceIterator& a,
            const RandomAccessSliceIterator& b) { return a.ptr_ < b.ptr_; }
    friend bool operator>(const RandomAccessSliceIterator& a,
            const RandomAccessSliceIterator& b) { return b < a; }
    friend bool operator<=(const RandomAccessSliceIterator& a,
            const RandomAccessSliceIterator& b) { return !(b < a); }
    friend bool operator>=(const RandomAccessSliceIterator& a,
            const RandomAccessSliceIterator& b) { return !(a < b); }
};
template <typename T>
class RandomAccessSliceRange {
    T* data_ = nullptr;
    std::size_t size_ = 0;
public:
    RandomAccessSliceRange(T* data, std::size_t size)
        : data_(data), size_(size) {}
    auto begin() const {
        return RandomAccessSliceIterator<T>(data_);
    }
    auto end() const {
        return RandomAccessSliceIterator<T>(data_ + size_);
    }
};
// Usage: ranges::sort works
// std::vector<int> v = {5, 2, 4, 1, 3};
// RandomAccessSliceRange<int> r(v.data(), v.size());
// std::ranges::sort(r);  // sorts v

Wrapping an existing container (simple wrapper)

Forward iterators from an existing container without copying data.

template <typename C>
class Wrapper {
    C* container = nullptr;
public:
    explicit Wrapper(C& c) : container(&c) {}
    auto begin() const { return container->begin(); }
    auto end() const { return container->end(); }
};
// Usage
std::vector<int> v = {3, 1, 4};
Wrapper w(v);
std::ranges::sort(w);  // sorts v

Range adaptor implementation

A range adaptor takes an existing range and returns a transformed view. To support the pipe | operator, define operator|.

Adaptor structure (closure + operator|)

flowchart LR
  subgraph adaptor[Range adaptor structure]
    A1[range] --> A2["|"]
    A2 --> A3[AdaptorClosure]
    A3 --> A4[returns View]
  end

Filter adaptor example

An adaptor that traverses only elements matching a predicate.

#include <ranges>
#include <algorithm>
#include <iostream>
template <std::ranges::input_range R, typename Pred>
class FilterView : public std::ranges::view_interface<FilterView<R, Pred>> {
    R base_;
    Pred pred_;
public:
    FilterView(R r, Pred p) : base_(std::move(r)), pred_(std::move(p)) {}
    class Iterator {
        std::ranges::iterator_t<R> iter_;
        std::ranges::sentinel_t<R> end_;
        Pred* pred_ = nullptr;
    public:
        using value_type = std::ranges::range_value_t<R>;
        using iterator_category = std::input_iterator_tag;
        Iterator(std::ranges::iterator_t<R> i, std::ranges::sentinel_t<R> e, Pred* p)
            : iter_(i), end_(e), pred_(p) {
            while (iter_ != end_ && !(*pred_)(*iter_)) ++iter_;
        }
        auto& operator*() const { return *iter_; }
        Iterator& operator++() {
            do ++iter_; while (iter_ != end_ && !(*pred_)(*iter_));
            return *this;
        }
        Iterator operator++(int) {
            auto tmp = *this;
            ++*this;
            return tmp;
        }
        friend bool operator==(const Iterator& a, std::ranges::sentinel_t<R> s) {
            return a.iter_ == s;
        }
    };
    auto begin() const {
        return Iterator(std::ranges::begin(base_), std::ranges::end(base_), &pred_);
    }
    auto end() const {
        return std::ranges::end(base_);
    }
};
// Adaptor object (pipe | support)
template <typename Pred>
struct FilterClosure {
    Pred pred_;
    template <std::ranges::range R>
    auto operator()(R&& r) const {
        return FilterView(std::forward<R>(r), pred_);
    }
};
template <std::ranges::range R, typename Pred>
auto operator|(R&& r, const FilterClosure<Pred>& c) {
    return c(std::forward<R>(r));
}
struct FilterAdaptor {
    template <typename Pred>
    auto operator()(Pred p) const {
        return FilterClosure<Pred>{std::move(p)};
    }
};
inline constexpr FilterAdaptor filter;
// Usage
int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    auto evens = v | filter([](int x) { return x % 2 == 0; });
    for (auto x : evens)
        std::cout << x << " ";  // 2 4
}

Stride view adaptor (every N-th element)

Traverse every N-th element.

#include <ranges>
#include <iostream>
template <std::ranges::input_range R>
class StrideView : public std::ranges::view_interface<StrideView<R>> {
    R base_;
    std::ranges::range_difference_t<R> stride_;
public:
    StrideView(R r, std::ranges::range_difference_t<R> s)
        : base_(std::move(r)), stride_(s) {}
    class Iterator {
        std::ranges::iterator_t<R> iter_;
        std::ranges::sentinel_t<R> end_;
        std::ranges::range_difference_t<R> stride_;
    public:
        using value_type = std::ranges::range_value_t<R>;
        using iterator_category = std::input_iterator_tag;
        Iterator(std::ranges::iterator_t<R> i, std::ranges::sentinel_t<R> e,
                 std::ranges::range_difference_t<R> s)
            : iter_(i), end_(e), stride_(s) {}
        auto& operator*() const { return *iter_; }
        Iterator& operator++() {
            for (auto n = stride_; n > 0 && iter_ != end_; --n) ++iter_;
            return *this;
        }
        Iterator operator++(int) {
            auto tmp = *this;
            ++*this;
            return tmp;
        }
        friend bool operator==(const Iterator& a, std::ranges::sentinel_t<R> s) {
            return a.iter_ == s;
        }
    };
    auto begin() const {
        return Iterator(std::ranges::begin(base_), std::ranges::end(base_), stride_);
    }
    auto end() const { return std::ranges::end(base_); }
};
template <typename Diff>
struct StrideClosure {
    Diff stride_;
    template <std::ranges::range R>
    auto operator()(R&& r) const {
        return StrideView(std::forward<R>(r), stride_);
    }
};
template <std::ranges::range R, typename Diff>
auto operator|(R&& r, const StrideClosure<Diff>& c) {
    return c(std::forward<R>(r));
}
struct StrideAdaptor {
    template <typename N>
    auto operator()(N n) const {
        return StrideClosure<N>{n};
    }
};
inline constexpr StrideAdaptor stride;
// Usage: prints 0, 2, 4, 6, 8
// std::vector<int> v = {0,1,2,3,4,5,6,7,8,9};
// for (auto x : v | stride(2)) std::cout << x << " ";

Transform adaptor (minimal)

Prefer the standard std::views::transform.

// Prefer standard views::transform
#include <ranges>
#include <iostream>
int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    auto doubled = v | std::views::transform([](int x) { return x * 2; });
    for (auto x : doubled)
        std::cout << x << " ";  // 2 4 6 8 10
}

Sentinel-based range examples

A sentinel lets end() return a different type than the iterator. When you do not need a full end iterator, you can save memory or comparison work.

Null-terminated C string

A C string ends at '\0'. With a sentinel you can detect the end with *it == '\0' instead of scanning to compare iterators.

#include <ranges>
#include <algorithm>
class CStringIterator {
    const char* ptr_ = nullptr;
public:
    using value_type = char;
    using difference_type = std::ptrdiff_t;
    using iterator_category = std::input_iterator_tag;
    explicit CStringIterator(const char* p) : ptr_(p) {}
    char operator*() const { return *ptr_; }
    CStringIterator& operator++() { ++ptr_; return *this; }
    CStringIterator operator++(int) {
        auto tmp = *this;
        ++*this;
        return tmp;
    }
    bool is_end() const { return *ptr_ == '\0'; }
};
// Sentinel: different type from the iterator
struct CStringSentinel {};
bool operator==(const CStringIterator& it, CStringSentinel) {
    return it.is_end();
}
bool operator==(CStringSentinel, const CStringIterator& it) {
    return it.is_end();
}
class CStringRange {
    const char* str_ = nullptr;
public:
    explicit CStringRange(const char* s) : str_(s) {}
    auto begin() const { return CStringIterator(str_); }
    auto end() const { return CStringSentinel{}; }
};
// Usage
int main() {
    CStringRange r("Hello");
    for (char c : r)
        std::cout << c;  // Hello
    auto count = std::ranges::count(r, 'l');  // 2
}

Line-by-line file reading (sentinel)

Treat EOF like a sentinel, similar in spirit to std::istream iteration.

#include <ranges>
#include <sstream>
#include <string>
#include <iostream>
class LineIterator {
    std::istream* stream_ = nullptr;
    std::string line_;
public:
    using value_type = std::string;
    using difference_type = std::ptrdiff_t;
    using iterator_category = std::input_iterator_tag;
    LineIterator() = default;
    explicit LineIterator(std::istream& s) : stream_(&s) {
        ++*this;  // read first line
    }
    const std::string& operator*() const { return line_; }
    LineIterator& operator++() {
        if (stream_ && std::getline(*stream_, line_)) { /* OK */ }
        else { stream_ = nullptr; }
        return *this;
    }
    LineIterator operator++(int) {
        auto tmp = *this;
        ++*this;
        return tmp;
    }
    bool is_end() const { return stream_ == nullptr; }
};
struct LineSentinel {};
bool operator==(const LineIterator& it, LineSentinel) {
    return it.is_end();
}
bool operator==(LineSentinel, const LineIterator& it) {
    return it.is_end();
}
class LineRange {
    std::istream* stream_ = nullptr;
public:
    explicit LineRange(std::istream& s) : stream_(&s) {}
    auto begin() const { return LineIterator(*stream_); }
    auto end() const { return LineSentinel{}; }
};
// Usage: for (const auto& line : LineRange(std::cin)) { ... }

Why use sentinels?

  • Memory: end() can return a lightweight empty sentinel
  • Comparison: it == sentinel can call it.is_end() once
  • Infinite ranges: A sentinel can mean “never equal” for unbounded sequences

Common mistakes and fixes

Issue 1: Iterator requirements not met

Symptom: std::ranges::sort(my_range) fails — “does not satisfy random_access_iterator”
Cause: sort needs random_access_range. input_iterator alone is not enough.
Fix:

// ❌ Only input_iterator → no sort
using iterator_category = std::input_iterator_tag;
// ✅ Provide random_access_iterator
using iterator_category = std::random_access_iterator_tag;
// Add operator+, operator-, operator[], operator<, ...

Issue 2: Concept check fails

Symptom: static_assert(std::ranges::range<MyRange>) fails
Cause: begin()/end() do not return types that model std::input_or_output_iterator, or the sentinel cannot be compared with begin()’s iterator.
Fix:

// ❌ begin/end return void or wrong types
void begin() const;
// ✅ Return iterators (or sentinels)
auto begin() const { return iterator(...); }
auto end() const { return iterator(...); }  // or sentinel

Issue 3: const correctness

Symptom: Error when calling r.begin() on const MyRange&
Cause: begin()/end() are not const.
Fix:

// ❌ Cannot call begin on const
auto begin() { return ...; }
// ✅ const member functions
auto begin() const { return ...; }
auto end() const { return ...; }

Issue 4: Iterator invalidation

Symptom: Undefined behavior while iterating because the container changed
Cause: Reallocation (e.g. vector::push_back) invalidates iterators.
Fix: Do not mutate the underlying container during traversal, or document lifetime rules clearly (as with span).

Issue 5: Predicate pointer/reference lifetime

Symptom: FilterView stores a lambda; iterators use Pred* pred_; after moving the view, iterators dangle.
Cause: Iterator holds a pointer into moved storage.
Fix:

// ✅ Avoid copying views unsafely, or store the predicate inside the iterator
// or use std::reference_wrapper for the base range only

Issue 6: iterator_reference_t vs value_type

Symptom: transform view’s operator* returns T but traits say T&.
Cause: When operator* returns a proxy or temporary, separate reference from value_type.
Fix:

// ✅ In C++20, iterator_traits often deduce from operator*
// Align value_type, reference, and pointer explicitly when needed
using value_type = std::remove_cvref_t<std::iter_reference_t<Iterator>>;

Issue 7: Template instantiation errors

Symptom: FilterView<std::vector<int>, SomePred> — incomplete type / undefined type
Cause: Lambdas are not default-constructible; copy paths may fail.
Fix:

// ✅ Move Pred consistently
FilterView(R r, Pred p) : base_(std::move(r)), pred_(std::move(p)) {}
// closures: std::move(p)

Issue 8: Rvalue range lifetime

Symptom: Crash after auto v = get_temporary_vector() | filter(pred);
Cause: The view stores a reference to a temporary that is already destroyed.
Fix:

// ✅ Traverse the temporary immediately
for (auto x : get_temporary_vector() | filter(pred)) { ... }
// Or design the view to own/move the range

Issue 9: operator== symmetry

Symptom: it == sentinel works but sentinel == it does not compile
Cause: Only one side of operator== is defined.
Fix:

// ✅ Define both directions (e.g. as friends)
friend bool operator==(const Iterator& it, Sentinel s) { return it.is_end(); }
friend bool operator==(Sentinel s, const Iterator& it) { return it.is_end(); }

Issue 10: Compatibility with default_sentinel_t

Symptom: Errors comparing to std::default_sentinel_t
Cause: Some views use default_sentinel; your iterator must compare if you interop.
Fix:

// ✅ Optionally add operator== with default_sentinel_t
friend bool operator==(const Iterator& it, std::default_sentinel_t) {
    return it.is_end();
}

Best practices

iterator_concept vs iterator_category

In C++20, iterator_concept takes precedence over iterator_category. For std::random_access_iterator, define iterator_concept.

template <typename T>
class MyIterator {
public:
    using iterator_concept = std::random_access_iterator_tag;
    using iterator_category = std::random_access_iterator_tag;
    using value_type = T;
    using difference_type = std::ptrdiff_t;
    // ...
};

copyable vs move-only range

Views should usually be cheap to copy to model std::ranges::view. Copying should copy handles/references, not large buffers.

// ✅ Hold the base range by reference — copying copies the handle
template <typename R>
class MyView {
    R base_;
public:
    MyView(R r) : base_(std::move(r)) {}
};

noexcept

Mark operator++ and operator* noexcept when safe so algorithms can optimize.

T& operator*() const noexcept { return *ptr_; }
Iterator& operator++() noexcept { ++ptr_; return *this; }

Document rules

  • Lifetime: Does the range reference external storage or own it?
  • Invalidation: When do iterators become invalid?
  • Exceptions: Can begin()/end() throw?

Performance comparison

Benchmark scenario

  • Data: 1,000,000 integers
  • Work: filter evens, then sum
ApproachRelative timeMemory
vector + manual loop1.0× (baseline)N * sizeof(int)
views::filter (lazy)~1.0×O(1) extra
Push results into another vector~1.2×~2N
Custom FilterView~1.0×O(1) extra

Takeaways:

  • Lazy views avoid intermediate containers.
  • Pipelines v | filter(p) | transform(f) can fuse into a single pass.
  • For custom ranges, avoid virtual calls and redundant copies in hot iterators.

Optimization tips

// ✅ Pass by reference (avoid copies)
template <std::ranges::input_range R>
class MyView {
    R base_;
public:
    explicit MyView(R&& r) : base_(std::forward<R>(r)) {}
};
// ✅ Cache in the iterator when the same value is read repeatedly

Production patterns

Lazy evaluation

Views compute only when iterated. Building a pipeline costs nothing until you loop.

auto pipeline = v
    | std::views::filter([](auto x) { return x > 0; })
    | std::views::transform([](auto x) { return x * 2; });
// No work yet
for (auto x : pipeline) {  // work happens here
    // ...
}

Infinite range

For endless sequences, make the sentinel never match; use take to bound iteration (like std::views::iota).

class InfiniteIota {
    int start_ = 0;
public:
    class Iterator {
        int value_;
    public:
        explicit Iterator(int v) : value_(v) {}
        int operator*() const { return value_; }
        Iterator& operator++() { ++value_; return *this; }
        Iterator operator++(int) {
            auto tmp = *this;
            ++*this;
            return tmp;
        }
        bool operator==(const Iterator&) const = default;
    };
    struct Sentinel {};
    friend bool operator==(const Iterator&, Sentinel) { return false; }  // never ends
    friend bool operator==(Sentinel, const Iterator&) { return false; }
    explicit InfiniteIota(int start = 0) : start_(start) {}
    auto begin() const { return Iterator(start_); }
    auto end() const { return Sentinel{}; }
};
// Usage: bound with take
// for (auto x : InfiniteIota(0) | std::views::take(10))

Composable pipeline

Reuse a pipeline across ranges.

auto positive_doubled = [](auto&& r) {
    return std::forward<decltype(r)>(r)
        | std::views::filter([](auto x) { return x > 0; })
        | std::views::transform([](auto x) { return x * 2; });
};
std::vector<int> v = {-1, 2, -3, 4};
for (auto x : positive_doubled(v))
    std::cout << x << " ";  // 4 8

Concatenating ranges

Join multiple ranges. In C++23 you can use std::ranges::views::concat.

#include <ranges>
#include <vector>
#include <iostream>
// C++23: v1 | std::views::concat(v2) | std::views::concat(v3)
// Manual: variant or an iterator that switches between two ranges

Type erasure

To treat different range types uniformly you might use std::any or virtual calls—usually slower. Prefer std::variant or templates when you can.


Using view_interface

Inherit to get view helpers

Inheriting std::ranges::view_interface<MyView<R>> can yield empty(), operator bool, size() (for random_access_range), and sometimes data() when you only implement begin / end.

#include <ranges>
template <typename R>
class MyView : public std::ranges::view_interface<MyView<R>> {
    R base_;
public:
    MyView(R r) : base_(std::move(r)) {}
    auto begin() const { return std::ranges::begin(base_); }
    auto end() const { return std::ranges::end(base_); }
};
// empty(), size(), etc. become available where applicable

Worked examples

Index range (Iota-style)

class IotaView {
    int start_, count_;
public:
    IotaView(int start, int count) : start_(start), count_(count) {}
    class Iterator {
        int value_;
        int limit_;
    public:
        Iterator(int v, int lim) : value_(v), limit_(lim) {}
        int operator*() const { return value_; }
        Iterator& operator++() { ++value_; return *this; }
        Iterator operator++(int) {
            auto tmp = *this;
            ++*this;
            return tmp;
        }
        friend bool operator==(const Iterator& a, const Iterator& b) {
            return a.value_ == b.value_;
        }
    };
    auto begin() const { return Iterator(start_, start_ + count_); }
    auto end() const { return Iterator(start_ + count_, start_ + count_); }
};
// 0..4
for (auto i : IotaView(0, 5))
    std::cout << i << " ";

Slice (start index, count)

template <std::ranges::random_access_range R>
class Slice {
    R* r = nullptr;
    std::ranges::range_difference_t<R> start_, count_;
public:
    Slice(R& rng, auto start, auto count)
        : r(&rng), start_(start), count_(count) {}
    auto begin() const {
        return std::ranges::begin(*r) + start_;
    }
    auto end() const {
        return std::ranges::begin(*r) + start_ + count_;
    }
};

Making SensorBuffer a range (fixing the opening example)

#include <ranges>
#include <vector>
#include <algorithm>
#include <iostream>
class SensorBuffer {
    std::vector<double> data_;
public:
    void push(double v) { data_.push_back(v); }
    double* raw_data() { return data_.data(); }
    std::size_t size() const { return data_.size(); }
    // ✅ Add begin/end → satisfies range
    auto begin() { return data_.begin(); }
    auto end() { return data_.end(); }
    auto begin() const { return data_.begin(); }
    auto end() const { return data_.end(); }
};
int main() {
    SensorBuffer buf;
    buf.push(1.0); buf.push(2.0); buf.push(3.0);
    for (auto x : buf)
        std::cout << x << " ";  // 1 2 3
    std::ranges::sort(buf);
}

Implementation checklist

When you build a custom range, verify:

  • begin() / end() (or sentinel) provided
  • Iterator implements operator*, operator++, operator==
  • iterator_category or iterator_concept defined
  • begin() / end() callable on const objects
  • static_assert(std::ranges::range<MyRange>) passes
  • Lifetime documented (especially for non-owning ranges)
  • random_access_iterator if you need ranges::sort
  • Adaptors use operator| and a closure object
  • With view_interface, begin/end may be enough for empty, etc.

Range pieces and what each needs

TopicMeaning
rangeProvides begin/end (or sentinel)
Iterator++, *, ==/!=, traits-compatible
sentinelEnd type comparable with the iterator
Range adaptorChaining with pipe |
view_interfaceDerive empty, size, …
Lazy / infiniteCompute on traversal; bound with take

FAQ (inline)

Q. Do I have to inherit from view_interface?

A. No. It reduces boilerplate when you build views (you get helpers such as empty(), operator bool and, where applicable, size() from your begin/end), but it is not mandatory for every custom range.

Q. How do I combine a custom range with std::views adaptors?

A. Make sure your iterators expose the right category and satisfy the iterator concepts. Incomplete iterators can fail at compile time or cause undefined behavior when composed with adaptors, so add a static_assert(std::ranges::range<MyRange>) (or the stronger concept you need) early.

One-line recap: Get begin/end (or sentinel) right and your type can be a range. Range adaptors plus lazy/infinite patterns cover most real-world uses.

Next: [C++ Hands-On #26-1] constexpr functions and variables