Лямбды в C++: как захват и std::function бьют по производительности
Разбор захвата, времени жизни и стирания типа в лямбдах C++. Где скрытая аллокация, где висячая ссылка и почему std::function в горячем цикле стоит вам процентов производительности.

Профиль показал, что 30% времени обработчик событий проводит не в бизнес-логике, а в operator() обёртки std::function и в malloc. Событий было около 200 тысяч в секунду, каждое проходило через колбэк, сохранённый в std::vector<std::function<void(const Event&)>>. Ниже — разбор того, что именно происходит с лямбдой в момент захвата, где рождается аллокация и висячая ссылка, и как переписать горячий путь без этого. Всё для C++17 и новее; отдельные приёмы требуют C++20, это отмечено.
Что такое лямбда на самом деле
Лямбда — это не функция. Компилятор генерирует безымянный класс с перегруженным operator(), а захваченные переменные становятся его полями. Два разных лямбда-выражения — два разных типа, даже если тело одинаковое. Именно поэтому вы не можете положить лямбду в std::vector напрямую и не можете написать её тип руками — только auto или decltype.
Захват по значению копирует переменную в поле замыкания. Захват по ссылке кладёт в поле ссылку — то есть указатель на исходный объект. Всё поведение, которое кусает на проде, вытекает из этих двух фактов.
Лямбда с пустым списком захвата [] — это просто структура без полей. Она неявно приводится к обычному указателю на функцию и не стоит вам ничего. Как только внутри квадратных скобок появляется хоть одна переменная, приведение к указателю пропадает.
Ошибка первая: висячая ссылка при захвате по ссылке
Классика на живом проекте — лямбда переживает объект, на который ссылается. Регистрируем колбэк, объект уходит из области видимости, при вызове читаем мусор.
// Опасно: config захвачен по ссылке, но живёт короче колбэка
void registerHandler(EventBus& bus) {
LocalConfig config = loadConfig();
bus.subscribe([&config](const Event& e) {
// config уже уничтожен к моменту вызова
handle(e, config.threshold);
});
} // здесь config разрушается, ссылка в лямбде повисает
Компилятор не предупредит: захват по ссылке легален. Санитайзер поймает — соберите отладочную версию с -fsanitize=address, и AddressSanitizer выдаст heap-use-after-free или stack-use-after-return с трейсом до строки захвата. Это первое, что стоит включить, если колбэки ведут себя нестабильно.
Правило простое: захват по ссылке безопасен, только пока лямбда гарантированно не переживёт захваченное. Локальный std::for_each, std::sort, std::remove_if — безопасно, лямбда умирает в той же строке. Колбэк, который куда-то сохраняется, — захватывайте по значению или явно перемещайте владение.
Ошибка вторая: скрытая аллокация в std::function
Чтобы сложить разнотипные лямбды в один контейнер, разработчики тянутся к std::function. Она стирает тип — снаружи все колбэки выглядят одинаково. Плата за это — стирание типа реализуется через виртуальный вызов и, если замыкание не помещается в small-object-буфер, через аллокацию в куче.
Размер буфера не стандартизован. На типичных реализациях libstdc++ и libc++ он около 16 байт. Захватили два std::string или shared_ptr с чем-то ещё — вылезли за буфер, получили new на каждое создание std::function. В горячем цикле это те самые проценты, которые показал профиль.
| Способ хранения колбэка | Аллокация | Вызов | Когда брать |
|---|---|---|---|
Шаблон template<class F> | нет | инлайнится | тип известен на месте вызова |
std::function | возможна (за пределами SBO) | виртуальный, непрямой | нужен один тип-контейнер для разных лямбд |
| указатель на функцию | нет | непрямой | захвата нет, нужен C-совместимый API |
std::move_only_function (C++23) | возможна | виртуальный | колбэк захватывает move-only тип |
Как переписать горячий путь
Если тип колбэка известен в точке вызова — не стирайте его. Принимайте шаблонный параметр, лямбда прокинется по значению и вызов заинлайнится.
// Без стирания типа: вызов инлайнится, аллокаций нет
template <class Handler>
void forEachEvent(const std::vector<Event>& events, Handler&& handler) {
for (const Event& e : events) {
handler(e); // прямой вызов operator() замыкания
}
}
// вызов на месте — тип лямбды выводится, ничего не стирается
forEachEvent(events, [threshold](const Event& e) {
if (e.value > threshold) process(e);
});
Когда контейнер разнотипных колбэков всё-таки нужен — сначала померьте, попадаете ли вы в small-object-буфер. Держите замыкание маленьким: захватывайте один указатель или индекс вместо нескольких строк. Часто достаточно захватить this и id, а данные достать внутри.
Ошибка третья: mutable, generic и жизнь после C++14
По умолчанию operator() лямбды помечен const — захваченные по значению поля менять нельзя. Ключевое слово mutable снимает этот const. Частая путаница: mutable позволяет менять копию внутри лямбды, а не исходную переменную снаружи.
int counter = 0;
auto next = [counter]() mutable {
// меняется копия внутри замыкания, внешний counter не тронут
return ++counter;
};
next(); next(); // вернёт 1, потом 2
// counter снаружи по-прежнему 0
С C++14 работает захват с инициализатором — [p = std::move(ptr)]. Это единственный способ перенести владение unique_ptr в замыкание без копии. С C++20 в списке захвата разрешено разворачивать пакеты параметров и захватывать this явно через [*this] (захват копии объекта, а не указателя) — полезно, когда лямбда переживает объект.
Обобщённые лямбды
С C++14 параметр можно объявить как auto — получается шаблонный operator(). С C++20 можно писать явные шаблонные параметры: []<class T>(T x){}. Удобно, но помните: каждая инстанциация — отдельное тело, и если такая лямбда попадает в std::function<void(int)>, инстанцируется ровно версия под int.
Что ревьюить в лямбдах
- Захват
[&]у колбэка, который сохраняется. Замечание: «лямбда переживёт локальные переменные — захват по ссылке даёт use-after-free, замени на захват по значению или перемещение владения». [=]сthisвнутри метода. До C++20[=]захватываетthisпо указателю, а не члены по значению. Замечание: «здесь захватывается указатель this, объект должен пережить лямбду — так задумано?».std::functionв горячем цикле. Замечание: «этот колбэк вызывается на каждом событии, стирание типа мешает инлайну — рассмотри шаблонный параметр».- Крупное замыкание в
std::function. Замечание: «захват двух строк выбивает нас из SBO, каждое создание — аллокация; захвати указатель».
Как это померить
Не гадайте, попадаете ли в буфер и инлайнится ли вызов — проверьте инструментами.
- perf на Linux:
perf recordс последующимperf reportпокажет, сколько времени висит вstd::function::operator()и в аллокаторе. - godbolt.org: вставьте функцию с шаблонным колбэком и с
std::function, сравните ассемблер. У шаблонной версии тело лямбды окажется внутри цикла, уstd::function—callчерез таблицу. - AddressSanitizer (
-fsanitize=address) — против висячих ссылок при захвате. - heaptrack или
valgrind --tool=massif— увидеть, растут ли аллокации от созданияstd::function.
Что делать дальше
Начните с профиля: без цифры «сколько именно в аллокаторе» переписывание — угадайка. Если тип колбэка известен на месте — уберите std::function, дайте шаблонный параметр и убедитесь по godbolt, что вызов инлайнится. Если контейнер разнотипных колбэков нужен — держите замыкания в пределах small-object-буфера вашей стандартной библиотеки. На C++23 присмотритесь к std::move_only_function, если колбэки захватывают unique_ptr. Инвалидация подписок и время жизни шины событий — отдельная тема, здесь мы разбирали только сам колбэк.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: cppreference — Lambda expressions, cppreference — std::function
Частые вопросы
Лямбда всегда медленнее обычной функции?
У меня старый компилятор, C++11 — что не сработает?
Как понять, попадает ли моё замыкание в small-object-буфер std::function?
Переписал на шаблонный колбэк, а вызов всё равно не инлайнится — почему?
Грозит ли захват this висячим указателем?
mutable меняет исходную переменную снаружи?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.