Привет, Хабр!

Лямбды в C++ существуют с 2011 года, и за пятнадцать лет вокруг них накопилось столько неочевидностей, что они до сих пор регулярно встречаются на код-ревью даже у senior-разработчиков. Захваты по ссылке, время жизни this, mutable-семантика, поведение в range-based for, ограничения std::function: каждая из этих тем выглядит элементарной в отрыве, но в боевом коде одна неосторожная лямбда превращается в undefined behavior, который не ловится компилятором и проявляется в продакшене через крэш или, что хуже, через тихое повреждение данных.

Дальше пять задач, в которых легко споткнуться. Попробуйте решить их в уме до того, как откроете разбор. Если три из пяти решите правильно, понимание лямбд у вас выше среднего.

Задача 1: захват по ссылке локальной переменной

Функция возвращает лямбду, которая ссылается на локальное состояние:

#include <functional>
#include <iostream>

std::function<int()> make_counter() {
    int count = 0;
    return [&count]() {
        return ++count;
    };
}

int main() {
    auto counter = make_counter();
    std::cout << counter() << '\n';
    std::cout << counter() << '\n';
    std::cout << counter() << '\n';
}

Программа компилируется без ошибок, без предупреждений в большинстве компиляторов, иногда даже выдаёт ожидаемые 1, 2, 3 в debug-сборке. Что в реальности происходит и почему вывод нельзя считать корректным?

Лямбда захватывает count по ссылке через [&count]. Локальная переменная count живёт в стек-фрейме функции make_counter до момента возврата из неё. После возврата стек-фрейм перестаёт существовать, и память, где лежал count, либо переиспользуется следующими вызовами, либо остаётся со старым значением до тех пор, пока никто не записал туда что-то новое. Лямбда внутри std::function теперь содержит висячую ссылку, обращение к ней при каждом вызове - counter() это undefined behavior.

В debug-сборке часто получается «корректный» вывод по простой причине: после возврата из make_counter стек-фрейм не затирается сразу, область памяти остаётся нетронутой до следующего вызова. В release-сборке с оптимизациями компилятор может переиспользовать ту же память для других переменных в main, и значения начнут вести себя случайным образом. Под санитайзером AddressSanitizer этот код падает с ошибкой stack-use-after-return и показывает точное место проблемы.

Решение: захватывать по значению, что копирует переменную в storage самой лямбды:

std::function<int()> make_counter() {
    int count = 0;
    return [count]() mutable {
        return ++count;
    };
}

Здесь count копируется в саму лямбду, mutable нужен, потому что по умолчанию лямбда с захватом по значению создаёт operator() с квалификатором const, и менять захваченные значения нельзя. С mutable всё работает: счётчик хранится внутри лямбды, переживает любые перемещения и копирования объекта std::function.

GCC 13+ и Clang 16+ с флагом -Wdangling-reference (включён в -Wall начиная с GCC 14) выдают предупреждение в подобных случаях, но реально срабатывает оно не везде. Поэтому правило простое: захватывать по ссылке, только если точно знаете, что лямбда не переживёт область видимости захваченных переменных.

Задача 2: захват this с переживанием объекта

Класс регистрирует callback на сервис уведомлений и забывает о нём:

#include <functional>
#include <string>
#include <vector>
#include <iostream>

class NotificationService {
public:
    void subscribe(std::function<void(const std::string&)> handler) {
        handlers_.push_back(std::move(handler));
    }

    void notify(const std::string& message) {
        for (const auto& h : handlers_) h(message);
    }

private:
    std::vector<std::function<void(const std::string&)>> handlers_;
};

class UserSession {
public:
    UserSession(NotificationService& service, std::string username)
        : username_(std::move(username)) {
        service.subscribe([this](const std::string& msg) {
            std::cout << username_ << " received: " << msg << '\n';
        });
    }

private:
    std::string username_;
};

int main() {
    NotificationService service;
    {
        UserSession alice(service, "alice");
        service.notify("hello");
    }
    service.notify("world");
}

Первый вызов notify работает корректно и выводит «alice received: hello». Второй вызов notify после уничтожения alice приводит к undefined behavior. Какое исправление архитектурно правильное и почему банальный захват username_ по значению вместо this решает не всю проблему?

Лямбда захватывает this, что фактически означает захват указателя на объект UserSession. Когда alice уничтожается в конце блока, указатель остаётся в NotificationService::handlers_, и при следующем notify через этот указатель идёт обращение к уничтоженному объекту.

Захват username_ по значению (через [username = username_] или просто [*this] начиная с C++17) убирает обращение к мёртвому объекту, но решает только верхний слой проблемы. Главная архитектурная проблема не в этом: callback продолжает жить и вызываться, хотя сущность, к которой он относился, уже мертва. В большинстве систем это не то поведение, которое хотел разработчик. Если alice вышла из сессии, она не должна получать уведомления.

Правильное решение использует механизм деподписки. Самый распространённый вариант в современном C++: weak_ptr вместе с shared_ptr плюс проверка валидности в начале callback.

class UserSession : public std::enable_shared_from_this<UserSession> {
public:
    static std::shared_ptr<UserSession> create(NotificationService& service, std::string username) {
        auto session = std::shared_ptr<UserSession>(new UserSession(std::move(username)));
        std::weak_ptr<UserSession> weak = session;
        service.subscribe([weak](const std::string& msg) {
            if (auto self = weak.lock()) {
                std::cout << self->username_ << " received: " << msg << '\n';
            }
        });
        return session;
    }

private:
    explicit UserSession(std::string username) : username_(std::move(username)) {}
    std::string username_;
};

Теперь callback держит weak_ptr, который не продлевает время жизни объекта. При вызове weak.lock() возвращается либо shared_ptr на живой объект (и callback выполняется), либо пустой shared_ptr (и callback тихо пропускается). Эта схема стандартная для асинхронных систем на C++ и встречается в Boost.Asio, Folly, Qt и многих других местах.

Альтернативный подход без shared_ptr: возвращать из subscribe какой-то id и иметь явный метод unsubscribe, который вызывается в деструкторе подписчика.

Задача 3: mutable lambda копирует, а не разделяет состояние

Сценарий с распределением работы между несколькими worker-потоками. Каждый поток получает свою копию лямбды, но логически счётчик должен быть один:

#include <iostream>
#include <thread>
#include <vector>

void run_workers() {
    int processed = 0;
    auto worker = [processed]() mutable {
        for (int i = 0; i < 100; ++i) {
            ++processed;
        }
        std::cout << "Worker processed: " << processed << '\n';
    };

    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(worker);
    }
    for (auto& t : threads) t.join();

    std::cout << "Total processed: " << processed << '\n';
}

Каждый из пяти worker-потоков выводит «Worker processed: 100», в финале «Total processed: 0». Разработчик ожидал увидеть 500 в финале (пять воркеров обработали по 100 элементов). Что пошло не так и какие два решения тут есть, в зависимости от того, что вообще требовалось?

mutable lambda хранит захваченные переменные внутри объекта самой лямбды. Когда лямбда передаётся в std::thread или копируется в threads.emplace_back(worker), копируется и её внутреннее состояние. Каждый поток получает свою независимую копию processed, увеличивает её до 100 и завершается. Переменная processed в run_workers - это совсем другая переменная, которая не менялась ни разу, потому что захват был по значению.

Решение зависит от того, что хотел программист.

Если задача состояла в том, чтобы каждый воркер посчитал свою порцию работы независимо, никакой проблемы и нет, всё работает корректно. В финале нужно либо собрать результаты от воркеров через future, либо использовать atomic-счётчик:

#include <atomic>

void run_workers() {
    std::atomic<int> processed{0};
    auto worker = [&processed]() {
        for (int i = 0; i < 100; ++i) {
            processed.fetch_add(1, std::memory_order_relaxed);
        }
    };

    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(worker);
    }
    for (auto& t : threads) t.join();

    std::cout << "Total processed: " << processed.load() << '\n';
}

Захват processed по ссылке плюс atomic-операции дают разделяемый между потоками счётчик, который инкрементируется без data race и виден после join всех потоков. Memory ordering relaxed достаточен, потому что нет зависимости от других переменных.

Если задача состояла в том, чтобы каждый воркер вёл свой локальный счётчик и не было shared state, то и без atomic всё работало бы, только финальное значение надо получать через future:

#include <future>

void run_workers() {
    std::vector<std::future<int>> futures;
    for (int i = 0; i < 5; ++i) {
        futures.push_back(std::async(std::launch::async, []() {
            int processed = 0;
            for (int j = 0; j < 100; ++j) ++processed;
            return processed;
        }));
    }

    int total = 0;
    for (auto& f : futures) total += f.get();
    std::cout << "Total processed: " << total << '\n';
}

mutable lambda в реальном коде нужна сравнительно редко: либо для memo-функций со внутренним состоянием, либо для функторов, которые передаются один раз и не копируются. В большинстве случаев, когда хочется поставить mutable, на самом деле нужен либо захват по ссылке плюс atomic, либо архитектура без разделяемого состояния.

Задача 4: захват переменной из range-based for

Код собирает callbacks, каждый из которых должен запомнить значение из контейнера на момент создания:

#include <functional>
#include <iostream>
#include <vector>

std::vector<std::function<void()>> create_callbacks() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    std::vector<std::function<void()>> callbacks;

    for (const auto& value : data) {
        callbacks.push_back([&value]() {
            std::cout << value << ' ';
        });
    }

    return callbacks;
}

int main() {
    auto callbacks = create_callbacks();
    for (const auto& cb : callbacks) cb();
    std::cout << '\n';
}

Программа выводит «5 5 5 5 5» (или другую последовательность одинаковых чисел, или вообще мусор) вместо ожидаемого «1 2 3 4 5». В чём подвох именно с range-based for, чего бы не случилось с обычным for-циклом по индексу?

value - это ссылочная переменная в range-based for. В каждой итерации она привязывается к следующему элементу контейнера, но это одна и та же ссылочная переменная, которая просто переуказывает. Лямбда захватывает её по ссылке через [&value], и все лямбды получают ссылку на одну и ту же переменную value.

После выхода из цикла переменная value уничтожается, и каждая лямбда содержит висячую ссылку. Дальше плюс тот факт, что create_callbacks возвращает callbacks, выходя из функции, ссылка указывает на стек-фрейм, который уже не существует. Двойное undefined behavior.

Решение очевидное, но не сразу заметное при беглом чтении: захватывать значение, а не ссылку.

for (const auto& value : data) {
    callbacks.push_back([value]() {
        std::cout << value << ' ';
    });
}

Теперь каждая лямбда копирует текущее значение value в свой собственный storage, и все пять лямбд получают разные числа. Программа выводит «1 2 3 4 5».

Особенность range-based for в том, что промежуточная переменная цикла одна на весь цикл, в отличие от языков вроде JavaScript с let-биндингом, где для каждой итерации создаётся новая переменная. В C++ это сделано из соображений производительности, но цена для лямбд высока: захват по ссылке в цикле почти всегда ошибка, кроме случаев, когда лямбда гарантированно выполняется до конца итерации.

Если очень нужен захват по ссылке (например, дорогой объект, который нельзя копировать), стоит использовать структуру shared_ptr и захватывать его по значению, или использовать capture init с C++14:

for (const auto& value : data) {
    callbacks.push_back([v = value]() {
        std::cout << v << ' ';
    });
}

Capture init копирует выражение в новую переменную внутри лямбды, что эквивалентно [value] для простых типов.

Задача 5: move-only типы в std::function

Современный код часто работает с unique_ptr, который запрещает копирование. Логично попытаться захватить такой объект в лямбде:

#include <functional>
#include <memory>
#include <iostream>

struct Resource {
    Resource() { std::cout << "Resource constructed\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
    void use() { std::cout << "Resource used\n"; }
};

int main() {
    auto resource = std::make_unique<Resource>();
    std::function<void()> task = [r = std::move(resource)]() {
        r->use();
    };
    task();
}

Компилятор отказывается компилировать этот код с длинным сообщением про то, что unique_ptr не копируется. Сама лямбда [r = std::move(resource)] не копируется вместе с unique_ptr, проблема возникает на этапе обёртки в std::function.

Почему std::function это не выдерживает и какой стандартный способ обойти ограничение в C++23?

std::function требует, чтобы хранящийся в нём callable был копируемым. Это требование заложено в стандарте C++11 и не убрано до сих пор: внутри std::function может потребоваться копировать обёртку, например, при копировании самой std::function. Лямбда [r = std::move(resource)] копируема только если все её захваченные типы копируемы, а unique_ptr копируемым не является.

Логика стандартного std::function устарела за прошедшие пятнадцать лет. В современном C++ move-only callbacks встречаются сплошь и рядом: capture unique_ptr, capture файлового дескриптора, capture coroutine handle.

В C++23 в стандарте появился std::move_only_function ровно для этого случая. Он работает почти как std::function, но не требует копируемости хранящегося callable:

#include <functional>
#include <memory>

int main() {
    auto resource = std::make_unique<Resource>();
    std::move_only_function<void()> task = [r = std::move(resource)]() {
        r->use();
    };
    task();
}

Этот код компилируется и работает, потому что std::move_only_function хранит callable и не требует возможности его копирования. Сам объект task move-only: его можно перемещать, но не копировать. В большинстве сценариев move-семантика как раз и нужна (передача между потоками, хранение в контейнере).

Если по каким-то причинам C++23 ещё недоступен (старая платформа, корпоративные ограничения на стандарт), стандартный обходной путь это shared_ptr вместо unique_ptr:

auto resource = std::make_shared<Resource>();
std::function<void()> task = [resource]() {
    resource->use();
};

Shared_ptr копируется без проблем, лямбда копируется, std::function счастлива. Платится за это атомарным счётчиком ссылок, что для большинства callback-сценариев несущественно. Но архитектурно правильнее перейти на std::move_only_function, если стандарт позволяет.

В C++26 (ожидается осенью 2026) появилось дополнение std::copyable_function, которое явно обозначает требование копируемости, и старый std::function постепенно переводится в категорию устаревших с точки зрения именования. Сейчас рекомендация такова: новый код пишем на std::move_only_function или std::copyable_function в зависимости от требований, std::function оставляем для legacy-совместимости.


Что забрать в работу

Все пять задач выше показывают одно и то же: внешняя простота лямбд скрывает много неявного поведения вокруг времени жизни захваченных объектов и взаимодействия с обёртками. Захват по ссылке локальной переменной даёт dangling reference при возврате из функции. Захват this не учитывает время жизни объекта. Mutable lambda копирует состояние, а не разделяет его между копиями. Захват в range-based for ссылается на одну и ту же переменную цикла. Move-only типы не пролезают через классический std::function.

Все ситуации компилируются без ошибок и часто без предупреждений. Часть ловится санитайзерами в runtime, часть только бенчмарками или анализаторами кода. Практическое правило: захватывать по значению по умолчанию, переходить на захват по ссылке только если уверены, что лямбда не переживёт область видимости захваченных переменных. Для долгоживущих лямбд (в std::function, callbacks, std::thread) использовать смарт-указатели для управления временем жизни и явный механизм отмены подписок.

Современные стандарты постепенно закрывают исторические углы: с C++17 появился захват [*this], копирующий объект целиком, с C++23 пришёл std::move_only_function для callable с unique_ptr, в C++26 фиксируется явное разделение copyable и move-only функций. Если стандарт проекта позволяет, эти средства убирают значительную часть проблем.

Хотите глубже разобраться в современном C++ не только по статьям, но и на живых примерах из практики? Приходите на открытые уроки OTUS — их ведут преподаватели-практики, которые каждый день работают с промышленной разработкой и разбирают не абстрактную теорию, а реальные инженерные ошибки.

  • 30 июня, 20:00. «RAII в C++: фундамент надёжного управления ресурсами». Записаться
    разберём, как управлять ресурсами так, чтобы код был устойчивее к утечкам, исключениям и ошибкам времени жизни.

  • 2 июля, 20:00. «Всё, что нужно знать об управлении памятью в C++». Записаться
    поговорим о памяти, владении объектами и типичных ловушках, которые приводят к undefined behavior.

  • 16 июля, 20:00. «Выразительный C++: кодируем намерения». Записаться
    обсудим, как писать код, в котором лучше видны ownership, ограничения и ожидания разработчика.

Больше открытых уроков по разработке, инфраструктуре, аналитике, ИИ и другим ИТ-направлениям — в дайджесте.