C++ Views and Range Adaptors Without Confusion: From C++20 to C++23

The standard library provides several closely related abstractions around ranges: views, range adaptor objects, and range adaptor closure objects.

They are easy to mix up because they often appear in the same expression:

auto result = values
    | std::views::filter(predicate)
    | std::views::transform(function);

However, they play different roles:

  • a view is a range type that represents a sequence,
  • a range adaptor object is a callable object that takes a range and produces a view,
  • a range adaptor closure object is a unary callable that can be placed on the right-hand side of |.

This article explains:

  • what views and range adaptors are,
  • how they work together,
  • how ordinary containers are adapted into views,
  • and how to implement a custom pipeable range adaptor in C++23.

The standard views and piping syntax discussed in the first part are available in C++20. The std::ranges::range_adaptor_closure helper used for custom adaptor closures was added in C++23.

View, Adaptor, and Closure: The Mental Model

The easiest way to remove the terminology confusion is to look at the three forms side by side:

std::ranges::filter_view       // a view type
std::views::filter             // a range adaptor object
std::views::filter(predicate)  // a range adaptor closure object

They are related, but they are not interchangeable names for the same thing.

This distinction becomes especially useful once custom adaptors enter the picture.

What Views Are

A view is a lightweight range designed to be cheap to compose and pass around. Many views provide lazy transformations over another range, meaning they do not materialize a new container with all resulting elements in advance.

Laziness, however, is not a requirement of the std::ranges::view concept itself. Types such as std::span, std::string_view, and std::ranges::ref_view are views even though they do not calculate transformed values on demand.

For views that do perform lazy work, the exact moment when that work happens depends on the view.

For example, std::ranges::transform_view applies its transformation when an element is accessed through the iterator. A filter_view, on the other hand, may evaluate its predicate while finding the first element or while advancing the iterator to the next matching element.

Consider a simple transformation from integers to strings:

auto values = std::vector<int>{1, 2, 8, 10, 11};

// No vector<string> is created here.
auto transformed = std::ranges::transform_view(
    values,
    [](int value) {
        return std::to_string(value);
    });

std::vector<std::string> result;

// Elements are transformed while the view is iterated.
std::ranges::copy(transformed, std::back_inserter(result));

Constructing transformed does not produce five std::string objects and store them in a new container. The strings are generated as the view is iterated by std::ranges::copy.

In C++23, materialization can often be written more directly with std::ranges::to:

// C++23
auto result = transformed | std::ranges::to<std::vector<std::string>>();

The main benefit of views is not merely laziness. It is composition: one view can be built on top of another, creating a processing pipeline without intermediate containers.

Creating a custom view is also a good way to understand how views work internally. That is the focus of my other article, Constructing Views in Modern C++: A Practical Guide.

Composing Views Directly

A common operation that does not have a dedicated transform_if algorithm is filtering a sequence first and transforming only the remaining elements.

Suppose we want to convert only odd numbers into strings. It can be expressed directly with filter_view and transform_view:

auto values = std::vector<int>{1, 2, 8, 10, 11};

auto transformed = std::ranges::transform_view(
    std::ranges::filter_view(
        values,
        [](int value) {
            return value % 2 != 0;
        }),
    [](int value) {
        return std::to_string(value);
    });

std::vector<std::string> result;
std::ranges::copy(transformed, std::back_inserter(result));

The resulting vector contains:

["1", "11"]

This works, but even a composition of two views is already becoming harder to read. The syntax is nested inside-out: first read filter_view, then move outward to transform_view.

Range adaptors provide a more convenient interface for this kind of composition.

Range Adaptor Objects

A range adaptor object is a callable customization-point object that accepts a std::ranges::viewable_range as its first argument and returns a view.

The standard range adaptor objects live in the std::views namespace, which is an alias for std::ranges::views.

For many adaptors, there is a directly corresponding view type:

Range adaptor objectCorresponding view
std::views::filterstd::ranges::filter_view
std::views::transformstd::ranges::transform_view
std::views::take_whilestd::ranges::take_while_view
std::views::dropcommonly std::ranges::drop_view

However, the relationship is not always strictly one-to-one. A range adaptor object is an interface, not merely an alias for a view constructor.

For example, std::views::drop can use specialized representations for certain input ranges instead of always producing a std::ranges::drop_view. This is one reason to prefer the adaptor interface in ordinary application code: the adaptor can select an appropriate representation for the particular input range.

A direct call looks like this:

auto values = std::vector<int>{1, 2, 8, 10, 11};

// Has a std::ranges::drop_view type
auto dropped = std::views::drop(values, 2);

std::vector<int> result;
std::ranges::copy(dropped, std::back_inserter(result));

The resulting vector contains:

[8, 10, 11]

However, when std::views::drop is applied to a std::string_view or std::span, the adaptor can return another std::string_view or std::span instead of constructing a std::ranges::drop_view:

std::string_view original{"Some String"};

// Has type std::string_view
auto dropped = std::views::drop(original, 5);

std::cout << dropped << '\n';

The code prints:

String

An interesting detail is that std::views::drop, std::views::filter, and the other standard adaptors are not ordinary functions. They are inline constexpr objects of unspecified callable types.

Conceptually, the declaration looks like this:

namespace std::ranges::views {
    inline constexpr /* unspecified */ drop = /* unspecified */;
}

This callable-object design allows range adaptors to provide flexible interfaces. In particular, adaptors that accept additional arguments can support both direct calls:

std::views::drop(range, 5)

and partial application:

std::views::drop(5)

The second form produces a range adaptor closure that can later be applied to a range using the pipe operator.

Range Adaptor Closure Objects and Piping

A range adaptor closure object is a unary callable that accepts a range. For a closure closure and a range range, these expressions have the same meaning:

closure(range);
range | closure;

Parameterized range adaptor objects such as std::views::drop, std::views::filter, and std::views::transform support partial application.

For example:

auto skip_two = std::views::drop(2);

skip_two is a range adaptor closure object. The range argument has not been supplied yet; the value 2 has been bound into the closure.

It can then be applied using either syntax:

auto a = skip_two(values);
auto b = values | skip_two;

The same mechanism makes the earlier filter-and-transform example considerably easier to read:

auto values = std::vector<int>{1, 2, 8, 10, 11};

auto transformed = values
    | std::views::filter([](int value) {
          return value % 2 != 0;
      })
    | std::views::transform([](int value) {
          return std::to_string(value);
      });

std::vector<std::string> result;
std::ranges::copy(transformed, std::back_inserter(result));

Now the operations are read from left to right in the same order in which they are conceptually applied:

values -> filter odd values -> convert them to strings

Two range adaptor closure objects can also be composed before a range is supplied:

auto odd_strings =
    std::views::filter([](int value) {
        return value % 2 != 0;
    })
    | std::views::transform([](int value) {
          return std::to_string(value);
      });

auto transformed = values | odd_strings;

This makes reusable pipelines possible without introducing intermediate containers.

Direct View Construction vs. Range Adaptors

The following two forms express essentially the same transformation:

auto direct = std::ranges::transform_view(
    std::ranges::filter_view(values, predicate),
    function);

and:

auto piped = values
    | std::views::filter(predicate)
    | std::views::transform(function);

The first form names the concrete view types explicitly. That can be useful when discussing implementation details or when the exact type matters.

The second form focuses on the transformation pipeline. For application code, it is usually easier to read and gives the adaptor object room to choose the appropriate view representation.

How Containers Become Views

There is one detail that may look suspicious in the previous examples.

std::ranges::transform_view is parameterized by an underlying view, but we constructed it directly from a std::vector:

auto values = std::vector<int>{1, 2, 8, 10, 11};

auto transformed = std::ranges::transform_view(
    values,
    [](int value) {
        return std::to_string(value);
    });

std::vector is a range, but it is not a view. So why does this compile?

The answer is class template argument deduction. transform_view has a deduction guide equivalent to:

template<class R, class F>
transform_view(R&&, F)
    -> transform_view<std::views::all_t<R>, F>;

std::views::all_t<R> selects an appropriate view type for the supplied range.

For an lvalue std::vector<int>, this is typically a std::ranges::ref_view<std::vector<int>>, which refers to the existing vector instead of copying it.

Conceptually, the conversion looks like this:

More generally, std::views::all can:

  • preserve an object that is already a view,
  • create a ref_view for a suitable lvalue range,
  • or create an owning_view when ownership of an rvalue range is appropriate.

So the deduction guide does not magically turn std::vector itself into a view. It selects a suitable view wrapper through std::views::all_t.

Non-Owning Views Make Object Lifetime Matter

Views are lightweight partly because they often avoid owning the data they expose.

That also means you need to understand which object actually owns the data, how long it lives, and when a reference or view can become dangling.

My free C++ Object Lifetime: A Practical Guide covers ownership, constructors and destructors, copy/move semantics, RAII, and the lifetime rules behind these decisions.

Creating a Custom Range Adaptor Closure in C++23

Standard range adaptor closures have existed as part of the ranges machinery since C++20, but C++20 did not provide a public standard helper for making user-defined closure types participate in the same piping machinery.

C++23 added std::ranges::range_adaptor_closure for this purpose.

A type derived from it can define the unary call operator, while the standard library supplies the integration required for expressions such as:

range | closure

and closure composition:

closure1 | closure2

Consider a custom adaptor called filter_not. It should behave like std::views::filter, except that it keeps elements for which the predicate returns false.

First, define the closure that stores a bound predicate:

#include <functional>
#include <ranges>
#include <utility>

template<class Pred>
class filter_not_closure
    : public std::ranges::range_adaptor_closure<
          filter_not_closure<Pred>> {
public:
    explicit filter_not_closure(Pred pred)
        : pred_(std::move(pred)) {
    }

    template<std::ranges::viewable_range R>
    auto operator()(R&& range) const {
        return std::views::filter(
            std::forward<R>(range),
            std::not_fn(pred_));
    }

private:
    Pred pred_;
};

filter_not_closure has one job: store the predicate and apply it to a range. Because it derives from std::ranges::range_adaptor_closure, an object of this type can participate in standard piping.

We could use the closure type directly:

auto view = values
    | filter_not_closure{
          [](int value) {
              return value % 2 == 0;
          }};

However, the standard library usually exposes a separate stateless adaptor object such as std::views::filter. We can mimic that interface as well:

struct filter_not_fn {
    template<std::ranges::viewable_range R, class Pred>
    auto operator()(R&& range, Pred pred) const {
        return std::views::filter(
            std::forward<R>(range),
            std::not_fn(std::move(pred)));
    }

    template<class Pred>
    auto operator()(Pred pred) const {
        return filter_not_closure<Pred>{std::move(pred)};
    }
};

inline constexpr filter_not_fn filter_not{};

Now filter_not supports both styles:

auto direct = filter_not(
    values,
    [](int value) {
        return value % 2 == 0;
    });

and partial application with piping:

auto view = values
    | filter_not([](int value) {
          return value % 2 == 0;
      })
    | std::views::transform([](int value) {
          return std::to_string(value);
      });

std::vector<std::string> result;
std::ranges::copy(view, std::back_inserter(result));

The resulting vector contains only the odd values converted to strings:

["1", "11"]

The architecture now mirrors the standard-library model:

filter_not                  -> std::views-style adaptor object
filter_not(predicate)       -> range adaptor closure object
range | filter_not(...)     -> resulting view

For a production-quality generic adaptor, additional overloads may be useful to preserve predicate value categories and support move-only state where appropriate. The simplified implementation above deliberately keeps that machinery out of the way so the relationship between adaptor, closure, and view remains visible.

Final Thoughts

Views, range adaptor objects, and range adaptor closure objects solve different parts of the same problem.

A view represents a range. It may provide a lazy transformation, reference existing data, own another range, or generate values. What matters is that it satisfies the view requirements and can participate efficiently in range composition.

A range adaptor object is the convenient entry point for constructing views. It accepts a viewable_range, may accept additional arguments, and can choose the appropriate view representation for the input.

A range adaptor closure object is what makes the pipeline syntax possible. It represents a transformation waiting for its range argument:

std::views::filter(predicate)

and can therefore be applied as:

range | std::views::filter(predicate)

The practical model is short:

view                   = the range representation
range adaptor object   = the view-producing interface
range adaptor closure  = a bound transformation that can be piped

C++20 provides the standard views, standard adaptor objects, and the piping model used by std::views. C++23 adds std::ranges::range_adaptor_closure, making it much easier to integrate user-defined closures into that same model.

In ordinary code, prefer standard range adaptors when they express the operation clearly. Reach for concrete view types when you need to understand or control the underlying representation. And when the standard adaptors are not enough, a small custom adaptor can package a reusable transformation without giving up lazy composition.

Once these three abstractions are separated mentally, the ranges library becomes considerably less mysterious. There is still plenty of template machinery underneath, naturally. It is C++, after all.