1. Что такое лямбда и как она устроена
Когда новичок впервые видит лямбду, часто появляется ощущение: «А это точно C++? Может, я случайно открыл JavaScript?». На самом деле идея простая: иногда вам нужно маленькое правило на 1–3 строки, которое используется ровно в одном месте. Делать ради него отдельную функцию — как заводить отдельный холодильник ради одного пельменя: можно, но странно.
Лямбда позволяет создать вызываемое значение прямо в выражении. То есть вы не «объявляете функцию где-то выше», а создаёте объект, который можно вызвать как функцию. Это сильно помогает, когда правило должно быть рядом с использованием: сортируем «по приоритету, а если равны — по названию», фильтруем «все выполненные», проверяем «есть ли хоть одна срочная задача».
Лямбда — выражение, которое создаёт значение
Важно начать с правильной mental model (модели в голове). Лямбда — это не «магический синтаксис объявления функции». Лямбда — это выражение, как 1 + 2 или std::string{"hi"}. Оно создаёт значение (объект), которое можно сохранить в переменную, передать куда-то или сразу вызвать.
Смотрите на лямбду как на «коробку с кнопкой»: вы её создали — она лежит. Нажали кнопку — она отработала.
#include <iostream>
int main() {
auto hello = [] {
std::cout << "Hello!\n"; // Hello!
};
hello(); // вызов “коробки с кнопкой”
}
Обратите внимание на важную мелочь: после auto hello = [] { ... }; стоит ;, потому что это обычное присваивание переменной. Это одна из самых частых «мелких» ошибок у начинающих.
Анатомия записи [] (params) -> R { body }
Чтобы лямбды перестали выглядеть как «набор скобок, который испугал компилятор», полезно разобрать их по частям. Полная форма лямбды выглядит так: [] (параметры) -> тип { тело }. Некоторые части можно опускать, и именно поэтому сначала кажется, что «вариантов слишком много».
Давайте договоримся: в этой лекции мы сознательно не используем захваты (то есть в [] у нас будет пусто). Захваты — следующая лекция, и там будет много интересных граблей.
| Часть | Пример | Смысл |
|---|---|---|
| Список захвата | |
Доступ к внешним переменным (сегодня оставляем пустым) |
| Параметры | |
Как у обычной функции |
| Явный тип результата | |
Опционально, когда нужно зафиксировать тип |
| Тело | |
Команды, как в обычной функции |
Небольшая «картинка-подсказка»:
flowchart LR
A["[]"] --> B["(params)"]
B --> C["-> return_type (optional)"]
C --> D["{ body }"]
Минимальная лямбда: [] { ... }
Начать лучше с самого простого случая: лямбда без параметров и без возвращаемого значения. Это буквально «мини-функция на месте». Такой пример кажется игрушечным, но он отлично тренирует чтение синтаксиса и понимание «создал — потом вызвал».
#include <iostream>
int main() {
auto ping = [] {
std::cout << "ping\n"; // ping
};
ping();
ping();
}
Здесь полезно заметить две вещи. Во-первых, ping — обычная переменная, её можно передавать и хранить (про «как хранить» подробно будет отдельная лекция). Во-вторых, лямбда ничего не делает «сама по себе»: пока вы не написали ping();, она не выполняется.
3. Параметры и возвращаемое значение
Параметры лямбды: как у функции, только рядом
Теперь добавим параметры. На этом этапе лямбда почти не отличается от обычной функции: в круглых скобках перечисляются параметры с типами. С точки зрения синтаксиса это удобно: вы уже умеете писать функции, значит половина навыка уже есть.
Представим наш учебный консольный проект (пусть это будет маленький менеджер задач TaskBox). У задачи есть приоритет, а мы хотим быстро вычислять «штраф» за низкий приоритет.
#include <iostream>
int main() {
auto penalty = [](int priority) {
return (10 - priority) * 5;
};
std::cout << penalty(8) << '\n'; // 10
std::cout << penalty(2) << '\n'; // 40
}
Параметров может быть несколько, как в обычной функции:
#include <iostream>
int main() {
auto sum = [](int a, int b) {
return a + b;
};
std::cout << sum(2, 3) << '\n'; // 5
}
Сюда же относится и использование ссылок/const, которые вы уже знаете. Например, если вы передаёте строку, обычно дешевле передать const std::string&, чтобы не копировать:
#include <iostream>
#include <string>
int main() {
auto starts_with_hash = [](const std::string& s) {
return !s.empty() && s[0] == '#';
};
std::cout << starts_with_hash("#todo") << '\n'; // 1
std::cout << starts_with_hash("todo") << '\n'; // 0
}
Возврат значения и вывод типа результата
В лямбде return работает так же, как в функции. Если лямбда что-то возвращает, компилятор обычно может вывести тип результата сам, глядя на выражение после return. Это удобно и читаемо, пока вы не пытаетесь сделать «умно и хитро» (а начинающие иногда пытаются — просто из любопытства).
Самый частый сценарий: вернуть число, строку или bool:
#include <iostream>
int main() {
auto is_even = [](int x) {
return x % 2 == 0;
};
std::cout << is_even(10) << '\n'; // 1
std::cout << is_even(7) << '\n'; // 0
}
Если лямбда «ничего не возвращает» (то есть возвращает void), вы просто не пишете return со значением:
#include <iostream>
int main() {
auto print_line = [](int x) {
std::cout << "value = " << x << '\n'; // value = 42
};
print_line(42);
}
Есть важное правило, которое лучше запомнить сейчас: если в теле лямбды есть return, то все return должны возвращать один и тот же тип по смыслу. Компилятор не любит, когда в одной ветке вы возвращаете int, а в другой — double или std::string. Иногда это можно исправить явным типом результата — и это как раз следующая тема.
Явный тип результата: -> T
Иногда лямбда «по умолчанию» возвращает не то, что вы хотели. Самый классический пример — деление целых чисел. Если вы делите int на int, получится int, и дробная часть исчезнет. Это не «ошибка компилятора», это ваша арифметика.
Чтобы явно сказать: «я хочу double», можно написать -> double. Это читается как «стрелочка-тип результата».
#include <iostream>
int main() {
auto div_real = [](int a, int b) -> double {
return static_cast<double>(a) / b;
};
std::cout << div_real(5, 2) << '\n'; // 2.5
}
Почему здесь полезно сразу два приёма (-> double и static_cast<double>)?
-> double — это контракт «результат лямбды — вещественное число».
static_cast<double>(a) — это «подготовка» вычисления, чтобы деление тоже было вещественным, а не целочисленным.
Есть и другой частый сценарий: в разных ветках вы возвращаете значения, которые «похожи», но на самом деле разных типов. Например, 0 (это int) и 0.0 (это double). В таких ситуациях проще и честнее зафиксировать один тип результата и под него подогнать все ветки.
4. «Создать и сразу вызвать» и пример из TaskBox
«Создать и сразу вызвать»: лямбда-однодневка
Иногда вам нужно вычисление «вот прямо здесь», но вы не хотите создавать отдельную функцию и даже отдельную переменную. В C++ можно создать лямбду и сразу её вызвать: после фигурных скобок просто пишете (аргументы).
Этот приём похож на «одноразовый стаканчик»: выпил и выбросил. Главное — не злоупотреблять, чтобы код не стал ребусом.
#include <iostream>
int main() {
int x = 6;
int y = 9;
int mx = [](int a, int b) {
return (a > b) ? a : b;
}(x, y);
std::cout << mx << '\n'; // 9
}
Обратите внимание на важный момент: мы не использовали захваты. Мы передали x и y как параметры, то есть лямбда осталась «чистой» и независимой от внешних переменных. Это хорошая привычка: пока можно — передавайте через параметры. Захваты мощные, но их легко применить так, что потом будет больно (и да, это будет в следующей лекции).
Пример из TaskBox: правило рядом с местом применения
Чтобы лямбды не казались «математикой ради математики», покажу маленький кусок, который логически вписывается в менеджер задач. Допустим, у нас есть модель задачи и мы хотим определить «важнее ли задача A, чем задача B». Сегодня мы не делаем сортировку (это будет отдельная лекция дня), но мы можем написать правило сравнения и протестировать его вызовом.
#include <iostream>
#include <string>
struct Task {
std::string title;
int priority{};
};
int main() {
auto more_important = [](const Task& a, const Task& b) {
return a.priority > b.priority;
};
Task t1{"Buy milk", 2};
Task t2{"Pay rent", 9};
std::cout << more_important(t2, t1) << '\n'; // 1
}
Ключевая идея: лямбда живёт рядом с тем кодом, где она нужна, и читатель сразу видит «по какому правилу сравниваем». А ещё она не требует придумывать имя глобальной функции уровня isTaskMoreImportant_v2_final_real.
5. Типичные ошибки в синтаксисе лямбды
Ошибка №1: забыли точку с запятой после присваивания лямбды в переменную.
Конструкция auto f = [] { ... } — это обычное объявление переменной с инициализацией. Поэтому ; обязателен. Компилятор обычно ругается чем-то вроде «expected ‘;’», и это один из тех случаев, когда он прав, а вы просто устали.
Ошибка №2: ожидание, что лямбда выполнится при создании.
Лямбда — это не «магия автозапуска». Она создаёт объект. Выполнение начинается только при вызове f() или при «создать и сразу вызвать» [] { ... }(). Если вы создали auto log = [] { ... }; и ничего не произошло — так и задумано.
Ошибка №3: случайно получили целочисленное деление.
Запись return a / b; при int a, int b возвращает int. Если вы хотели 2.5, а получили 2, это не «плавающая запятая сломалась», это вы не сказали компилятору, что хотите вещественную арифметику. Лечится static_cast<double>(a) и при необходимости явным -> double.
Ошибка №4: разные типы в разных return внутри одной лямбды.
Если в одной ветке вы возвращаете 0, а в другой 0.5, или в одной ветке строку, а в другой число — компилятор не обязан угадывать, что вы «имели в виду». В простых случаях помогает привести возвращаемые значения к одному типу или зафиксировать его через -> T, но чаще правильнее сделать логику проще и очевиднее.
Ошибка №5: путаница «квадратные скобки — это массив?»
Очень частая психологическая ловушка: [] напоминает индексацию. В лямбдах это не индексатор и не массив, а список захвата. Сегодня он пустой, поэтому [] выглядит особенно странно. Просто запомните: в лямбдах квадратные скобки — это «про внешние переменные», а не «про доступ по индексу». Подробно и с примерами мы разберём это в следующей лекции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ