Синтаксис си/с++

Лямбда-выражения: захват переменных, типы захвата

Введение

Мы разобрали указатели на функции как способ передачи поведения как параметра — лямбда-выражения (C++11) дают значительно более гибкую, современную альтернативу, способную «захватывать» переменные из окружающего контекста, чего указатели на функции не умеют принципиально, и в этой статье разберём лямбды и различные способы захвата.

Концепция

Лямбда-выражение — анонимная (не имеющая явного имени) функция, определяемая непосредственно в месте использования, синтаксис которой включает список захвата ([...], определяющий, какие переменные из окружающего контекста доступны внутри лямбды и как — по значению или по ссылке), список параметров, и тело функции. Захват по значению ([x]) создаёт копию переменной x на момент создания лямбды, и последующие изменения оригинальной переменной x за пределами лямбды не повлияют на эту захваченную копию — захват по ссылке ([&x]) даёт лямбде прямой доступ к оригинальной переменной, и изменения, видимые снаружи лямбды (или сделанные самой лямбдой), отражаются на одной и той же, общей переменной. [=] и [&] — сокращённые формы, захватывающие ВСЕ упомянутые в теле лямбды внешние переменные по значению или по ссылке соответственно, без необходимости перечислять каждую переменную явно по отдельности.

Пример кода

#include <iostream>
#include <vector>
#include <algorithm>

int main()
{
    int threshold = 10;

    auto isAboveThreshold = [threshold](int value) { // захват threshold ПО ЗНАЧЕНИЮ
        return value > threshold;
    };

    std::vector<int> numbers = {5, 15, 8, 20, 3};
    int count = std::count_if(numbers.begin(), numbers.end(), isAboveThreshold);
    std::cout << "Чисел больше " << threshold << ": " << count << std::endl;

    return 0;
}
// Захват по значению против захвата по ссылке — принципиально разное поведение
#include <iostream>

int main()
{
    int counter = 0;

    auto incrementByValue = [counter]() mutable { // mutable — необходимо для ИЗМЕНЕНИЯ захваченной по значению копии
        counter++; // изменяет только ВНУТРЕННЮЮ копию лямбды, НЕ оригинальный counter
        std::cout << "Внутри (by value): " << counter << std::endl;
    };

    auto incrementByReference = [&counter]() {
        counter++; // изменяет ОРИГИНАЛЬНУЮ переменную counter напрямую
        std::cout << "Внутри (by reference): " << counter << std::endl;
    };

    incrementByValue();   // "Внутри (by value): 1" — но оригинальный counter всё ещё 0!
    incrementByValue();   // "Внутри (by value): 2" — внутренняя копия растёт независимо
    std::cout << "Оригинал после by value: " << counter << std::endl; // 0 — НЕ изменился вовсе!

    incrementByReference(); // "Внутри (by reference): 1"
    std::cout << "Оригинал после by reference: " << counter << std::endl; // 1 — РЕАЛЬНО изменился

    return 0;
}
// std::function для хранения лямбд как обычных переменных, передачи и возврата из функций
#include <functional>
#include <iostream>

std::function<int(int, int)> chooseOperation(bool useAddition)
{
    if (useAddition) {
        return [](int a, int b) { return a + b; }; // лямбда БЕЗ захвата — может конвертироваться даже в указатель на функцию
    }
    return [](int a, int b) { return a * b; };
}

int main()
{
    auto operation = chooseOperation(true);
    std::cout << operation(3, 4) << std::endl; // 7
    return 0;
}

Пояснения к коду

isAboveThreshold показывает типичное, идиоматичное применение лямбды с захватом по значению — threshold захватывается единожды, на момент создания лямбды, что подходит для случая, когда лямбда передаётся стандартному алгоритму (std::count_if) и значение порога не должно изменяться между вызовами. Сравнение incrementByValue/incrementByReference явно демонстрирует принципиальное различие захвата — несмотря на идентичную логику внутри обеих лямбд (counter++), только версия с захватом по ссылке ([&counter]) реально изменяет оригинальную переменную, видимую за пределами лямбды, тогда как версия с захватом по значению ([counter], дополнительно требующая mutable для возможности изменения захваченной копии внутри тела лямбды) работает исключительно с собственной, изолированной копией, не влияющей на оригинал. std::function<int(int, int)> показывает практичный способ хранения лямбды как обычного значения (возможность вернуть её из функции, сохранить в переменной, передать дальше), что особенно важно, поскольку лямбды без захвата (как в этом примере) технически не имеют единого, простого «типа лямбды» в обычном смысле (каждая лямбда имеет собственный, уникальный, генерируемый компилятором тип), и std::function даёт унифицированную «обёртку», способную хранить любую лямбду (или указатель на функцию) с подходящей сигнатурой.

Подводные камни

  • Захват переменной по ссылке ([&x]) лямбдой, время жизни которой превышает время жизни захваченной переменной — это классический, серьёзный источник неопределённого поведения, особенно когда лямбда сохраняется (например, в std::function или асинхронно выполняется позже, в основном цикле статей про асинхронность), а захваченная по ссылке локальная переменная уже вышла из области видимости (статья 525) к моменту реального вызова лямбды, что приводит к обращению к уже несуществующей памяти, аналогично подводным камням про возврат ссылки на локальную переменную.
  • Использование [=] (захват всех переменных по значению) для лямбды, которая должна изменять одну из захваченных переменных, ожидая, что изменение отразится на оригинале — широкий захват по значению создаёт независимые копии для всех использованных переменных, и без точного, явного захвата конкретной переменной по ссылке ([&specificVariable]) изменения внутри лямбды никогда не повлияют на соответствующие оригинальные переменные за её пределами, что является частой, неочевидной для начинающих ловушкой при первом знакомстве с лямбдами.
  • Забытый mutable для лямбды, захватывающей переменную по значению, но пытающейся изменить эту захваченную копию внутри своего тела — без mutable оператор () лямбды по умолчанию является константным (аналогично const-методу класса), что запрещает изменение захваченных по значению полей внутри тела лямбды, и попытка такого изменения без mutable приводит к ошибке компиляции, явно указывающей на необходимость добавления этого ключевого слова.
  • Захват this (неявный или явный, через [this]) в лямбде, переживающей время жизни объекта, к которому принадлежит this — аналогично общему подводному камню захвата по ссылке, лямбда, захватившая this указатель текущего объекта и сохранённая для использования позже (после потенциального уничтожения этого объекта), при попытке вызова будет обращаться к полям уже не существующего объекта через «висячий» this, что является неопределённым поведением, требующим особого внимания при сохранении лямбд, захватывающих this, для отложенного, асинхронного выполнения.