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

  • Lambda = anonymous function object. Compiler synthesizes a unique class with operator() + (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 has a unique compiler-generated type → two lambdas are always distinct types, even if they look identical.

2. Captures

  • Captures bring outside variables into the lambda 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)
  • Rule: captured-by-reference object must outlive the lambda.
  • Common bug: return lambda that captures local by reference → dangling reference when fn 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
  • Recommend: name captures explicitly ([x, &y]). Defaults hide what's borrowed/owned → bug-prone in long lambdas.

2.3 Init Captures (C++14)

  • Introduce new variables in capture list. For moves + 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

  • [this] — captures this pointer (access via this->). Dangles if object dies first.
  • [*this] (C++17) — copies entire object into lambda; 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

  • Lambda's operator() is const by default → captured-by-value vars can't be modified inside body.
  • 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 referent (reference itself is const, referent isn't).

4. Generic Lambdas (C++14)

  • auto params → generic lambda. 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");
  • C++20: 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

  • Implicitly constexpr when body satisfies constexpr rules.
  • Mark explicitly with constexpr (C++17) or consteval (C++20, compile-time-only).
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 type from initializer via template argument deduction rules.
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, volatile.
  2. auto& / auto&& / const auto& — preserve references + const of source.
  3. auto* — requires a pointer.
  4. Braced initializers (auto x = {1, 2, 3};) → std::initializer_list, not the bracketed type.

7. decltype

  • decltype(expr)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)
  • Gotcha: decltype(name) = declared type. decltype((name)) = type of expressionT& for any lvalue.

8. decltype(auto)

  • decltype(auto) (C++14) — uses decltype rules (preserving refs + const), inferred from 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 for perfect-return-type forwarding — wrapper returns exactly what wrapped fn returns, references and all.

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

  • Omit class template args when compiler can deduce from 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 supply deduction guides when default deduction wouldn't pick 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>
  • Reduces noise. Doesn't replace explicit template args when deduction is ambiguous or wrong.