C++ Lambdas, `auto`, and Type Deduction


  • Description: A note on C++ lambdas (captures, mutable, generic, constexpr/consteval), auto/decltype/decltype(auto), and CTAD (Class Template Argument Deduction)
  • My Notion Note ID: K2A-B1-11
  • Created: 2018-10-05
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Lambda Basics

A lambda is an anonymous function object. The compiler synthesizes a unique class with operator() and (optionally) captured members.

auto add = [](int a, int b) { return a + b; };
add(2, 3);   // 5

// Anatomy:
//   [captures](parameters) -> return_type { body }
//
// Most parts are optional:
//   []        -- no captures
//   ()        -- no parameters (can omit if there are no params)
//   -> T      -- explicit return type (often deduced)

Each lambda expression has a unique compiler-generated type, so two lambdas are always distinct types even if they look identical.


2. Captures

Captures bring outside variables into the lambda's body.

2.1 Capture by Value vs by Reference

int x = 10;

auto by_value = [x] { return x; };       // copies x at lambda creation
auto by_ref   = [&x] { return x; };      // refers to x; reflects changes

x = 20;
by_value();   // 10 (frozen)
by_ref();     // 20 (live)

Capture-by-reference rule: the captured object must outlive the lambda. Common mistake: returning a lambda that captures a local by reference. The reference dangles when the function returns.

2.2 Default Captures ([=], [&])

[=]        // capture all used variables by value
[&]        // capture all used variables by reference
[=, &z]    // by value except z by reference
[&, x]     // by reference except x by value

Recommended: name your captures explicitly ([x, &y]). Default captures hide what's borrowed and what's owned, which is bug-prone in long lambdas.

2.3 Init Captures (C++14)

Init captures introduce new variables in the capture list. Useful for moves and computed values.

auto big = std::make_unique<Data>();
auto lambda = [data = std::move(big)] {     // move into the lambda
    return data->size();
};

auto counter = [n = 0]() mutable {           // start state
    return ++n;
};
counter();   // 1
counter();   // 2

2.4 Capturing *this

Inside a member function, [this] captures the this pointer (lambda accesses members through this->). Beware that the lambda dangles if the object dies first.

[*this] (C++17) copies the entire object into the lambda — the lambda becomes self-contained.

class Widget {
    int value_ = 0;
public:
    auto by_pointer() {
        return [this] { return value_; };       // borrows *this
    }
    auto by_copy() {
        return [*this] { return value_; };      // copies *this
    }
};

3. mutable Lambdas

By default, the lambda's operator() is const, meaning captured-by-value variables cannot be modified inside the body. Adding mutable removes the const.

int x = 10;

auto a = [x] { return x; };               // OK: read-only
auto b = [x] { x = 20; return x; };       // ERROR: can't modify x
auto c = [x]() mutable { x = 20; return x; };  // OK: mutable

mutable only affects captures-by-value. Captures-by-reference can always modify the referenced variable (the reference itself is const, but the referent is not).


4. Generic Lambdas (C++14)

Lambdas with auto parameters become generic — the compiler synthesizes a templated operator().

auto print = [](const auto& x) { std::cout << x; };
print(42);
print("hello");
print(std::vector{1, 2, 3});   // would need an overload of <<

auto add = [](auto a, auto b) { return a + b; };
add(1, 2);                       // int
add(1.0, 2);                     // double
add(std::string("hi "), "world");

Since C++20, you can use explicit template parameters:

auto sum = []<typename T>(const std::vector<T>& v) {
    T total{};
    for (const auto& x : v) total += x;
    return total;
};

5. constexpr and consteval Lambdas

A lambda is implicitly constexpr when its body satisfies constexpr rules. Mark it explicitly with constexpr (C++17) or consteval (C++20, must be evaluated at compile time).

constexpr auto square = [](int x) constexpr { return x * x; };
static_assert(square(5) == 25);

consteval auto cube = [](int x) { return x * x * x; };
constexpr int n = cube(3);   // OK: compile-time
// int m = cube(runtime_x);   // ERROR: must be compile-time

6. auto Type Deduction

auto deduces the type from the initializer using template argument deduction rules. The most important consequences:

int x = 42;

auto a = x;          // int (top-level const/ref/volatile dropped)
auto& b = x;         // int& (reference preserved)
const auto& c = x;   // const int& (you say const explicitly)
auto* d = &x;        // int*

auto e = "hello";    // const char* (NOT std::string)

const int& cr = x;
auto f = cr;         // int (drops const AND reference!)
auto& g = cr;        // const int& (preserves both)

Key rules:

  1. auto (alone) drops references, top-level const, and volatile.
  2. auto& / auto&& / const auto& preserve references and const of the source.
  3. auto* requires a pointer.
  4. Braced initializers (auto x = {1, 2, 3};) deduce std::initializer_list, not the bracketed type.

7. decltype

decltype(expr) gives the exact declared type of expr, including references.

int x = 10;
const int& cr = x;

decltype(x)   a = 0;     // int
decltype(cr)  b = x;     // const int& (preserves the reference)
decltype((x)) c = x;     // int& (parens around an lvalue → reference)

The "extra parentheses" rule is a common gotcha: decltype(name) is the variable's declared type, but decltype((name)) is the type of the expression, which is T& for any lvalue.


8. decltype(auto)

decltype(auto) (C++14) deduces a type using decltype rules — preserving references and const — but inferred from the initializer.

int& f();

auto x = f();              // int (reference dropped)
decltype(auto) y = f();    // int& (preserved)

template <typename Fn>
decltype(auto) wrap(Fn&& fn) {
    return fn();           // returns by reference if fn returns by reference
}

Use decltype(auto) for perfect-return-type forwarding — when you want the wrapper to return exactly what the wrapped function returns, references and all.


9. Class Template Argument Deduction (CTAD, C++17)

CTAD lets you omit class template arguments when the compiler can deduce them from the constructor.

std::pair p{1, "hello"};                 // pair<int, const char*>
std::vector v{1, 2, 3};                  // vector<int>
std::tuple t{1, 'a', 2.0};               // tuple<int, char, double>
std::array a{1, 2, 3};                   // array<int, 3>

std::lock_guard lock(mtx);               // lock_guard<std::mutex>

Library authors can supply deduction guides when default deduction wouldn't pick the right type:

template <typename Iter>
class Range {
public:
    Range(Iter begin, Iter end);
};

// Deduction guide
template <typename Iter>
Range(Iter, Iter) -> Range<Iter>;

std::vector<int> v;
Range r(v.begin(), v.end());   // Range<vector<int>::iterator>

CTAD reduces noise but doesn't replace explicit template arguments when the deduction is ambiguous or wrong.