Лямбда-выражения: полное руководство

Лямбда — это анонимная функция, которую можно определить прямо в месте использования. Самая мощная и одновременно самая непонятная возможность C++11. Здесь разберём её по частям: синтаксис, захват переменных, замыкания, mutable, std::function и типичные ловушки.

C++11 Замыкания Захват переменных std::function

Зачем вообще нужны лямбды

Начнём с задачи, где лямбда решает проблему, а без неё приходится писать много лишнего кода.

Задача: отсортировать вектор чисел по убыванию модуля.

Функция (C-style)

bool compareAbs(int a, int b) {
    return abs(a) > abs(b);
}

sort(v.begin(), v.end(),
     compareAbs);
Функция рядом, вне sort

Функтор (класс)

struct CompareAbs {
    bool operator()(int a, int b) const {
        return abs(a) > abs(b);
    }
};

sort(v.begin(), v.end(),
     CompareAbs{});
Класс ради одной строки

Лямбда (C++11)

sort(v.begin(), v.end(),
    [](int a, int b) {
        return abs(a) > abs(b);
    });
Логика — прямо в sort
Преимущество лямбды. Логика видна прямо там, где используется. Не нужно отвлекаться на отдельное имя функции или класс с одним методом. Именно поэтому лямбды стали основным инструментом для функций обратного вызова (callbacks).

Анатомия лямбда-выражения

Разберём синтаксис по частям. У лямбды есть пять компонент, из которых обязательны только две.

[capture] (параметры) mutable -> тип_возврата { тело } Список захвата обязательно, может быть [] Параметры как у обычной функции mutable опционально: разрешить менять копии Возвращаемый тип опционально, обычно выводится Тело обязательно, как у функции Пример: [x, &y](int a) mutable -> int { return a + x + y; }
минимальная лямбда — ничего не захватывает
// Пустой список захвата — ведёт себя как обычная функция
auto square = [](int x) { return x * x; };

cout << square(5);   // 25

// То же самое, что написать функцию:
int square(int x) { return x * x; }
Главная идея. Лямбда — это анонимная функция, определённая в месте использования. Компилятор превращает её в класс с operator(), но нам не нужно об этом думать.

Захват переменных: главный источник путаницы

Лямбда может использовать переменные из внешней области видимости — но только те, которые явно указаны в списке захвата. Способ захвата влияет на поведение: копия или ссылка?

Синтаксис Что захватывает Что происходит при изменении внешней переменной
[] Ничего Переменные недоступны внутри лямбды
[x] Копию x Изменения внешнего x не видны
[&x] Ссылку на x Изменения внешнего x видны сразу
[=] Копии всех используемых переменных Изменения внешних переменных не видны
[&] Ссылки на все используемые переменные Изменения внешних переменных видны
[=, &x] Копии всех, кроме x (он по ссылке) Гибридный вариант
[&, x] Ссылки на все, кроме x (он по копии) Гибридный вариант

Демонстрация 1: что на самом деле захватывает лямбда

Слева — «внешние» переменные, справа — что увидят лямбды. Измените значение и посмотрите, как поведут себя разные захваты.

внешняя x
10

Три лямбды в программе

auto byCopy = [x]() {
    return x;
};

auto byRef = [&x]() {
    return x;
};

auto byCopyAll = [=]() {
    return x;
};

Что видят лямбды

byCopy()    → 10
byRef()     → 10
byCopyAll() → 10
$ // x = 10 — все три лямбды видят одинаковое значение

Замыкания: почему это работает

Когда лямбда захватывает переменные, компилятор создаёт объект-замыкание — класс со скрытыми полями для каждой захваченной переменной и одним методом operator(). Именно поэтому захваченные переменные «живут» внутри лямбды.

Это лямбда:

что пишем мы
int x = 10, y = 20;
auto sum = [x, y](int z) { return x + y + z; };

cout << sum(5);   // 35

Это то, во что её превращает компилятор:

что видит компилятор
class __lambda_1 {
    int x;   // копия внешней x
    int y;   // копия внешней y
public:
    __lambda_1(int _x, int _y) : x(_x), y(_y) {}

    int operator()(int z) const {
        return x + y + z;
    }
};

auto sum = __lambda_1(x, y);   // захват = вызов конструктора
cout << sum(5);                // вызов operator()
Ключевая мысль. Захваченные переменные становятся полями класса. Поэтому они живут столько же, сколько живёт объект-замыкание. Именно отсюда берутся все неочевидные эффекты с временем жизни.
Почему operator() помечен const. По умолчанию лямбда не может менять захваченные по значению переменные — её operator() константный. Чтобы разрешить изменения, нужно ключевое слово mutable.

Ключевое слово mutable

Оно снимает константность с operator(), позволяя лямбде менять свои копии захваченных переменных. Важно: речь идёт именно о копиях — внешние переменные при этом не меняются.

Демонстрация 2: как работает mutable

Лямбда со счётчиком вызовов. Нажмите «Вызвать лямбду» несколько раз и следите за состоянием. Обратите внимание: внешняя переменная не меняется.

Код

int counter = 0;

auto next = [counter]() mutable {
    counter++;
    return counter;
};

// Первый вызов → 1
// Второй вызов  → 2
// Третий вызов  → 3
// Внешний counter всё ещё 0!

Состояние

Внешняя counter: 0
Копия в лямбде: 0
Вызовов сделано: 0
Последний результат: —
$ // нажмите «Вызвать лямбду», чтобы увидеть эффект

Главная ловушка: висячие ссылки в лямбдах

Захват по ссылке опасен, когда лямбда живёт дольше, чем захваченная переменная. Компилятор об этом не предупредит — программа просто начнёт выдавать мусор.

Демонстрация 3: что происходит при висячей ссылке

Программа создаёт лямбду внутри блока, возвращает её наружу, а затем вызывает. Пройдите по шагам и увидите, что при захвате по ссылке лямбда «читает мусор».

Код программы


        

Состояние памяти

x внутри блока: —
область видимости блока: активна
лямбда: —
Результат вызова: —
$ // нажмите «Шаг», чтобы проиграть сценарий
Правило. Никогда не захватывайте по ссылке переменные, которые могут умереть раньше, чем лямбда. Для лямбд, сохраняемых «на потом» (в контейнерах, колбэках, потоках), используйте захват по значению или std::shared_ptr.

std::function: универсальная обёртка для вызываемых объектов

Лямбда имеет уникальный тип, известный только компилятору. Если нужно сохранить её в переменной, которая объявлена в другом месте (в заголовке класса, в контейнере, в параметре функции), используется std::function.

что хранит std::function
#include <functional>

// Лямбда без захвата — можно сохранить в указатель на функцию
int (*fp)(int) = [](int x) { return x * x; };

// Лямбда с захватом — только в std::function
int threshold = 5;
function<bool(int)> isBig = [threshold](int x) {
    return x > threshold;
};

// В std::function можно положить:
// 1. Лямбду со захватом
// 2. Обычную функцию
// 3. Функтор
// 4. Указатель на функцию-член

isBig(10);   // true
isBig(3);    // false
Цена std::function. Она стирает тип — отсюда небольшие накладные расходы (виртуальный вызов + возможно копирование лямбды в внутренний буфер). Если лямбда используется только локально, лучше оставить auto — это быстрее и позволяет компилятору инлайнить.

Демонстрация 4: лямбды в конвейере обработки

Пять лямбд сохранены в векторе std::function. Каждая получает число и возвращает результат. Прогоните конвейер по шагам.

вход: 2
+10
×2
abs
-5
результат
$ // нажмите «Запустить конвейер»
реализация конвейера
#include <vector>
#include <functional>
using namespace std;

int main() {
    vector<function<int(int)>> pipeline;

    pipeline.push_back([](int x) { return x + 10; });
    pipeline.push_back([](int x) { return x * 2;  });
    pipeline.push_back([](int x) { return abs(x); });
    pipeline.push_back([](int x) { return x - 5;  });

    int value = 2;
    for (auto& step : pipeline) {
        value = step(value);
    }

    cout << value << endl;
}

Универсальные лямбды и другие возможности C++14

auto в параметрах — шаблонная лямбда

работает для разных типов
auto print = [](const auto& x) {
    cout << x << endl;
};

print(42);            // int
print(3.14);          // double
print(string("hi"));  // std::string

Инициализация захвата (init capture)

создаём новую переменную в захвате
int x = 10;

// Новая переменная y живёт в лямбде
auto f = [y = x * 2]() { return y; };

cout << f();   // 20

// Можно переместить объект в лямбду
auto g = [data = std::move(vect)]() {
    // data — теперь собственность лямбды
};

IIFE — лямбда, вызываемая сразу

Иногда нужно выполнить код один раз для инициализации. Лямбда позволяет сделать это, не создавая отдельную функцию.

немедленно вызываемая лямбда
const int config_value = []() {
    if (some_condition)
        return 42;
    else
        return 17;
}();   // ← обратите внимание на скобки в конце

// Значение получено и присвоено сразу
// Никакой отдельной функции не нужно

Лямбды в алгоритмах STL

Это самое частое применение лямбд. Стандартные алгоритмы принимают функцию — и лямбда позволяет описать эту функцию прямо на месте.

find_if — найти первое чётное число
vector<int> v = {3, 5, 7, 8, 11, 12};

auto it = find_if(v.begin(), v.end(),
    [](int x) { return x % 2 == 0; });

if (it != v.end())
    cout << *it;   // 8
sort — сортировка по своему критерию
struct Person { string name; int age; };

vector<Person> people = { /* ... */ };

sort(people.begin(), people.end(),
    [](const Person& a, const Person& b) {
        return a.age < b.age;
    });
for_each — применить ко всем
for_each(v.begin(), v.end(),
    [](int x) { cout << x * 2 << " "; });

// Похоже на range-for, но позволяет
// использовать std::function,
// прерывать по условию и т.д.
count_if — посчитать по условию
int threshold = 10;
int count = count_if(v.begin(), v.end(),
    [threshold](int x) {
        return x > threshold;
    });

Лучшие практики

1. Предпочитайте явные захваты [x, &y]

[=] и [&] удобны, но опасны: [=] копирует больше, чем нужно, а [&] может оставить висячие ссылки. Явный список захвата делает намерения программиста прозрачными.

2. Для лямбд «на потом» — только захват по значению

Если лямбда сохраняется в контейнер, передаётся в другой поток или возвращается из функции — захват по ссылке почти наверняка создаст висячую ссылку. Захватывайте по значению или используйте shared_ptr.

3. Не используйте std::function там, где хватит auto

auto f = [](int x) { ... }; хранит лямбду в её собственном типе — компилятор может инлайнить вызов. std::function добавляет виртуальный вызов. Используйте его, только когда нужно хранить разные вызываемые объекты под одним типом.

4. mutable — только когда действительно нужен

Лямбда, меняющая своё состояние между вызовами — редкая ситуация. Если она используется в алгоритмах STL (вроде for_each), поведение может стать непредсказуемым: неизвестно, в каком порядке алгоритм будет обходить элементы.

Антипаттерн: захват [&] в колбэке, который переживёт функцию

плохой код
function<void()> makeCallback() {
    int local = 42;
    return [&local]() {           // ← захват по ссылке
        cout << local << endl;
    };
}

int main() {
    auto cb = makeCallback();
    cb();   // читает мусор — local уже не существует
}

Решение: [local]() { ... } — захват по значению.

Антипаттерн: захват [=] от this в классах

В C++11/14 захват [=] в функции-члене захватывает указатель this, а не копию объекта. Если объект будет уничтожен, а лямбда — сохранена, получите висячий указатель на this. В C++17 появился явный захват [*this] — он действительно копирует объект.

Шпаргалка

СинтаксисЗначение
[](){ }Лямбда без захвата — эквивалент обычной функции
[x](){ }Захват копии x
[&x](){ }Захват ссылки на x
[=](){ }Копии всех используемых переменных
[&](){ }Ссылки на все используемые переменные
[x, &y](){ }Комбинация: копия x, ссылка на y
[y = x*2](){ }Init capture: создаём новую переменную y
[ptr = std::move(v)](){ }Захват через перемещение
[](int x) mutable { }Разрешить менять копии захваченных переменных
[](auto x){ }Универсальная лямбда — работает с любым типом
[](){ ... }()IIFE: немедленно вызвать лямбду
function<int(int)> f = [](int x){ return x; };Сохранить лямбду в std::function

Задание

  1. Напишите лямбду [](int a, int b){ return a + b; } и вызовите её. Чем она отличается от обычной функции?
  2. Внутри функции создайте int x = 10;. Определите две лямбды: [x] и [&x], возвращающие x. Измените x = 100 и вызовите обе. Объясните разницу.
  3. Напишите лямбду со счётчиком вызовов через mutable. Сколько раз нужно вызвать, чтобы счётчик дошёл до 5?
  4. Объясните, почему этот код опасен:
    auto make = []() {
        int x = 42;
        return [&x]{ return x; };
    };
    auto f = make();
    cout << f();
  5. Реализуйте ту же функцию, но безопасно (через захват по значению).
  6. Отсортируйте вектор строк по длине, затем по алфавиту при равной длине. Используйте лямбду как компаратор.
  7. Создайте std::function<int(int)> и сохраните в него лямбду [](int x){ return x * x; }. Вызовите её дважды.
  8. Захватите уникальный указатель в лямбду через перемещение:
    auto ptr = make_unique<int>(42);
    auto f = [p = std::move(ptr)]() { return *p; };
    Почему после этого ptr нельзя использовать?
  9. Реализуйте IIFE, который возвращает разные значения в зависимости от условия. В чём преимущество по сравнению с отдельной функцией?
  10. Используйте std::for_each с лямбдой, чтобы увеличить каждый элемент вектора в два раза. Обратите внимание: чтобы изменить элемент, лямбда должна принимать его по ссылке.