1. Що таке лямбда і як вона влаштована
Коли ви вперше бачите лямбду, часто виникає відчуття: «А це точно C++? Може, я випадково відкрив JavaScript?». Насправді ідея проста: інколи вам потрібне невелике правило на 1–3 рядки, яке використовується рівно в одному місці. Створювати заради нього окрему функцію — це як заводити окремий холодильник заради одного пельменя: можна, але дивно.
Лямбда дозволяє створити обʼєкт, який можна викликати, прямо у виразі. Тобто ви не «оголошуєте функцію десь вище», а створюєте обʼєкт, що поводиться як функція. Це дуже допомагає, коли правило має бути поруч із місцем використання: сортуємо «за пріоритетом, а якщо однакові — за назвою», фільтруємо «усі виконані», перевіряємо «чи є хоч одне термінове завдання».
Лямбда — це вираз, який створює значення
Важливо одразу сформувати правильне уявлення. Лямбда — це не «магічний синтаксис оголошення функції». Лямбда — це вираз, як 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["(параметри)"]
B --> C["-> тип_результату (необовʼязково)"]
C --> D["{ тіло }"]
Мінімальна лямбда: [] { ... }
Почати найкраще з найпростішого випадку: лямбда без параметрів і без значення, яке повертається. Це буквально «мініфункція на місці». Такий приклад здається іграшковим, але він чудово тренує читання синтаксису й розуміння схеми «створив — потім викликав».
#include <iostream>
int main() {
auto ping = [] {
std::cout << "ping\n"; // ping
};
ping();
ping();
}
Тут корисно помітити дві речі. По-перше, ping — це звичайна змінна, її можна передавати й зберігати (про те, як саме це робити, детально буде в окремій лекції). По-друге, лямбда нічого не робить «сама собою»: поки ви не написали ping();, вона не виконується.
2. Параметри та значення, яке повертається
Параметри лямбди: як у функції, тільки поруч
Тепер додамо параметри. На цьому етапі лямбда майже не відрізняється від звичайної функції: у круглих дужках ви перелічуєте параметри з типами. Із погляду синтаксису це зручно: ви вже вмієте писати функції, отже половина справи вже зроблена.
Уявімо наш навчальний консольний проєкт — нехай це буде невеликий менеджер завдань 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). У таких ситуаціях простіше й чесніше зафіксувати один тип результату й узгодити з ним усі гілки.
3. «Створити й одразу викликати» та приклад із 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.
4. Типові помилки в синтаксисі лямбди
Помилка № 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: плутанина «квадратні дужки — це масив?»
Дуже поширена психологічна пастка: [] нагадує індексацію. У лямбдах це не індексація і не масив, а список захоплення. Сьогодні він порожній, тому [] виглядає особливо дивно. Просто запамʼятайте: у лямбдах квадратні дужки стосуються зовнішніх змінних, а не доступу за індексом. Детально й на прикладах ми розберемо це в наступній лекції.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ