Лямбды в C++: захват переменных, чек-лист перед продом
Захват по ссылке в лямбде, переживающей свою область видимости, — классический источник use-after-free. Разбираем, что проверять в захватах перед тем, как код уйдёт в продакшен.

Лямбда с захватом [&], которую положили в std::thread, в очередь задач или в std::function внутри объекта, — одна из самых частых причин повреждения памяти в C++. Компилятор не предупредит: синтаксис корректен, тесты на маленьких данных зелёные, а под нагрузкой сервис ловит SIGSEGV или, хуже, тихо читает мусор. Ниже — чек-лист захватов, который стоит прогнать глазами и санитайзером перед релизом. Ориентируюсь на C++17 и C++20, где именно расширился синтаксис захвата.
Почему захват по ссылке ломается
Лямбда — это объект-замыкание: компилятор генерирует класс с operator(), а захваченные переменные становятся его полями. При захвате по значению поле копируется, при захвате по ссылке поле хранит ссылку (по факту — указатель) на исходную переменную. Пока лямбда живёт не дольше, чем захваченные объекты, всё в порядке. Проблема появляется, когда замыкание переживает область видимости, из которой захватило ссылку.
#include <functional>
// Возвращаем лямбду, которая захватила локальную переменную по ссылке.
std::function<int()> make_counter() {
int count = 0;
// count умрёт при выходе из функции, а ссылка на него уедет наружу.
return [&count]() { return ++count; };
}
int main() {
auto counter = make_counter();
return counter(); // use-after-free: count уже уничтожен
}
Здесь count уничтожается на выходе из make_counter, а замыкание продолжает держать на него ссылку. Захват по значению — [count] — снял бы проблему: значение скопировалось бы в поле замыкания.
Санитайзер видит то, что не видит компилятор
Пример выше собирается без единого предупреждения даже с -Wall -Wextra. Но под AddressSanitizer он падает с явным диагнозом:
g++ -std=c++20 -fsanitize=address -g main.cpp -o app
./app
# ==ERROR: AddressSanitizer: stack-use-after-return
# READ of size 4 ... in make_counter()::{lambda()#1}::operator()()
Правило простое: любую лямбду, которая переживает свой скоуп, гоняем под ASan хотя бы раз до релиза. Для stack-use-after-return нужна ещё переменная окружения ASAN_OPTIONS=detect_stack_use_after_return=1 — в свежих сборках Clang и GCC она включена по умолчанию, но проверьте, а не полагайтесь.
Чек-лист захватов перед продакшеном
Проходите по каждому пункту для лямбд, которые куда-то сохраняются, — в поток, в контейнер колбэков, в std::function, в асинхронную задачу.
- Где живёт лямбда дольше вызывающего кода? Если замыкание сохраняется в поле, очередь или поток, захват по ссылке под подозрением по умолчанию.
- Не спрятан ли
thisв[=]или[&]? Обе формы захватывают указательthis, а не копию объекта. Лямбда переживёт объект — получите доступ к освобождённой памяти. - Что захватывается через
[=]на самом деле? Для указателей и итераторов копируется сам указатель, а не то, на что он смотрит. Копия указателя на удалённый объект так же опасна, как ссылка. - Захват
move-onlyобъектов делается через init-capture?[p = std::move(ptr)]— единственный способ перенестиunique_ptrв замыкание (C++14 и новее). - Нет ли гонки при захвате по ссылке в многопоточном коде? Ссылка на общий объект из нескольких потоков без синхронизации — это data race, даже если объект жив.
- Прогнали под ASan и, для потоков, под ThreadSanitizer? Оба ловят то, что компилятор пропускает.
Захват this — отдельная ловушка
Самый коварный случай — колбэк внутри класса, который захватывает this:
#include <functional>
#include <vector>
struct Widget {
int scale = 2;
std::vector<std::function<int(int)>> callbacks;
void add() {
// [=] и [&] захватывают this, а не scale по значению.
callbacks.push_back([this](int x) { return x * scale; });
}
};
Если Widget уничтожат, а callbacks куда-то скопировали или передали раньше, вызов колбэка обратится к освобождённому this. В C++17 можно явно захватить копию объекта: [*this] копирует весь объект в замыкание. Если нужна только одна переменная — захватывайте именно её значением через init-capture: [scale = scale]. Так намерение видно из кода, а не выводится из знания правил захвата.
Захват по ссылке — это заём памяти под честное слово: лямбда обещает не пережить того, у кого заняла. Компилятор это обещание не проверяет — проверяете вы или санитайзер.
По значению или по ссылке: когда что
| Ситуация | Захват | Почему |
|---|---|---|
Лямбда вызывается тут же (алгоритмы STL, std::sort) | по ссылке [&] | дешевле, объекты гарантированно живы |
Лямбда сохраняется в поток / очередь / std::function в поле | по значению [=] или явный список | исходный скоуп исчезнет раньше вызова |
| Нужен весь объект в колбэке класса | [*this] (C++17) | копирует объект, а не указатель |
Перенос unique_ptr и других move-only | init-capture [p = std::move(ptr)] | копирование запрещено, нужен move |
| Большой объект, только чтение, лямбда короткоживущая | по ссылке на const через init-capture | избегаем дорогой копии |
Практическое правило: захват по ссылке — для короткоживущих лямбд в пределах одной функции. Всё, что уходит за границу скоупа, захватывает по значению, и захватывает явным списком переменных, а не [=].
Почему явный список лучше [=]
[=] захватывает всё, что упоминается в теле, включая this, и делает это неявно. Через год кто-то допишет в тело лямбды обращение к члену класса — и this утечёт в замыкание без единой строки изменений в списке захвата. Явный список [scale, offset] заставляет автора осознанно добавить каждую переменную и сразу видеть, что захватывается указатель на объект, а что — значение.
Что ещё ломается
- mutable забыт. Захваченные по значению переменные внутри лямбды по умолчанию
const. Если нужно менять копию — добавляйтеmutable, иначе получите ошибку компиляции про присваивание в const. - Захват по ссылке ссылки на временный объект.
[&ref], гдеrefсвязан с временным объектом, — ссылка повиснет, как только временный объект уничтожится в конце выражения. - Дублирование состояния при копировании замыкания. Захваченное по значению состояние копируется вместе с лямбдой. Скопировали
std::function— получили две независимые копии счётчика, а не общий. - Рекурсивная лямбда через захват самой себя. Через
std::functionэто работает, но замыкание держит ссылку/значениеstd::function, что легко превращается в циклическую ссылку и утечку. В C++23 для рекурсии есть явный параметрthisу лямбд (deducing this), но проверьте поддержку в вашем компиляторе.
Что настроить дальше
Включите в CI отдельный прогон тестов с -fsanitize=address и -fsanitize=thread (два разных прогона, вместе они несовместимы). Именно они ловят use-after-free и гонки на захватах, которые не видны в обычной сборке. Для статики добавьте clang-tidy с проверками из группы bugprone-* — там есть диагностики на висячие ссылки в лямбдах. И заведите в код-ревью простое правило: сохраняемая лямбда с [&] или [this] требует явного комментария, почему захваченные объекты переживут вызов.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Lambda expressions — cppreference.com, AddressSanitizer — Clang documentation
Частые вопросы
А если у меня старый компилятор без C++17 — как захватить объект целиком вместо this?
Санитайзер молчит — значит, захват безопасен?
Захват по значению всегда безопаснее — может, использовать его везде?
Почему [=] считается плохой практикой, если он работает?
Как перенести unique_ptr в лямбду — компилятор ругается на копирование?
Грозит ли висячая ссылка в лямбде именно падением, или бывает хуже?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.