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.
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)); // 8rg::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::trimC++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
};