#084 – The transition from std::transform to std::ranges::transform

std::transform has been the standard library’s go-to “apply a function to every element of a sequence” tool since C++98. It works. It’s also showing every one of its twenty-five-plus years: callers spell out begin() and end() for every range, ad-hoc projections require an extra lambda layer, and a misuse produces template-instantiation errors that are infamous for their length and incomprehensibility.

C++20 introduced a parallel set of algorithms in std::ranges:: — same operations, different shape. This Nibble walks through what changes when you migrate from std::transform to std::ranges::transform, what stays the same, and the related-but-separate std::views::transform that turns the eager algorithm into a lazy pipeline component.

Three things to take away:

  • std::ranges::transform accepts an entire range as one argument instead of an iterator pair, and adds an optional projection parameter that pre-transforms each element before the operation sees it.
  • The ranges algorithms are constrained by C++20 concepts, so misuse produces shorter, more accurate error messages at the call site rather than deep inside template instantiation.
  • The eager algorithm (std::ranges::transform) is a separate feature from the lazy view (std::views::transform); both exist, and choosing between them depends on whether you want the work done now or composed into a pipeline.

The classical form

The C++03 reference shows the canonical pattern: provide two iterators delimiting the input, an output iterator, and a unary callable:

#include <algorithm>
#include <vector>

std::vector<double> in = { 1.0, 2.0, 3.0, 4.0 };
std::vector<double> out(in.size());

std::transform(in.begin(), in.end(), out.begin(),
               [](double x) { return x * x; });

Two binary signatures exist as well — one that consumes elements pairwise from two ranges, and overloads in the companion algorithms (std::accumulate etc.). The shape is consistent across the algorithms library: every call writes out the same boilerplate of .begin()s and .end()s.

Three pain points are visible here, in increasing order of practical cost:

  1. Verbosity. Spelling in.begin(), in.end() on every call adds noise to code where the range is the interesting object, not its iterators.
  2. No projections. When the function should operate on a member of each element rather than the element itself, you write a lambda that calls the member. Code that should be one line becomes three.
  3. Error messages. A type mismatch in the callable produces a template-instantiation chain ending in some internal helper. The actual call site appears far from the error location, often without any indication of what the programmer should have written instead.

The ranges form

C++20 added std::ranges::transform (and the entire algorithms library in std::ranges::). The shape is the same operation with three improvements layered in:

#include <algorithm>
#include <ranges>
#include <vector>

std::vector<double> in = { 1.0, 2.0, 3.0, 4.0 };
std::vector<double> out(in.size());

std::ranges::transform(in, out.begin(),
                       [](double x) { return x * x; });

The in.begin(), in.end() pair has collapsed to in. The algorithm accepts a range — anything modelling std::ranges::input_range, which includes every standard container, raw arrays, views, sentinel-terminated ranges, and user-defined types that expose begin/end. This is the ergonomic win that’s most visible at the call site, and it adds up across a codebase.

The constraint side is less visible but more important. The ranges algorithms are constrained with C++20 concepts:

template <std::ranges::input_range R, class O,
          std::copy_constructible F, class Proj = std::identity>
requires std::indirectly_writable<O,
            std::indirect_result_t<F&, std::projected<std::ranges::iterator_t<R>, Proj>>>
constexpr auto transform(R&& r, O result, F op, Proj proj = {});

Pass something that isn’t an input_range, or a callable that can’t be applied to the projected element type, and the compiler reports the constraint failure — at the call site, in terms of what the programmer wrote. Compare to the classical version, where the same mistake produces a template- instantiation cascade ending in some helper deep inside the algorithm’s implementation. Concept-driven errors are the single biggest practical improvement of the ranges algorithms, and they apply across the whole library.

The projection: the underrated win

The fourth parameter to most ranges algorithms is the projection — a callable that runs on each element before the algorithm’s main operation sees it. For transform, a projection lets you pull out a member, apply a wrapper function, or perform any other per-element transformation without writing an additional lambda layer:

struct Order {
    std::string sku;
    double      price;
};

std::vector<Order> orders = { /* ... */ };
std::vector<double> withTax(orders.size());

// Classical: lambda has to know the struct shape AND apply the operation.
std::transform(orders.begin(), orders.end(), withTax.begin(),
               [](const Order& o) { return o.price * 1.10; });

// Ranges with projection: the operation works on price, projection
//                         pulls price out of Order. Cleaner separation.
std::ranges::transform(orders, withTax.begin(),
                       [](double p) { return p * 1.10; },
                       &Order::price);

The projection is &Order::price — a pointer-to-member, used as a function. The algorithm dereferences each element, applies the projection (giving a double), then applies the operation (giving the taxed price). The lambda no longer needs to know it was passed Order objects; it operates on prices. Reuse becomes easier and the operation is more testable in isolation.

Projections work the same way across the ranges algorithms. std::ranges::sort(v, std::less<>{}, &Person::age) sorts a std::vector<Person> by age without writing a comparison lambda. std::ranges::find(v, target, &Person::id) searches by ID. The projection parameter generalises a pattern that appears in virtually every algorithm call against a structured type.

std::views::transform – The lazy cousin

A separate but related feature lives in std::views::. Where std::ranges::transform is the eager algorithm — does the work, writes results — std::views::transform is the lazy view: a description of a transformation that executes per-element only when something asks for the next value:

#include <ranges>

std::vector<int> v = { 1, 2, 3, 4, 5 };

// Eager: writes results to `out` immediately.
std::vector<int> out(v.size());
std::ranges::transform(v, out.begin(), [](int x) { return x * x; });

// Lazy: describes the transformation; nothing runs until iterated.
auto squared = v | std::views::transform([](int x) { return x * x; });

The view doesn’t allocate, doesn’t copy, and doesn’t compute anything until something walks it. Composing views with | builds pipelines that the compiler can fuse into a single pass:

auto result = v
    | std::views::filter([](int x) { return x > 0; })
    | std::views::transform([](int x) { return x * x; });

for (int x : result) {              // single pass, no temporaries
    std::cout << x << '\n';
}

This is the bigger story of the ranges library — composable, lazy pipelines that read like Unix-style chains. The distinction from the eager algorithm matters in practice: reach for std::ranges::transform when you need a result container now, and std::views::transform when the transformation is part of a larger pipeline that consumes the elements one at a time.

What hasn’t moved over

A few caveats are worth knowing for production migration:

  • Execution policies. The classical algorithms gained parallel execution policies in C++17 (Nibble #081); std::ranges::transform does not yet take an execution policy. A C++26 proposal addresses this, but for now, algorithms that need std::execution::par continue to use the iterator-pair form.
  • Numeric algorithms. std::accumulatestd::reducestd::inner_product, and the rest of <numeric> do not have ranges counterparts in C++20. C++23 added ranges versions of some (std::ranges::fold_left); broader coverage is incremental.
  • Compiler maturity. Ranges shipped in C++20 but implementations stabilised at different rates. Modern toolchains (GCC 13+, Clang 16+, MSVC current) handle ranges well; older ones may have surprising gaps.

A practical migration

For new code where the algorithm has a ranges counterpart and no execution policy is needed, prefer the ranges form. The ergonomic and error-message improvements are real and the projection parameter eliminates a category of throwaway lambdas. For existing code, mass migration is rarely worth the churn — leave working std::transform calls alone, and use std::ranges::transform in new code and in code you’re modifying anyway. The two forms coexist; the standard library makes no promise to deprecate the iterator-pair forms.

When you find yourself reaching for the lazy pipeline form — “transform, then filter, then take the first ten” — that’s the cue to use std::views::transform and friends rather than chaining eager algorithms with intermediate containers. This is where the ranges library is genuinely transformative rather than incremental.

Takeaway

The transition from std::transform to std::ranges::transform is small at the algorithm level (a range instead of two iterators) and large at the library level (concept-checked errors, projections, lazy views composed with |). For new code, prefer the ranges form unless you specifically need an execution policy or a numeric algorithm without a ranges counterpart. Use projections to drop boilerplate lambdas that exist only to pull a member out of a struct.

Reach for std::views::transform when the transformation is one stage of a pipeline rather than a one-shot operation. The rule is simple: ranges algorithms are the iterator-pair algorithms with the rough edges sanded off — the work they do is unchanged; the way you ask for it is cleaner.