Лямбда-выражения в C++: когда они действительно ускоряют код?

Онлайн-тренажер Python для начинающих

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

Начать курс

Лямбда-выражения в C++: когда они действительно ускоряют код?

Привет, коллега! Если ты когда-нибудь заглядывал на Stack Overflow, то наверняка видел вопрос, который собрал тысячи просмотров: "What is a lambda expression, and when should I use one?". И это не случайно. Лямбды в C++ — тема, которая одновременно и упрощает жизнь, и порождает массу споров об оптимизации и производительности.

Сегодня мы не просто дадим сухое определение. Мы разберемся, как лямбда-выражения работают под капотом, когда их использование делает код быстрее, а когда — наоборот, создает скрытые проблемы. Поехали!

Что такое лямбда-выражение? Взгляд изнутри

Формально, лямбда-выражение — это анонимная функция, которую можно определить прямо в месте вызова. Но если копнуть глубже, компилятор превращает каждую лямбду в уникальный функциональный объект (функтор). Вот пример:

// Лямбда
auto add = [](int a, int b) { return a + b; };

// То, во что это превращается (упрощенно)
class __lambda_1 {
public:
    auto operator()(int a, int b) const { return a + b; }
};
__lambda_1 add;

Ключевой момент: каждая лямбда имеет свой уникальный тип. Это значит, что компилятор может агрессивно инлайнить вызовы — в отличие от указателей на функции или std::function.

Производительность: где лямбды выигрывают?

Давай сразу к делу. Вот три сценария, где лямбды дают ощутимый прирост производительности:

1. Инлайнинг и отсутствие косвенных вызовов

Когда ты передаешь лямбду в шаблонную функцию (например, std::sort), компилятор знает точный тип. Это позволяет ему встроить код лямбды прямо в вызывающую функцию. Сравни:

// Вариант 1: указатель на функцию (медленнее)
bool compare(int a, int b) { return a < b; }
std::sort(vec.begin(), vec.end(), compare);

// Вариант 2: лямбда (быстрее)
std::sort(vec.begin(), vec.end(), [](int a, int b) { return a < b; });

В первом случае — косвенный вызов через указатель. Во втором — прямой инлайн. На больших объемах данных (миллионы элементов) разница может достигать 20-30% по времени.

2. Захват переменных без лишних копий

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

std::vector<std::string> data;
std::string prefix = "prefix_";

// Плохо: копируем тяжелую строку
std::for_each(data.begin(), data.end(), 
    [=](std::string& s) { s = prefix + s; });

// Хорошо: захват по ссылке
std::for_each(data.begin(), data.end(), 
    [&](std::string& s) { s = prefix + s; });

Захват по значению ([=]) создает копию prefix внутри функтора. Для std::string это может быть дорого. Захват по ссылке ([&]) — легковесен.

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

Современные алгоритмы STL (C++17/20) активно используют лямбды для параллельных вычислений. Например, std::transform с политикой выполнения std::par:

std::vector<int> input(1000000);
std::vector<int> output(input.size());

// Параллельная обработка
std::transform(std::execution::par, 
    input.begin(), input.end(), output.begin(),
    [](int x) { return x * x + 2 * x + 1; });

Лямбда здесь — идеальный кандидат для распараллеливания, так как у нее нет побочных эффектов (чистая функция). Компилятор и библиотека могут безопасно запустить ее на нескольких потоках.

Когда лямбды могут навредить производительности?

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

1. Тяжелый захват по значению

Если ты захватываешь большой объект по значению — каждый вызов лямбды будет работать с копией. Особенно опасно в циклах:

std::vector<std::array<int, 1000>> big_data;
auto heavy_obj = getHeavyObject(); // 1 МБ данных

// ОЧЕНЬ плохо: копируем 1 МБ при каждом вызове
for (auto& item : big_data) {
    auto lambda = [heavy_obj](auto& x) { /* ... */ };
    lambda(item);
}

Решение: захватывать по ссылке или использовать std::move для перемещения.

2. Стирание типа через std::function

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

// Быстро (тип известен)
auto fast_lambda = [](int x) { return x * 2; };

// Медленно (стирание типа)
std::function<int(int)> slow_func = [](int x) { return x * 2; };

std::function использует механизм виртуальных вызовов (type erasure). На практике это в 10-20 раз медленнее, чем прямой вызов лямбды. Используй std::function только когда нужна полиморфная обертка (например, для callback'ов).

3. Рекурсивные лямбды

Лямбды не могут вызывать сами себя напрямую (до C++23). Обходные пути через std::function или Y-комбинаторы часто работают медленнее обычной рекурсивной функции:

// Медленно и некрасиво
std::function<int(int)> fib = [&fib](int n) {
    return n < 2 ? n : fib(n-1) + fib(n-2);
};

// Быстрее просто написать функцию
int fib_regular(int n) {
    return n < 2 ? n : fib_regular(n-1) + fib_regular(n-2);
}

Практические советы по оптимизации

Теперь, когда мы знаем теорию, давай перейдем к конкретным рецептам:

  • Используй конкретный захват: вместо [=] пиши [x, &y]. Это помогает компилятору лучше оптимизировать.
  • Избегай std::function в горячих циклах: если може

Блоги

Рекомендаций по книгам