Skip to content
C++

Writing a Custom Range View

The standard library's view adaptors — filter, transform, take — all follow the same structural pattern. Learning that pattern lets you extend the ranges machinery with your own domain-specific views that integrate seamlessly with the pipe operator and the constrained algorithm suite. This page builds a complete trim_view: a view that strips leading and trailing elements satisfying a predicate, leaving only the interior portion of a range. Along the way we cover every layer of the idiom — the view type itself, the adaptor closure, the adaptor object, and the pipe operator.

What the standard requires of a view

A type is a view if it satisfies std::ranges::view. That concept requires three things: the type must model range (i.e., have begin() and end()), it must be movable, and it must opt in to the enable_view trait. In practice, the easiest way to satisfy all three is to inherit from std::ranges::view_interface<Derived>. That CRTP base generates a large set of derived operations — including empty(), size(), front(), back(), operator[], and the enable_view specialisation — from your begin() and end() alone. You provide those two; the base class provides everything else.

Key constraint: Views must be O(1) to move, copy, and destroy. They should never own the underlying data — only a reference or a copy of the base range (which is itself a view or a lightweight reference). If your view holds an owning container, it violates the view contract.

Step 1 — the trim_view type

Our view wraps a base range R and a predicate P. It strips all leading and trailing elements where the predicate returns true, yielding only the elements between the first and last non-matching positions. Because computing the trimmed endpoints requires iterating the range, we apply lazy evaluation: the endpoints are computed once the first time begin() or end() is called, and cached for subsequent calls. The mutable members hold this cached state.

#include <ranges>
#include <optional>
namespace rg = std::ranges;

template <rg::forward_range R, std::indirect_unary_predicate<rg::iterator_t<R>> P>
    requires rg::view<R>
class trim_view : public rg::view_interface<trim_view<R, P>>
{
public:
    // Constructors
    trim_view() = default;
    constexpr trim_view(R base, P pred)
        : base_{ std::move(base) }
        , pred_{ std::move(pred) }
    {}

    // Access to the underlying range (lvalue and rvalue overloads)
    constexpr R  base() const &  { return base_; }
    constexpr R  base()       && { return std::move(base_); }
    constexpr const P& pred() const { return pred_; }

    // begin() and end() trigger lazy evaluation on first call
    constexpr auto begin()
    {
        ensure_evaluated();
        return *begin_;
    }

    constexpr auto end()
    {
        ensure_evaluated();
        return *end_;
    }

    // size() works when both begin and end are random-access
    constexpr auto size()
        requires rg::sized_range<R>
    {
        ensure_evaluated();
        return rg::distance(*begin_, *end_);
    }

private:
    R base_{};
    P pred_{};

    // Cached endpoints — computed once, stored as optionals so we can
    // distinguish "not yet computed" from "computed to begin(base_)"
    mutable std::optional<rg::iterator_t<R>> begin_;
    mutable std::optional<rg::iterator_t<R>> end_;
    mutable bool evaluated_ { false };

    void ensure_evaluated() const
    {
        if (evaluated_) return;
        evaluated_ = true;

        // Skip leading elements satisfying the predicate
        auto first = rg::begin(base_);
        auto last  = rg::end(base_);
        while (first != last && pred_(*first))
            ++first;

        // Walk backward to find the last non-matching element
        auto it = last;
        while (it != first) {
            --it;
            if (!pred_(*it)) { ++it; break; }
        }

        begin_ = first;
        end_   = it;
    }
};

The template constraints restrict R to a forward range that is already a view (so we are not storing an owning container), and P to an indirect unary predicate that can be called on the range's element type.

Step 2 — user-defined deduction guide

The constraint requires rg::view<R> means that passing a plain std::vector or a raw C array directly to the constructor would fail — those are not views. To accept owning containers and automatically wrap them in views::all() (which converts a range to a view by either borrowing a span or wrapping in ref_view), we add a deduction guide. CTAD then invokes it and deduces R as views::all_t<Rng> automatically.

// Deduction guide: accepts any range, wraps it in views::all
template <rg::forward_range Rng, typename P>
trim_view(Rng&&, P) -> trim_view<rg::views::all_t<Rng>, P>;

// Now both work:
std::vector<int> v { 1,3,5,4,6,3,1 };
trim_view tv1 { v, is_odd };           // CTAD: R = ref_view<vector<int>>
trim_view tv2 { v | rg::views::all, is_odd }; // R = ref_view<vector<int>}

Step 3 — the range adaptor closure and adaptor

A bare trim_view{r, pred} construction works, but it does not integrate with the pipe operator. To make r | views::trim(pred) work, we need two additional helper types that the standard library uses internally for all its own adaptors. The range adaptor closure stores the predicate and overloads operator()(R&&) to apply it. The range adaptor is the named object you interact with — it overloads operator()(R&&, P) for direct call and operator()(P) for partial application (which returns a closure).

namespace details
{
    // Closure: holds the predicate, applies to a range when called or piped
    template <typename P>
    struct trim_view_range_adaptor_closure
    {
        P pred;
        constexpr explicit trim_view_range_adaptor_closure(P p) : pred{ std::move(p) } {}

        template <rg::forward_range R>
        constexpr auto operator()(R&& r) const
        {
            return trim_view(std::forward<R>(r), pred);
        }
    };

    // Pipe operator: r | closure(pred) → closure(pred)(r)
    template <rg::forward_range R, typename P>
    constexpr auto operator|(R&& r, trim_view_range_adaptor_closure<P> const& closure)
    {
        return closure(std::forward<R>(r));
    }

    // Adaptor: the named object. Direct call or partial application.
    struct trim_view_range_adaptor
    {
        // Direct call: views::trim(r, pred)
        template <rg::forward_range R, typename P>
        constexpr auto operator()(R&& r, P pred) const
        {
            return trim_view(std::forward<R>(r), std::move(pred));
        }

        // Partial application: views::trim(pred) returns a closure
        template <typename P>
        constexpr auto operator()(P pred) const
        {
            return trim_view_range_adaptor_closure<P>{ std::move(pred) };
        }
    };
} // namespace details

// Public-facing entry point
namespace views
{
    inline constexpr details::trim_view_range_adaptor trim;
}

The adaptor is an inline constexpr object — a stateless callable. The two operator() overloads cover both the direct-call form and the partial-application form that feeds the pipe operator.

Step 4 — using the custom view

With all three pieces in place — the view type, the deduction guide, and the adaptor machinery — trim_view behaves identically to a standard library view. It composes with other adaptors, it works with the constrained algorithms, and it is lazy: the endpoints are computed only when you first iterate, not when you construct the view.

#include <print>
#include <vector>

auto is_odd = [](int n) { return n % 2 != 0; };

std::vector<int> numbers { 1, 3, 5, 4, 6, 8, 3, 1 };

// Direct construction — valid but verbose
trim_view tv1 { numbers, is_odd };

// Via adaptor object
auto tv2 = views::trim(numbers, is_odd);

// Pipe operator — most idiomatic
for (int x : numbers | views::trim(is_odd))
    std::print("{} ", x);
// Output: 4 6 8    (leading odds 1,3,5 and trailing odds 3,1 stripped)

// Composes with other std::views
for (int x : numbers | views::trim(is_odd) | std::views::reverse)
    std::print("{} ", x);
// Output: 8 6 4

// Works with constrained algorithms
namespace rg = std::ranges;
auto trimmed = numbers | views::trim(is_odd);
std::println("max interior: {}", rg::max(trimmed));   // 8
Laziness in action: In the code above, none of the trim_view objects actually scan the vector until you begin iterating — either with the range-for loop or by calling rg::max. Constructing tv1 and tv2 stores only the reference and predicate; no element is touched.

The four-layer view architecture

Every standard library view — filter_view, transform_view, take_view — is built from the same four layers. Once you recognise the pattern you can read any view implementation in the standard library headers.

1. The view type

Derives from view_interface<Derived> via CRTP. Holds the base range (as a view) and any parameters. Implements begin() and end() — possibly with lazy evaluation.

trim_view<R, P>

2. Deduction guide

Converts owning ranges to views::all_t<R> at construction time so callers can pass plain vectors and arrays directly.

trim_view(Rng&&, P) -> trim_view<views::all_t<Rng>, P>

3. Range adaptor closure

Stores the parameters (e.g., the predicate). Has operator()(R&&) to apply to a range, and pipes via operator| with ranges.

trim_view_range_adaptor_closure<P>

4. Range adaptor object

The public-facing inline constexpr object. Has two overloads: direct call with (range, args) and partial application with (args) returning a closure.

views::trim

C++23: range_adaptor_closure base class

C++23 adds std::ranges::range_adaptor_closure<T> as a CRTP base class. Inheriting from it automatically provides the pipe operator for your closure type, eliminating the need to write the operator| overload manually.

// C++23: inherit from range_adaptor_closure instead of writing operator|
template <typename P>
struct trim_view_closure
    : std::ranges::range_adaptor_closure<trim_view_closure<P>>
{
    P pred;
    constexpr explicit trim_view_closure(P p) : pred{ std::move(p) } {}

    template <std::ranges::forward_range R>
    constexpr auto operator()(R&& r) const
    {
        return trim_view(std::forward<R>(r), pred);
    }
    // operator| provided by the base class — no need to write it
};
Sign in to track progress