Range Properties of the Standard Range Adaptors

Enumerate the basic usages and properties of C++20-26 Range Adaptors.

Contents

In this post, “range adaptors” refer to both range factories (algorithm that produce range, can only be the starting point of a pipeline, like views::single) and (real) range adaptors (algorithm that takes a range and return an adapted range, like views::filter). In C++20 standard, following the adoption of Ranges TS, the standard adopted 18 range adaptors:

Of course, this is only a small subset of what is provided in range-v3 (over 100 adaptors). C++23 greatly expanded range support in multiple ways, including the addition of 14 more adaptors:

and C++26 is expected to provide even more (concat and maybe being the most expected ones).

Each adaptor has its own use case, feature, and limitations. Especially, each adaptors has its own accepted range properties, and the output range’s properties also differ. These properties limitations are often not documented, in standard or elsewhere, making determine those properties a pain. Therefore, this post serves as an expansion upon the excellent post by Barry Revzin, adding more range adaptors and adding more properties so that the reference is more complete.

This post still follows the same convention set in the above linked post (W w meaning type and value, [T] means range with refernece type T, (A, B) means tuple<A, B>, A -> B means function taking A and returning B), and the properties surveyed are the original ones (reference, category, common, sized, const-iterable, borrowed) plus an additional one: constant (also, value type is included for convenience). A range being a constant range simply means that its iterator are constant iterator, i.e. we cannot modify its elements using its iterators, so const vector<int> is a constant range but vector<int> is not. Notice that all of those 7 categories can be detected by concepts:

Note also that value type and reference type are two entirely different beast. Reference type is the type returned by operator*, and also the type that you interact with more commonly (ranges in this post is referred to as [T], where T is its reference type), basically you can think reference type as the element type (it is not necessarily a language reference). Value type is often a cvr-unqualified type that serves as “value of the same type of the element”, which is commonly just reference type minus cvref qualifiers, but not necessarily (value type and reference type can be completely unrelated, as long as they have a common reference).

All of the original descriptions and properties are copied here, credit belongs to the original author.

C++20 Range Adaptors

Factories

views::empty<T>: [T&]

Produces an empty range of type T.

views::single(t: T) -> [T&]

Produce a range that only contains a single value: t.

views::iota(beg: B[, end: E]) -> [B]

Produce a range that start at beg, and incrementing forever (when there is only one argument) or until beg == end (exclude end as usual).

>>> iota(0)
[0, 1, 2, ...]
>>> iota(0, 5)
[0, 1, 2, 3, 4]
>>> iota(beg, end)
[beg, beg + 1, beg + 2, ..., end - 1]

(Note that B and E can be any type, not just integral)

views::istream<T>(in: In) -> [T&]

Produce a range of T such that elements are read by in >> t (read one element per increment).

views::counted(it: It, n: N) -> [*It]

This is not a real range adaptor (there is no counted_view). Instead, it is an adaptor that adapt the range represented as [it, it + n) (begin + count) as the standard iterator-sentinel model. It adapts by construct a std::span or ranges::subrange.

Real Adaptors

views::all(r: [T]) -> [T]

Still, views::all is a semi-range adaptor; there is no all_view. Essentially, views::all(r) is a view of all the elements in r, which it done wrapping by either return auto(r) directly (if r is already a view), wrap in ref_view (if r is a lvalue), or wrap in owning_view otherwise. Therefore, all of views::all(r)’s range properties are exactly identical to that of r’s.

views::filter(r: [T], f: T -> bool) -> [T]

Produce a new range that only preserve elements of r that let f(e) evaluate to true.

>>> filter([1, 2, 3, 4], e => e % 2 == 0)
[2, 4]

views::transform(r: [T], f: T -> U) -> [U]

Return a new range such that each element is f(e) (where e is each element in r). Commonly called map in other languages.

>>> transform(["aa", "bb", "cc", "dd"], e => e[0])
['a', 'b', 'c', 'd']

views::take(r: [T], n: N) -> [T]

Produce a new range consists of the first n elements of r. If r has less than n elements, contains all of r’s elements.

>>> take([1, 2, 3, 4], 2)
[1, 2]
>>> take([1, 2, 3, 4], 8)
[1, 2, 3, 4]

Note that views::take will produce r’s type whenever possible (for example, empty_view passed in will return an empty_view).

views::take_while(r: [T], f: T -> bool) -> [T]

Produce a new range that includes all the element of r that makes f(e) evaluates to true until it first evaluates to false. (i.e. filter but stop when first false)

>>> take_while([1, 2, 3, 1, 2, 3], e => e < 3)
[1, 2]

views::drop(r: [T], n: N) -> [T]

Produce a new range consists of the all but the first n elements of r. If r has less than n elements, produce an empty range.

>>> drop([1, 2, 3, 4], 2)
[3, 4]
>>> drop([1, 2, 3, 4], 8)
[]

Note that views::drop will produce r’s type whenever possible (for example, empty_view passed in will return an empty_view).

views::drop_while(r: [T], f: T -> bool) -> [T]

Produce a new range that excludes the element of r until the first element that makes f(e) evaluates to false. (i.e. drop but stop when first false)

>>> drop_while([1, 2, 3, 1, 2, 3], e => e < 3)
[3, 1, 2, 3]

views::join(r: [[T]]) -> [T]

Join together a range of several range-of-Ts into a single range-of-T. Commonly called flatten in other languages.

>>> join([[1, 2], [3], [4, 5, 6]])
[1, 2, 3, 4, 5, 6]

views::lazy_split(r: [T], p: T | [T]) -> [[T]]

(The fixed version after C++20 DR P2210R2) Produce a range that splits a range of T into a range of several range-of-Ts based on delimeter (which can be a single element or a continuous subrange).

>>> lazy_split("a bc def", ' ')
["a", "bc", "def"]
>>> lazy_split("a||b|c||d", "||")
["a", "b|c", "d"]
>>> lazy_split("abcd", "")  # when size = 0, just split at every element
["a", "b", "c", "d"]

Note that lazy_split is maximally lazy, it will never touch any element until you increment to the element (i.e. will not compute any “next pattern position”), and thus support input ranges. However, the tradeoff is that the resulting inner range can only be at most forward, as you don’t really know you are at the end until you increment here.

views::split(r: [T], p: T | [T]) -> [[T]]

(The fixed version after C++20 DR P2210R2) Produce a range that splits a range of T into a range of several range-of-Ts based on delimeter (which can be a single element or a continuous subrange).

>>> split("a bc def", ' ')
["a", "bc", "def"]
>>> split("a||b|c||d", "||")
["a", "b|c", "d"]
>>> split("abcd", "")  # when size = 0, just split at every element
["a", "b", "c", "d"]

split is still lazy, but it eagerly computes the start of next subrange when iterating, thus does not support input range but allow subrange to be at most contiguous. (Since input range is rare and most string algorithm require more than forward range, this should be used in most times)

views::common(r: [T]) -> [T]

Produce a range with same element as in r, but ensure that the result is a common range. (Basically exists as a compatibility layer so that pre-C++20 iterator-pair algorithms can use C++20 ranges)

views::reverse(r: [T]) -> [T]

Produce a range that contains the reverse of the elements in r.

>>> reverse([1, 2, 3])
[3, 2, 1]

Note that the reverse of reverse_view is simply the base range itself, and subrange passed-in will return subrange too.

views::elements<I: size_t>(r: [(T1, T2, ..., TN)]) -> [TI]

Produce a range consists of the I-th element of each element (which are tuples). views::keys is equivalent to views::element<0>, and views::values is equivalent to views::element<1>.

>>> r = [("A", 1), ("B", 2)]
>>> elements<1>(r)  # or values(r)
[1, 2]
>>> keys(r)  # or elements<0>(r)
["A", "B"]

Other Standard Views

(std::initializer_list<T> is technically a view, but it does not model ranges::view.)

std::basic_string_view<charT[, traits[, Alloc]]>: [charT&]

A lightweight view of a constant contiguous sequence of charTs (i.e. a string). Can view const charT*, std::basic_string, and many more.

std::span<T[, extent: size_t]>: [T&]

A lightweight view of a contiguous sequence of Ts). Can view T*, so a replacement of traditional T* + length idiom. A span<T> is by default with dynamic extent, and span<T, extent> is a view of fixed size.

C++23 Range Adaptors

These are the range adaptors available in C++23 DIS.

Factories

views::zip(r1: [T1], r2: [T2], ...) -> [(T1, T2, ...)]

Produce a new range that is r1, r2, … zipped together; i.e. a range of tuple of each corresponding elements in each of the argument ranges.

>>> zip([1, 2, 3], [4, 5, 6])
[(1, 4), (2, 5), (3, 6)]
>>> zip([1, 2], ["A", "B"], [1.0, 2.0])
[(1, "A", 1.0), (2, "B", 2.0)]
>>> zip()
[]  # empty view with type tuple<>

views::zip_transform(f: (T1, T2, ...) -> U, r1: [T1], r2: [T2], ...) -> [U]

Produce a new range in which each element is f(e1, e2, ...) where e1, e2 is the corresponding element of r1, r2, … respectfully.

>>> zip_transform(f, [1, 2, 3], [4, 5, 6])
[f(1, 4), f(2, 5), f(3, 6)]
>>> zip_transform((a, b, c) => to_string(a) + b + to_string(c), [1, 2], ["A", "B"], [1.0, 2.0])
["1A1.0", "2B2.0"]
>>> zip_transform(f)
[]  # empty view with the type of result of f()

views::cartesian_product(r1: [T1], r2: [T2], ...) -> [(T1, T2, ...)]

Produce a new range that is r1, r2, … cartesian producted together; i.e. a range of tuple of every possible pair of elements in each of the argument ranges.

>>> cartesian_product([1, 2, 3], [4, 5, 6])
[(1, 4), (1, 5), (1, 6), (2, 4), (2, 5), (2, 6), (3, 4), (3, 5), (3, 6)]
>>> cartesian_product([1, 2], ["A", "B"], [1.0, 2.0])
[(1, "A", 1.0), (1, "A", 2.0), (1, "B", 1.0), (1, "B", 2.0),
 (2, "A", 1.0), (2, "A", 2.0), (2, "B", 1.0), (2, "B", 2.0)]
>>> cartesian_product()
[()]  # views::single(std::tuple<>{})

views::repeat(t: T[, n: N]) -> [const T&]

Produce a range that repeats the same value t either infinitely (when there is only one argument), or for n times.

>>> repeat(2)
[2, 2, 2, ...]
>>> repeat(2, 5)
[2, 2, 2, 2, 2]

Real Adaptors

views::as_rvalue(r: [T]) -> [T&&]

Produce a range with same element as in r, but ensure that the result is a range of rvalue reference. (Basically, did a std::move on each element so that you can then move every element from the view into some container or things like that)

views::join_with(r: [[T]], p: U | [U]) -> [common_reference_t<T, U>]

Join together a range of several range-of-Ts into a single range-of-T, with p inserted between each parts. This is the reverse of views::split.

>>> join_with([[1, 2], [3], [4, 5, 6]], 3)
[1, 2, 3, 3, 3, 4, 5, 6]
>>> join_with([[1, 2], [3], [4, 5, 6]], [3, 4])
[1, 2, 3, 4, 3, 3, 4, 4, 5, 6]

views::as_const(r: [T]) -> [T]

Produce a range with same element as in r, but ensure that the result’s element cannot be modified (i.e. a constant range). (Basically, did a std::as_const on each element so that you cannot modify them, albeit with a much more complicated algorithm that avoid wrapping if at all possible by delegate to std::as_const)

views::enumerate(r: [T]) -> [(N, T)]

Produce a range such that each of the original elements of r is accompanied by its index in r.

>>> enumerate([1, 3, 6])
[(0, 1), (1, 3), (2, 6)]

(Notice that the index type N is range_difference_t<R>)

views::adjacent<N: size_t>(r: [T]) -> [(T, T, ...)]

Produce a new range where each elements is a tuple of the next consecutive N elements. pairwise is an alias for adjacent<2>. If r has less than N elements, the resulting range is empty.

>>> adjacent<4>([1, 2, 3, 4, 5, 6])
[(1, 2, 3, 4), (2, 3, 4, 5), (3, 4, 5, 6)]
>>> pairwise(["A", "B", "C"])  # or adjacent<2>
[("A", "B"), ("B", "C")]
>>> adjacent<7>([1, 2, 3])
[]  # empty view with type tuple<int&, int&, ...> (repeat 7 times)
>>> adjacent<0>([1, 2, 3, 4, 5, 6])
[]  # empty view with type tuple<>

views::adjacent_transform<N: size_t>(f: (T, T, ...) -> U, r: [T]) -> [U]

Produce a new range where each elements is the result of f(e1, e2, ...), where e1, e2, ... are the next consecutive N elements. pairwise_transform is an alias for adjacent_transform<2>. If r has less than N elements, the resulting range is empty.

>>> adjacent_transform<4>(f, [1, 2, 3, 4, 5, 6])
[f(1, 2, 3, 4), f(2, 3, 4, 5), f(3, 4, 5, 6)]
>>> adjacent_transform<4>((a, b, c, d) => a + b + c + d, [1, 2, 3, 4, 5, 6])
[10, 14, 18]
>>> pairwise_transform((a, b) => a + b, ["A", "B", "C"])  # or adjacent_transform<2>
["AB", "BC"]
>>> adjacent_transform<0>(f, [1, 2, 3, 4, 5, 6])
[]  # empty view with type of the result of f()

views::chunk(r: [T], n: N) -> [[T]]

Produce a new range-of-range that is the result of dividing r into non-overlapping n-sized chunks (except that last chunk can be smaller than n).

>>> chunk([1, 2, 3, 4, 5], 2)
[[1, 2], [3, 4], [5]]
>>> chunk([1, 2, 3, 4], 8)
[[1, 2, 3, 4]]

views::slide(r: [T], n: N) -> [[T]]

Produce a new range-of-range that is the result of dividing r into overlapping n-sized chunks (basically, the m-th range is a view into the m-th through m+n-1-th elements of r). This is similar to views::adjacent<n> with the difference being that adjacent require a compile-time size and produce range-of-tuples, while views::slide require a runtime size and provide range-of-ranges.

>>> slide([1, 2, 3, 4, 5], 2)
[[1, 2], [2, 3], [3, 4], [4, 5]]
>>> slide([1, 2, 3, 4], 8)
[]

views::chunk_by(r: [T], f: (T, T) -> bool) -> [[T]]

Produce a new range-of-range such that f is invoked on consecutive elements, and a new group is started when f returns false.

>>> chunk_by([1, 2, 2, 3, 1, 2, 0, 4, 5, 2], (a, b) => a <= b)
[[1, 2, 2, 3], [1, 2], [0, 4, 5], [2]]
>>> chunk_by([1, 2, 2, 3, 1, 2, 0, 4, 5, 2], (a, b) => a >= b)
[[1], [2, 2], [3, 1], [2, 0], [4], [5, 2]]

views::stride(r: [T], n: N) -> [T]

Produce a new range consists of an evenly-spaced subset of r (with space fixed at n).

>>> stride([1, 2, 3, 4], 2)
[1, 3]
>>> stride([1, 2, 3, 4, 5, 6, 7], 3)
[1, 4, 7]

Other Standard Views

std::generator<T[, U[, Alloc]]> : [U ? T : T&&]

Produce a view of all the things you have co_yielded in a coroutine.

std::generator<int> ints(int start = 0) {
    while (true) co_yield start++;
}

void f() {
    std::println("{}", ints(3) | views::take(3)); // [3, 4, 5]
}

C++26 Range Adaptors

Factories

views::concat(r1: [T1], r2: [T2], ...) -> [common_reference_t<T1, T2, ...>]

(Current design as of P2542R8, already adopted for C++26.)

Produce a new range that is r1, r2, … concated head-to-tail together; i.e. a range that starts at the first element of the first range, ends at the last element of the last range, with all range elements sequenced in between respectively in the order of arguments.

>>> concat([1, 2, 3], [4, 5, 6])
[1, 2, 3, 4, 5, 6]
>>> concat([1, 2], [3, 4], [1.0, 2.0])
[1.0, 2.0, 3.0, 4.0, 1.0, 2.0]
# concat() is ill-formed

views::nullable(n: Nullable<T>) -> [T&]

(Current design as of P1255R12.)

Produce a new range of 0 or 1 element based on a nullable object.

int* p = new int(3);
nullable(p) // [3]
int* q = nullptr;
nullable(q) // []

views::upto(n: N) -> [N]

(Current design as of P3060R1.)

A convenient alias/alternative for views::iota(0uz, ranges::size(r)). Basically, produce a range of 0, 1, …, n - 1.

>>> upto(5)
[0, 1, 2, 3, 4]

Real Adaptors

views::to_input(r: [T]) -> [T]

(Current design as of P3137R0.)

Downgrade any range to an input, non-common range.

Useful to avoid expensive operations that many range algorithm/adaptor perform to preserve higher properties. For example:

(Note that views::to_input will produce r’s type whenever possible)

views::cache_latest(r: [T]) -> [T]

(Current design as of P3138R0.)

Cache the last element of any range to avoid extra work. For example: r | views::transform(f) | views::filter(g) will call f twice for every element of r when iterating, because filter dereferences twice on each iteration. If you add views::cache_latest between the two adaptor, f will only be called once per element.

views::transform_join(r: [T], f: T -> [U]) -> [U]

(Current design as of P3211R0.)

Transform the input sequence to a range-of-range, and then join all the ranges. Commonly called FlatMap in other languages. Following P2328, this adaptor can be implemented directly as views::join(views::transform(r, f)), therefore views::transform_join is just an alias for that.

>>> transform_join([0, 1, 2], x => [x, x, x])
[0, 0, 0, 1, 1, 1, 2, 2, 2]

views::slice(r: [T], m: N, n: N) -> [T]

(Current design as of P3216R0.)

Produce a new range consists of the m-th to n-th (as usual, left inclusive, right exclusive) elements of r. If r has less than n elements, contains all the elements after the m-th. If r has less than m elements, produce an empty range.

>>> slice([1, 2, 3, 4, 5], 1, 3)
[2, 3]
>>> slice([1, 2, 3, 4, 5], 1, 10)
[2, 3, 4, 5]
>>> slice([1, 2, 3, 4, 5], 10, 12)
[]

Note that views::slice will produce r’s type whenever possible (for example, empty_view passed in will return an empty_view). This is due to the fact that views::slice(r, m, n) is just an alias for views::take(views::drop(r, m), n - m).

views::take_exactly(r: [T], n: N) -> [T]

(Current design as of P3230R0.)

A variation of views::take that assumes there are at least n elements in r. In other words, more efficient in common cases but is UB if you try to take more than length elements.

>>> take_exactly([1, 2, 3, 4], 2)
[1, 2]

Note that views::take_exactly will produce r’s type whenever possible (for example, empty_view passed in will return an empty_view). Also note that views::take_exactly may downgrade infinite ranges to finite ones (views::iota(0) | views::take_exactly(5) is just views::iota(0, 5), while views::take cannot preserve type when iota_view is not sized).

views::drop_exactly(r: [T], n: N) -> [T]

(Current design as of P3230R0.)

A variation of views::drop that assumes there are at least n elements in r. In other words, more efficient in common cases but is UB if you try to drop more than length elements.

>>> drop_exactly([1, 2, 3, 4], 2)
[3, 4]

Note that views::drop_exactly will produce r’s type whenever possible (for example, empty_view passed in will return an empty_view). Also note that views::drop_exactly may process infinite ranges better (views::iota(0) | views::drop_exactly(5) is just views::iota(5), while views::drop cannot preserve type when iota_view is not sized).

views::delimit(r: [T] | It, p: U) -> [T]

(Current design as of P3220R0.)

Produce a new range that includes all the element of r until p (inclusive). Similar to views::take_while but using a value instead of a predicate for ending detection. Very useful in cases like importing NTBS ranges with views::delimit(str, '\0').

>>> delimit([1, 2, 3, 4, 5], 3)
[1, 2, 3]
>>> delimit([1, 2, 3, 4, 5], 6)
[1, 2, 3, 4, 5]