| Document number | P3211R3 |
| Date | 2026-09-10 |
| Audience | LEWG, SG9 (Ranges) |
| Reply-to | Hewill Kang <hewillk@gmail.com> |
views::transform_join
We propose views::transform_join, a range adaptor that applies a function returning a range for each
element,
then flattens the result.
This pattern, commonly known as flat mapping, is widespread in functional programming and data processing.
Providing it as a dedicated view improves readability and expressiveness, and also opens opportunities for
optimization in lazy evaluation contexts.
Noted that this is ranked as Tier 1 in P2760.
Initial revision.
Rename views::transform_join to views::flat_map.
Introduce new flat_map_view class.
Discuss related optimization.
Support borrowed_range in certain cases based on SG9's feedback in Brno.
Rename views::flat_map to views::transform_join based on 2026-09-08 LEWG Telecon's
feedback.
Mapping each element to a subrange and flattening the results into a single range is common in programming tasks like data processing, string handling, and range composition.
This appears frequently enough in practice to justify direct support in the standard ranges library.
Providing views::transform_join encourages clearer, more maintainable code and lays the groundwork for
potential optimizations specific to this use case. While the same behavior can be achieved through
composition such as views::transform and then views::join, a dedicated view enables the
library to make stronger semantic guarantees and allows more efficient
handling of transform function results, particularly for expensive or lvalue-producing mappers.
This pattern arises naturally in code like:
auto all_courses = students
| std::views::transform_join([](const Student& s) {
return std::views::all(s.courses);
});
views::flat_map vs views::transform_join?
The name transform_join describes how to combine transform and join, but it
does not show the real concept. Users may see it as two steps, not as a single map and flatten operation;
The name flat_map is common in many languages and clearly shows both mapping and flattening. This makes
it easier to find, less confusing, and matches what users expect.
The following table shows how this operation is named in various languages, all of which converge on flat-map:
| Language | Function Name | Example | Differences in Semantics |
|---|---|---|---|
| Haskell | concatMap(>>=) |
concatMap f [1, 2, 3] |
Purely functional; >>= is monadic bind, generalizing flat mapping beyond lists |
| C# (LINQ) | SelectMany |
source.SelectMany(x => ...) |
Applies to all IEnumerable; default for flattening nested queries in LINQ syntax |
| Python | itertools.chain.from_iterable(map(...)) |
list(chain.from_iterable(map(f, data))) |
No built-in flatMap; semantics are manual; relies on strict evaluation and eager lists |
| Java | flatMap |
stream.flatMap(f) |
Requires the mapper function to return a Stream; lazy evaluation is enforced |
| JavaScript | flatMap |
array.flatMap(x => [x, x + 1]) |
One-level flattening only; not lazy; limited to arrays |
| Kotlin | flatMap |
list.flatMap { listOf(it, it * 2) } |
Similar to Java, but cleaner syntax; strict (not lazy) evaluation for standard collections |
| Rust | flat_map |
iter.flat_map(|x| some_iter(x)) |
Applies to iterators; lazy by default; consumes the original iterator |
| Swift | flatMap |
array.flatMap { [$0, $0 * 2] } |
Semantics changed in Swift 4 - used to also remove optionals; now strictly flatten + map |
Given this widespread usage, flat_map is the most appropriate and intuitive name for the proposed view.
It reflects established terminology, avoids over-specifying implementation details, and aligns well with programmer
expectations.
It is worth noting that the range/v3 uses the name views::for_each for this operation.
Because
such naming deviates further from common terminology and could cause additional confusion, it is not considered a
suitable option.
Additionally, C++23 introduced a container named std::flat_map, which is a associative
container that stores key-value pairs.
This is conceptually quite different from the proposed views::transform_join, which is a range adaptor that
composes a transform-then-join operation. Clarifying this distinction helps avoid confusion between the two.
Finally, while flat_map is preferred for its clarity and alignment with established usage, other naming
alternatives are acceptable as long as they convey the correct semantics.
However, during the 2026-09-08 LEWG Telecon, LEWG members continued to express strong concerns regarding the name
flat_map, as a container type with the same name — but vastly different semantics — already exists in the standard
library; consequently, this proposal has reverted to using transform_join in R3.
views::transform(f) | views::join?
Although transform_join can be expressed as a composition of transform and
join, a dedicated transform_join_view provides clearer semantics and more efficient
behavior in practice.
The first minor issue with composition is that it interferes with base(). When writing
transform(f) | join, the result holds a transform_view internally, so calling
base() does not give access to the original range, which can be surprising and inconvenient in generic
contexts.
Second, a standalone view can manage both the outer iterator and the inner range. It can cache the transform result and avoid repeated calls, which is important when the function returns an lvalue range.
In the composed form,
join_view has no knowledge
of how the
inner range was produced - it treats each element as if it were obtained by dereferencing the outer iterator. This
works fine when the base is something like vector of vectors, where dereferencing is
cheap
and deterministic. But with transform | join, each inner range is the result of invoking a user-defined
transformation, which could be expensive to compute even if the result is a stable lvalue. For example :
auto outer = views::iota(1, 5); auto inner = views::iota(1, 10); auto flatten = views::transform_join(outer, [&](int i) -> auto& { return inner; }); println("{}", flatten); // calls transform function 40 times
While cache_latest can mitigate redundant evaluation, it forces the result to model only an
input_range, regardless of the actual capabilities of the underlying ranges. It also adds an extra
layer of adaptor composition and complexity. In contrast, a dedicated transform_join_view can handle this
caching internally while preserving the strongest valid iterator category.
A dedicated view also avoids unnecessary template instantiations. Since a transform-joined range can never be more than
bidirectional, there is no benefit in preserving random-access capabilities through transform_view.
However, when using adaptor composition, those capabilities may still be instantiated - even though
join_view will downgrade them - leading to code bloat and slower compilation.
In short, using composition is flexible but less efficient and can be awkward. A newly introduced transform_join_view avoids
repeated evaluations, keeps the best iterator type, and makes the view stack simpler.
Although paper
P2760 suggests there is little benefit to providing a
separat view as state: "Importantly, there really isn't much benefit to providing a bespoke
transform_join
as
opposed to simply implementing it in terms of these two existing adaptors.", practical usage has revealed that
the composed form introduces subtle inefficiencies, especially in the presence of expensive transformation
functions, and not easy to eliminate without internal knowledge of how adaptors interact.
When the inner range is a reference, we can avoid repeated invocations of the mapping function by storing a pointer
to the result instead. This allows us to simply dereference the pointer whenever the inner range is needed, rather
than invoking the transformation again — a technique somewhat analogous to cache_latest_view. However,
it's important to
note that this pointer must be stored inside the iterator, not the transform_join_view itself, in order to
ensure correct
behavior in multi-pass scenarios where multiple iterators may coexist and advance independently.
For prvalue inner ranges, the situation is somewhat more tricky, especially for immovable ranges.
For join_view, it underlying range's iterator already returns the inner
range.
This property makes it directly suitable for non-propagating-cache::emplace-deref, which
caches the inner range by
dereferencing the outer iterator.
In contrast, transform_join_view obtains the inner range by applying a mapping function to the element
referenced by the
outer iterator, rather than by directly dereferencing the outer iterator to yield the inner range. As a result, it
cannot straightforwardly use emplace-deref to cache the inner range. Instead, it
must store the
entire inner range object returned by the mapping function within it to properly extend its lifetime.
One possible approach to mitigate this is to define a proxy iterator that models invoking the mapping function upon
dereference. This proxy can then be used with emplace-deref to cache the transformed inner range
indirectly, albeit with
additional complexity.
At first glance, transform_join_view might not have a stashing issue as discussed in
P2770, since its inner range is always produced by applying a mapping function, rather than being a subrange
stored within the outer range.
However, in particular, the mapping function can certainly return a range whose lifetime is tied to the
outer
iterator -
for example, applying views::split('x') to strings obtained from an
istream_iterator<string> — then
transform_join_view need cache the current outer iterator to ensures the inner range remains
valid
during iteration. Therefore, transform_join_view follows the design pattern of that paper.
The author implemented views::transform_join based on libstdc++, see here.
The implementation supports input_range,
forward_range, and
bidirectional_range, and demonstrates correct caching behavior for both lvalue and prvalue immovable
transformed
ranges.
This wording is relative to Latest Working Draft .
Add a new feature-test macro to 17.3.2 [version.syn]:
#define __cpp_lib_ranges_transform_join 2026XXL // freestanding, also in <ranges>
Modify 25.2 [ranges.syn], Header
<ranges>
synopsis, as indicated:
[Drafting note: The exposition-only concept tidy-obj comes from P3220.]
// mostly freestanding #include <compare> // see [compare.syn] #include <initializer_list> // see [initializer.list.syn] #include <iterator> // see [iterator.synopsis] namespace std::ranges { […] namespace views { inline constexpr unspecified join_with = unspecified; } // [range.transform.join], transform join view template<input_range V, move_constructible F> requires see below class transform_join_view; template<class T, class F> constexpr bool transform-join-is-borrowed = // exposition only enable_borrowed_range<T> && tidy-obj<F> && forward_range<T> && enable_borrowed_range<invoke_result_t<F&, range_reference_t<T>>> && is_reference_v<invoke_result_t<F&, range_reference_t<T>>>; template<class T, class F> constexpr bool enable_borrowed_range<transform_join_view<T, F>> = transform-join-is-borrowed<T, F>; namespace views { inline constexpr unspecified transform_join = unspecified; } […] }
Add 25.7.? Transform join view [range.transform.join] after 25.7.15 [range.join.with] as indicated:
-1- A transform-join view transforms each element to a range and flattens the results into a view.
-2- The name views::transform_join denotes a range adaptor object ([range.adaptor.object]). Given subexpressions
E and F,
the expression views::transform_join(E, F) is expression-equivalent to
transform_join_view(E, F).
-3- [Example 1:
vector ints{0, 1, 2};
for (auto elem : ints | views::transform_join([](int i) { return views::repeat(i, 3); }))
cout << elem << ' '; // prints 0 0 0 1 1 1 2 2 2
— end example]
[25.7.?.2] Class template transform_join_view [range.transform.join]
namespace std::ranges {
template<class R, class F>
concept transformable-joinable = input_range<R> && // exposition only
regular_invocable<F&, range_reference_t<R>> &&
input_range<invoke_result_t<F&, range_reference_t<R>>>;
template<input_range V, move_constructible F>
requires view<V> && is_object_v<F> && transformable-joinable<V, F>
class transform_join_view : public view_interface<transform_join_view<V, F>> {
private:
using TransformedRng =
invoke_result_t<F&, range_reference_t<V>>; // exposition only
// [range.transform.join.iterator], class template transform_join_view::iterator
template<bool Const>
struct iterator; // exposition only
// [range.transform.join.sentinel], class template transform_join_view::sentinel
template<bool Const>
struct sentinel; // exposition only
V base_ = V(); // exposition only
movable-box<F> fun_; // exposition only, present only
// if transform-join-is-borrowed<V, F> is false
non-propagating-cache<iterator_t<V>> current_; // exposition only, present only
// if forward_range<V> is false
non-propagating-cache<TransformedRng> transformed_rng_; // exposition only, present only
// if is_reference_v<TransformedRng> is false
public:
transform_join_view() requires default_initializable<V> && default_initializable<F> = default;
constexpr explicit transform_join_view(V base, F fun);
constexpr V base() const & requires copy_constructible<V> { return base_; }
constexpr V base() && { return std::move(base_); }
constexpr auto begin() {
if constexpr (forward_range<V>) {
return iterator<false>{*this, ranges::begin(base_)};
} else {
current_ = ranges::begin(base_);
return iterator<false>{*this};
}
}
constexpr auto begin() const
requires forward_range<const V> &&
transformable-joinable<const V, const F> &&
is_reference_v<invoke_result_t<const F&, range_reference_t<const V>>>
{ return iterator<true>{*this, ranges::begin(base_)}; }
constexpr auto end() {
if constexpr (forward_range<V> &&
is_reference_v<TransformedRng> && forward_range<TransformedRng> &&
common_range<V> && common_range<TransformedRng>)
return iterator<false>{*this, ranges::end(base_)};
else
return sentinel<false>{*this};
}
constexpr auto end() const
requires forward_range<const V> &&
transformable-joinable<const V, const F> &&
is_reference_v<invoke_result_t<const F&, range_reference_t<const V>>> {
using TransformedConstRng = invoke_result_t<const F&, range_reference_t<const V>>;
if constexpr (forward_range<TransformedConstRng> &&
common_range<const V> && common_range<TransformedConstRng>)
return iterator<true>{*this, ranges::end(base_)};
else
return sentinel<true>{*this};
}
};
template<class R, class F>
transform_join_view(R&&, F) -> transform_join_view<views::all_t<R>, F>;
}
constexpr explicit transform_join_view(V base, F fun);
-1- Effects: Initializes
base_withstd::move(base), andfun_withstd::move(fun)iffun_is present.
[25.7.?.3] Class template transform_join_view::iterator [range.transform.join.iterator]
[Drafting note: Enabling borrowed_range requires storing the end iterator inside the
iterator to
avoid accessing the parent pointer.]
namespace std::ranges {
template<input_range V, move_constructible F>
requires view<V> && is_object_v<F> && transformable-joinable<V, F>
template<bool Const>
struct transform_join_view<V, F>::iterator {
private:
using Parent = maybe-const<Const, transform_join_view>; // exposition only
using Base = maybe-const<Const, V>; // exposition only
using BaseFun = maybe-const<Const, F>; // exposition only
using TransformedBase = // exposition only
invoke_result_t<BaseFun&, range_reference_t<Base>>;
using BaseIter = iterator_t<Base>; // exposition only
using BaseSent = sentinel_t<Base>; // exposition only
using TransformedIter = iterator_t<TransformedBase>; // exposition only
static constexpr bool transformed-is-glvalue = // exposition only
is_reference_v<TransformedBase>;
BaseIter current_ = BaseIter(); // exposition only, present only
// if Base models forward_range
BaseSent end_ = BaseSent(); // exposition only, present only
// if transform-join-is-borrowed<Base, BaseFun> is true
optional<TransformedIter> transformed_it_; // exposition only
Parent* parent_ = nullptr; // exposition only, present only
// if transform-join-is-borrowed<Base, BaseFun> is false
optional<TransformedBase&> transformed_rng_; // exposition only, present only
// if transformed-is-glvalue is true
constexpr BaseIter& current(); // exposition only
constexpr const BaseIter& current() const; // exposition only
constexpr auto& update-transformed-rng(); // exposition only
constexpr auto& get-transformed-rng(); // exposition only
constexpr auto get-end() const; // exposition only
constexpr void satisfy(); // exposition only
constexpr iterator(Parent& parent, BaseIter current)
requires forward_range<Base>; // exposition only
constexpr explicit iterator(Parent& parent)
requires (!forward_range<Base>); // exposition only
public:
using iterator_concept = see below;
using iterator_category = see below; // not always present
using value_type = range_value_t<TransformedBase>;
using difference_type = see below;
iterator() = default;
constexpr iterator(iterator<!Const> i)
requires Const &&
is_reference_v<invoke_result_t<F&, range_reference_t<V>>> &&
convertible_to<iterator_t<V>, BaseIter> &&
convertible_to<iterator_t<invoke_result_t<F&, range_reference_t<V>>>, TransformedIter> &&
convertible_to<invoke_result_t<F&, range_reference_t<V>>&, TransformedBase&> &&
(!transform-join-is-borrowed<Base, BaseFun> || convertible_to<sentinel_t<V>, BaseSent>);
constexpr decltype(auto) operator*() const { return **transformed_it_; }
constexpr TransformedIter operator->() const
requires has-arrow<TransformedIter> && copyable<TransformedIter>;
constexpr iterator& operator++();
constexpr void operator++(int);
constexpr iterator operator++(int)
requires transformed-is-glvalue && forward_range<Base> &&
forward_range<TransformedBase>;
constexpr iterator& operator--()
requires transformed-is-glvalue && bidirectional_range<Base> &&
bidirectional-common<TransformedBase>;
constexpr iterator operator--(int)
requires transformed-is-glvalue && bidirectional_range<Base> &&
bidirectional-common<TransformedBase>;
friend constexpr bool operator==(const iterator& x, const iterator& y)
requires transformed-is-glvalue && forward_range<Base> &&
equality_comparable<iterator_t<TransformedBase>>;
friend constexpr decltype(auto) iter_move(const iterator& i)
noexcept(noexcept(ranges::iter_move(*i.transformed_it_))) {
return ranges::iter_move(*i.transformed_it_);
}
friend constexpr void iter_swap(const iterator& x, const iterator& y)
noexcept(noexcept(ranges::iter_swap(*x.transformed_it_, *y.transformed_it_)))
requires indirectly_swappable<TransformedIter>;
};
}
-1-
iterator::iterator_conceptis defined as follows:
(1.1) — If
transformed-is-glvalueis true,Basemodelsbidirectional_range, andTransformedBasemodelsbidirectional-common, theniterator_conceptdenotesbidirectional_iterator_tag.(1.2) — Otherwise, if
transformed-is-glvalueistrueandBaseandTransformedBaseeach modelforward_range, theniterator_conceptdenotesforward_iterator_tag.(1.3) — Otherwise,
iterator_conceptdenotesinput_iterator_tag.
-2- The member typedef-name
iterator_categoryis defined if and only iftransformed-is-glvalueistrue,Basemodelsforward_range, andTransformedBasemodelsforward_range. In that case,iterator::iterator_categoryis defined as follows:
(2.1) — Let BASEC denote
iterator_traits<iterator_t<Base>>::iterator_category, and let TRANSFORMC denoteiterator_traits<iterator_t<TransformedBase>>::iterator_category.(2.2) — If BASEC and TRANSFORMC each model
derived_from<bidirectional_iterator_tag>andTransformedBasemodelscommon_range,iterator_categorydenotesbidirectional_iterator_tag.(2.3) — Otherwise, if BASEC and TRANSFORMC each model
derived_from<forward_iterator_tag>,iterator_categorydenotesforward_iterator_tag.(2.4) — Otherwise,
iterator_categorydenotesinput_iterator_tag.
-3-
iterator::difference_typedenotes the type:common_type_t<range_difference_t<Base>, range_difference_t<TransformedBase>>
-4-
transform_join_viewiterators use thesatisfyfunction to skip over empty transformed ranges.
constexpr BaseIter& current(); constexpr const BaseIter& current() const;
-5- Returns:
current_ifBasemodelsforward_range; otherwise,*parent_->current_.
constexpr auto& update-transformed-rng();
-6- Effects:
(6.1) — If
transformed-is-glvalueistrue, equivalent to:if constexpr (transform-join-is-borrowed<Base, BaseFun>) { BaseFun fun; transformed_rng_ = as-lvalue(std::invoke(fun, *current())); } else transformed_rng_ = as-lvalue(std::invoke(*parent_->fun_, *current())); return *transformed_rng_;(6.1) — Otherwise, equivalent to:
[Drafting note: Thenon-propagating-cachedoes not provide another way to in-place construct immovable types besidesemplace-deref, so this simply simulates a transform iterator to be able to call withemplace-deref.]return parent_->transformed_rng_.emplace-deref(transform-iterator(*parent_->fun_, current()));where
transform-iteratoris the exposition-only class:class transform-iterator { BaseFun& fun_; const BaseIter& current_; constexpr transform-iterator(BaseFun& fun, const BaseIter& current) : fun_(fun), current_(current) {} public: constexpr auto operator*() const { return std::invoke(fun_, *current_); } };
constexpr auto& get-transformed-rng();
-7- Effects: Equivalent to:
if constexpr (transformed-is-glvalue) return *transformed_rng_; else return *parent_->transformed_rng_;
constexpr auto get-end() const;
-8- Effects: Equivalent to:
if constexpr (transform-join-is-borrowed<Base, BaseFun>) return end_; else return ranges::end(parent_->base_);
constexpr void satisfy();
-9- Effects: Equivalent to:
for (; current() != get-end(); ++current()) { auto& transformed = update-transformed-rng(); transformed_it_ = ranges::begin(transformed); if (*transformed_it_ != ranges::end(transformed)) return; } if constexpr (transformed-is-glvalue) transformed_it_.reset();
constexpr iterator(Parent& parent, BaseIter current) requires forward_range<Base>;
-10- Effects: Initializes
current_withstd::move(current)andparent_withaddressof(parent)ifparent_is present; then callssatisfy().
constexpr explicit iterator(Parent& parent) requires (!forward_range<Base>);
-11- Effects: Initializes
parent_withaddressof(parent); then callssatisfy().
constexpr iterator(iterator<!Const> i)
requires Const &&
is_reference_v<invoke_result_t<F&, range_reference_t<V>>> &&
convertible_to<iterator_t<V>, BaseIter> &&
convertible_to<iterator_t<invoke_result_t<F&, range_reference_t<V>>>, TransformedIter> &&
convertible_to<invoke_result_t<F&, range_reference_t<V>>&, TransformedBase&> &&
(!transform-join-is-borrowed<Base, BaseFun> || convertible_to<sentinel_t<V>, BaseSent>);
-12- Effects: Initializes
current_withstd::move(i.current_),transformed_it_withstd::move(i.transformed_it_),parent_withi.parent_ifparent_is present,transformed_rng_withi.transformed_rng_, andend_withi.end_ifend_is present.-13- [Note 1:
Constcan only betruewhenBasemodelsforward_range. — end note]
constexpr TransformedIter operator->() const requires has-arrow<TransformedIter> && copyable<TransformedIter>;
-14- Effects: Equivalent to:
return *transformed_it_;
constexpr iterator& operator++();
-15- Effects: Equivalent to:
if (++*transformed_it_ == ranges::end(get-transformed-rng())) { ++current(); satisfy(); } return *this;
constexpr void operator++(int);
-16- Effects: Equivalent to:
++*this.
constexpr iterator operator++(int)
requires transformed-is-glvalue && forward_range<Base> &&
forward_range<TransformedBase>;
-17- Effects: Equivalent to:
auto tmp = *this; ++*this; return tmp;
constexpr iterator& operator--()
requires transformed-is-glvalue && bidirectional_range<Base> &&
bidirectional-common<TransformedBase>;
-18- Effects: Equivalent to:
if (current_ == get-end()) { --current_; transformed_it_ = ranges::end(update-transformed-rng()); } while (transformed_it_ == ranges::begin(get-transformed-rng())) { --current_; transformed_it_ = ranges::end(update-transformed-rng()); } --*transformed_it_; return *this;
constexpr iterator operator--(int)
requires transformed-is-glvalue && bidirectional_range<Base> &&
bidirectional-common<TransformedBase>;
-19- Effects: Equivalent to:
auto tmp = *this; --*this; return tmp;
friend constexpr bool operator==(const iterator& x, const iterator& y)
requires transformed-is-glvalue && forward_range<Base> &&
equality_comparable<iterator_t<TransformedBase>>;
-20- Effects: Equivalent to:
return x.current_ == y.current_ && x.transformed_it_ == y.transformed_it_;
friend constexpr void iter_swap(const iterator& x, const iterator& y) noexcept(noexcept(ranges::iter_swap(*x.transformed_it_, *y.transformed_it_))) requires indirectly_swappable<TransformedIter>;
-21- Effects: Equivalent to:
ranges::iter_swap(*x.transformed_it_, *y.transformed_it_);
[25.7.?.3] Class template transform_join_view::sentinel [range.transform.join.sentinel]
namespace std::ranges {
template<input_range V, move_constructible F>
requires view<V> && is_object_v<F> && transformable-joinable<V, F>
template<bool Const>
struct transform_join_view<V, F>::sentinel {
private:
using Parent = maybe-const<Const, transform_join_view>; // exposition only
using Base = maybe-const<Const, V>; // exposition only
sentinel_t<Base> end_ = sentinel_t<Base>(); // exposition only
public:
sentinel() = default;
constexpr explicit sentinel(Parent& parent);
constexpr sentinel(sentinel<!Const> s)
requires Const && convertible_to<sentinel_t<V>, sentinel_t<Base>>;
template<bool OtherConst>
requires sentinel_for<sentinel_t<Base>, iterator_t<maybe-const<OtherConst, V>>>
friend constexpr bool operator==(const iterator<OtherConst>& x, const sentinel& y);
};
}
constexpr explicit sentinel(Parent& parent);
-1- Effects: Initializes
end_withranges::end(parent.base_).
constexpr sentinel(sentinel<!Const> s) requires Const && convertible_to<sentinel_t<V>, sentinel_t<Base>>;
-2- Effects: Initializes
end_withstd::move(s.end_).
template<bool OtherConst> requires sentinel_for<sentinel_t<Base>, iterator_t<maybe-const<OtherConst, V>>> friend constexpr bool operator==(const iterator<OtherConst>& x, const sentinel& y);
-3- Effects: Equivalent to:
return x.current() == y.end_;