Using lambdas with standard algorithms

C++11: lambdas C++14: init captures C++20: ranges algorithms and projections

The standard algorithms are parameterized over behavior: what to count, how to order, which element to find. Before lambdas, supplying that behavior meant writing a named function object somewhere far from the call. A lambda puts the behavior in the argument list, where the reader is already looking — which is why lambdas and algorithms are effectively one feature used together.

What a lambda actually is

A lambda expression is compiler shorthand for a class with a call operator. Captures become data members; the body becomes operator():

double limit = 100.0;
auto pricier = [limit](const order& o) { return o.total > limit; };

// is essentially:
class __closure {
    double limit;                              // captures become members
public:
    explicit __closure(double l) : limit(l) {}
    bool operator()(const order& o) const {    // const unless declared mutable
        return o.total > limit;
    }
};

Every lambda has a distinct, unnamed type — which is why you store them in auto. A lambda with no captures additionally converts to a plain function pointer, so it can feed C-style APIs.

Captures: what, and more importantly, when

A capture copies (or references) the variable when the lambda is created, not when it's called. Reference captures see later changes; value captures don't:

Run in Compiler Explorer
#include <memory>
#include <print>
#include <string>

int main() {
    int rate = 3;
    auto by_value = [rate](int x) { return x * rate; };
    auto by_ref   = [&rate](int x) { return x * rate; };
    rate = 10;
    std::println("by value: {}", by_value(2));   // 6  - captured at creation
    std::println("by ref:   {}", by_ref(2));     // 20 - reads the current value

    // Init captures (C++14): a member initialized by any expression,
    // which is also how move-only things get into a lambda.
    auto owner = std::make_unique<std::string>("resource");
    auto sink = [p = std::move(owner)] { return p->size(); };
    std::println("owned length: {}", sink());
}

The working rules:

  • [x] copies, [&x] references, [this] captures the enclosing object's pointer, [*this] C++17 copies the whole object.
  • The defaults [=] and [&] capture everything the body touches. Fine for a lambda consumed on the same line; for anything stored, list captures explicitly so the reader can audit lifetimes.
  • Init captures [n = compute(), v = std::move(v)] C++14 create members from arbitrary expressions — the only way to capture by move.
  • Globals, statics, and constexpr constants aren't captured at all; the body just uses them.

Lambdas meet the algorithms

This is the payoff. Predicates, comparators, and transformations become one-liners at the call site — and the C++20 ranges algorithms add projections, which peel a member out of each element so the lambda (when you still need one) shrinks to just the criterion:

Run in Compiler Explorer
#include <algorithm>
#include <functional>
#include <print>
#include <string>
#include <vector>

struct book { std::string title; int pages; double rating; };

int main() {
    std::vector<book> shelf{
        {"Systems", 512, 4.6}, {"Compilers", 800, 4.8},
        {"Networks", 350, 4.1}, {"Databases", 610, 3.9},
    };

    auto long_read = [](const book& b) { return b.pages > 500; };
    std::println("long reads: {}", std::ranges::count_if(shelf, long_read));

    // Projection: the comparator is stock, the projection picks the member.
    std::ranges::sort(shelf, std::ranges::greater{}, &book::rating);
    std::println("best: {}", shelf.front().title);

    // Predicate + projection: the lambda sees only the projected value.
    bool any_bad = std::ranges::any_of(
        shelf, [](double r) { return r < 4.0; }, &book::rating);
    std::println("anything under 4.0? {}", any_bad);
}

Naming the lambda (long_read) instead of inlining it is free documentation — the algorithm call reads as a sentence, and the same predicate serves several algorithms without duplication.

Stateful lambdas

mutable lets the call operator modify the members that captures created. The classic use is a generator:

std::vector<int> ids(5);
std::ranges::generate(ids, [n = 100]() mutable { return n++; });  // 100 101 102 103 104

Two cautions. Algorithms are allowed to copy function objects, so state may not accumulate where you expect — if the state must be shared, keep it outside and capture by reference. And comparators must stay pure: a sort comparator that mutates or answers inconsistently is undefined behavior, not just a wrong order.

The dangling capture

Reference captures make lambdas cheap; they also make them time bombs when the lambda outlives the scope:

auto make_greeter(const std::string& name) {
    return [&] { return "hi " + name; };   // dangles: 'name' dies at return
}

The rule that prevents every version of this bug: a lambda that escapes the current scope — returned, stored, queued on a thread pool — captures by value or by init-capture move. Reference captures are for lambdas consumed in place, like an algorithm argument.

Guidelines

  • Prefer an algorithm-plus-lambda over a raw loop: count_if, any_of, find_if state the intent in their names.
  • List captures explicitly in any lambda that outlives its statement; save [=]/[&] for throwaways.
  • Use init captures to move expensive or move-only state in, and to give captured copies decent names.
  • Reach for projections before writing a lambda at all — &book::rating plus a stock comparator beats a hand-written one.
  • Escaping lambda ⇒ value captures. No exceptions; this rule is cheaper than the debugging session.

Next: Using generic and template lambdas