1. Вступ
Коли ви вперше бачите [] у лямбді, легко подумати: «Ну, квадратні дужки — це просто декоративна деталь синтаксису». Насправді це дуже практична частина. Лямбда за замовчуванням «бачить» лише свої параметри й те, що ви оголосили всередині її тіла. Усе зовні — ніби в іншому всесвіті, а механізм захоплення якраз і будує між ними міст.
Уявіть, що у вас є змінна minPriority, яку ввів користувач, і ви хочете перевірити умову: «пріоритет не нижчий за minPriority». Без захоплення вам довелося б або передавати minPriority параметром у кожну перевірку, або виносити це в глобальну змінну. А глобальні змінні — це як залишити відкритий чат на проєкторі під час дзвінка: рано чи пізно стане ніяково.
Захоплення розвʼязує цю проблему: ми можемо «вбудувати» значення minPriority усередину лямбди.
Трохи формалізму — але людською мовою: capture list (список захоплення) — це частина запису в [...], яка визначає, які зовнішні змінні лямбда використовуватиме і як саме.
Невеличка схема на рівні ідей:
flowchart LR
A["Зовнішня змінна minPriority"] -->|захоплення| B["Лямбда (callable-обʼєкт)"]
B --> C["Виклик: pred(task)"]
C --> D["Результат: true/false"]
2. Контекст: TaskBoard для прикладів
Перш ніж говорити про захоплення, корисно зафіксувати «світ», у якому ми працюємо. Нам потрібна структура задачі й невеликий набір даних, щоб приклади були не абстрактними «x і y», а ближчими до реального коду. Далі в лекції ми показуватимемо невеликі фрагменти, які можна вставляти в один main.cpp.
#include <iostream>
#include <string>
#include <vector>
struct Task {
std::string title;
int priority = 0; // 1..5, де 5 - "горить"
bool done = false;
};
Створімо тестові задачі:
int main() {
std::vector<Task> tasks = {
{"Buy milk", 2, false},
{"Fix bug #123", 5, false},
{"Read C++ book", 3, true}
};
std::cout << "Tasks: " << tasks.size() << '\n'; // Tasks: 3
}
Поки що це проста заготовка. У цій лекції ми не будемо будувати повноцінний CLI, меню та парсинг команд, щоб не відволікатися. Наша мета — навчитися створювати невеликі функції-правила (лямбди) із «вбудованими налаштуваннями» через захоплення.
3. Захоплення за значенням: [x] і режим [=]
Захоплення за значенням зазвичай найлегше зрозуміти. Ідея така: «Візьми змінну й поклади її копію всередину лямбди». Тобто лямбда стає самодостатньою: навіть якщо зовнішня змінна потім зміниться, лямбда памʼятатиме старе значення — те, яке було на момент її створення. Це схоже на фотографію: ви можете потім змінити зачіску, але фото залишиться тим самим.
Точкове захоплення [x]
Припустімо, користувач задав мінімальний пріоритет:
#include <iostream>
#include <string>
#include <vector>
int main() {
int minPriority = 3;
auto isImportant = [minPriority](int p) {
return p >= minPriority;
};
std::cout << isImportant(2) << '\n'; // 0
std::cout << isImportant(5) << '\n'; // 1
}
Тут [minPriority] означає: «скопіюй minPriority усередину лямбди». Відтоді лямбда зберігає власну внутрішню копію.
Важливий наслідок: якщо ви зміните minPriority після створення лямбди, вона не зміниться, тому що зберігає копію.
#include <iostream>
int main() {
int minPriority = 3;
auto check = [minPriority](int p) { return p >= minPriority; };
minPriority = 5; // змінюємо зовнішню змінну
std::cout << check(4) << '\n'; // 1 (поріг усе ще 3)
}
Захоплення «всього за значенням»: [=]
Іноді лямбда використовує багато зовнішніх змінних, і виписувати кожну вручну незручно. Тоді можна ввімкнути «режим за замовчуванням»: [=] — захопи все використане за значенням.
#include <iostream>
int main() {
int minPriority = 3;
bool showDone = false;
auto allow = [=](int p, bool done) {
return (p >= minPriority) && (showDone || !done);
};
std::cout << allow(4, false) << '\n'; // 1
std::cout << allow(4, true) << '\n'; // 0
}
Це зручно, але саме тут постає перша дилема проєктування. [=] може «захопити зайве» — те, чого ви потім не помітите під час читання коду. Через це багато команд віддають перевагу явним захопленням [minPriority, showDone], навіть якщо це на кілька символів довше: зате намір видно одразу.
Є іще один історичний нюанс: навколо «неявних захоплень» та їхніх правил у стандарті C++ було чимало обговорень і уточнень. Зокрема, були окремі зміни, спрямовані на спрощення й прояснення поведінки. Це видно хоча б зі звітів про прийняті зміни, де прямо згадується спрощення implicit lambda capture.
4. Захоплення за посиланням: [&x] і режим [&]
Тепер — більш «небезпечна, але потужна» версія. Захоплення за посиланням означає: «не копіюй значення, а дай лямбді доступ до оригінальної змінної». Тобто лямбда звертається до тієї самої памʼяті, до того самого обʼєкта. Якщо змінна змінюється зовні — лямбда побачить ці зміни. Якщо лямбда змінює змінну — це теж буде видно зовні.
Звучить чудово. І справді чудово… доки ви не забули про час життя змінної. Але до типових граблів ми дійдемо трохи пізніше.
Точкове захоплення [&x]
Порахуємо, скільки задач є «важливими» (пріоритет >= 4). Хочемо накопичувати значення в count зовні.
#include <iostream>
#include <vector>
#include <string>
struct Task { std::string title; int priority; bool done; };
int main() {
std::vector<Task> tasks = {{"A", 2, false}, {"B", 5, false}, {"C", 4, true}};
int count = 0;
auto consider = [&count](const Task& t) {
if (t.priority >= 4) ++count;
};
for (const auto& t : tasks) consider(t);
std::cout << count << '\n'; // 2
}
Якби ми захопили count за значенням ([count]), збільшення відбувалися б усередині копії, а зовнішнє значення не змінилося б.
Захоплення «всього за посиланням»: [&]
Аналогічно до [=], але для посилань: [&] означає «усе, що використовую ззовні, візьми за посиланням».
#include <iostream>
int main() {
int a = 10;
int b = 20;
auto addToA = [&](int x) { a += x + b; };
addToA(5);
std::cout << a << '\n'; // 35
}
Це зручно, але з погляду читабельності може бути навіть гірше, ніж [=]. Коли ви бачите [&], то розумієте: «ця лямбда потенційно чіпає все довкола». Іноді це справді потрібно, наприклад у невеликому локальному фрагменті на 2 рядки, але частіше краще явно захоплювати лише те, що реально використовується: [&a, b], [&count] тощо.
До речі, у стандартних обговореннях лямбд трапляються навіть окремі формулювання й питання, повʼязані з тим, як захоплення взаємодіють з іменами та приховуванням (hiding). Це добрий натяк на те, що тема не така «іграшкова», як може здатися під час першого знайомства.
Як обирати: значення чи посилання
Коли ви вивчаєте захоплення, спершу хочеться запамʼятати лише синтаксис. Але справжня сила — у виборі моделі володіння даними: копія чи посилання. Нижче — шпаргалка, яку корисно тримати в голові, доки не зʼявиться інтуїція.
| Що пишемо | Що це означає | Коли добре | Чим ризикуємо |
|---|---|---|---|
|
копія x усередині лямбди | x — налаштування або поріг; не хочемо, щоб зовнішні зміни впливали | можна «випадково» копіювати важкі обʼєкти (рядки, вектори) |
|
посилання на x усередині лямбди | хочемо змінювати x або бачити його зміни | x має жити довше, ніж лямбда |
|
усе використане — копіями | коротка лямбда і мало зовнішніх змінних | неявно захопили зайве і не помітили |
|
усе використане — посиланнями | дуже локальна лямбда в невеликому блоці | ризик, повʼязаний із часом життя, + «чіпає все довкола» |
І маленьке практичне правило для початківців: якщо змінна — це «налаштування» (поріг, ліміт, режим), захоплення за значенням часто безпечніше й зрозуміліше. Якщо змінна — це «лічильник», «результат» або «акумулятор», частіше потрібне захоплення за посиланням.
5. Змішане захоплення: точніше, ніж [=] і [&]
Коли ви вже трохи звикли, зʼявляється наступний логічний крок: «Хочу частину змінних захоплювати копією, а частину — посиланням». Це чудовий підхід, бо він робить намір чітким: що тут є параметром, а що — змінним станом.
Синтаксис виглядає так: ви обираєте базовий режим захоплення (capture-default) і додаєте винятки. Наприклад, [=, &count] означає: «усе за значенням, але count — за посиланням».
Зробімо приклад на TaskBoard: у нас є поріг minPriority як налаштування і є лічильник matched, який ми хочемо змінювати.
#include <iostream>
#include <string>
#include <vector>
struct Task { std::string title; int priority; bool done; };
int main() {
std::vector<Task> tasks = {{"A", 2, false}, {"B", 5, false}, {"C", 4, true}};
int minPriority = 4;
int matched = 0;
auto check = [=, &matched](const Task& t) {
if (t.priority >= minPriority) ++matched;
};
for (const auto& t : tasks) check(t);
std::cout << matched << '\n'; // 2
}
Це добре тим, що minPriority поводиться як «заморожене налаштування», а matched — як змінюваний результат. Такий код зазвичай простіше читати, ніж [&] (занадто широкий підхід), і надійніше, ніж [=] (лічильник тоді не змінювався б зовні).
6. mutable: стан усередині лямбди
Коли ви захоплюєте змінну за значенням, лямбда зберігає копію. І за замовчуванням C++ вважає цю копію незмінною всередині operator() лямбди. Це зроблено не через «шкідливість» мови, а для того, щоб лямбда за замовчуванням поводилася як «чиста функція»: отримала аргументи, повернула результат і нічого всередині не змінила.
Але інколи вам потрібно, щоб лямбда мала внутрішній стан. Наприклад, маленький генератор «наступного номера». Для цього і існує mutable: він дозволяє змінювати копії змінних, захоплених за значенням.
Зробімо мінігенератор ID для задач. Важливо: зовнішній nextId не змінюється — змінюється лише копія всередині лямбди.
#include <iostream>
int main() {
int nextId = 0;
auto gen = [nextId]() mutable {
++nextId;
return nextId;
};
std::cout << gen() << ' ' << gen() << '\n'; // 1 2
std::cout << nextId << '\n'; // 0
}
Це типовий момент, коли новачки спочатку дивуються, потім радіють, а потім починають писати «суперлямбди зі станом на 200 рядків». Так робити не варто. Але маленький mutable-генератор на 3–5 рядків — цілком нормальний інструмент.
7. Час життя при захопленні за посиланням
Зараз — головна причина, чому захоплення за посиланням стало джерелом легендарних багів і мемів на кшталт «чому воно працює тільки по вівторках».
Якщо ви захопили змінну за посиланням, то лямбда не володіє цією змінною. Вона лише зберігає посилання на неї. Якщо змінна зникла, а лямбду викликали пізніше, лямбда звертається до «привида». У C++ це вже не «помилка компіляції», а неприємна категорія «поведінка не визначена», тобто програма може робити що завгодно.
Подивімося на приклад, який компілюється, але небезпечний: ми створюємо лямбду всередині блоку, у якому живе x, і зберігаємо її, щоб викликати пізніше.
#include <iostream>
int main() {
auto f = [] { return 0; };
{
int x = 10;
f = [&x] { return x; }; // x захоплено за посиланням
} // x знищено тут
std::cout << f() << '\n'; // небезпечно: x уже не існує
}
Щоб це «побачити очима», уявімо час життя:
timeline
title Час життя змінної x і використання лямбди
section Блок { ... }
x існує : a1, 1
лямбда захопила &x : a2, 1
блок закінчився, x знищено : a3, 1
section Після блоку
викликаємо лямбду : b1, 1
лямбда читає x (але x уже немає) : b2, 1
Правильний висновок на поточному етапі курсу такий: захоплення за посиланням безпечне, якщо ви викликаєте лямбду «тут же», доки змінні ще живі. Якщо ж ви збираєтеся десь зберігати лямбду й використовувати її пізніше, захоплення за значенням зазвичай безпечніше. Або потрібен інший дизайн, але його ми обговорюватимемо пізніше в темах про зберігання callable-обʼєктів.
8. Читабельність: приховування імен і захоплення за замовчуванням
На цьому етапі часто зʼявляється проблема не в тому, що програма не працює, а в тому, що її неможливо читати без телепатії. Лямбди дуже компактні, а режими захоплення за замовчуванням ([&], [=]) роблять їх ще компактнішими. І тут легко написати код, у якому незрозуміло, звідки взялася змінна і хто саме її змінює.
Є навіть окремі обговорення й питання про «hiding by lambda captures and parameters» — тобто ситуації, коли імена параметрів або захоплень приховують інші імена й плутають читача. Це не просто філософія. Це питання того, наскільки передбачувано буде підтримувати такий код.
Найпростіший приклад із життя: ви захопили minPriority, а всередині лямбди зробили параметр з тим самим іменем. У результаті виникає плутанина.
#include <iostream>
int main() {
int minPriority = 3;
auto f = [minPriority](int minPriority) { // параметр приховав захоплення
return minPriority; // це вже параметр, не захоплення
};
std::cout << f(10) << '\n'; // 10
}
Компілятор не зобовʼязаний «рятувати вас від невдалого дизайну». Тому правило просте: не давайте параметрам і локальним змінним імена, які збігаються з іменами захоплень. Трохи більше літер у назві — і значно менше когнітивного болю.
9. Типові помилки під час захоплення в лямбдах
Помилка № 1: забули захоплення і здивувалися помилці компіляції.
Класична ситуація: ви пишете лямбду, усередині звертаєтеся до змінної minPriority, а компілятор лається, що вона «не захоплена». Це не прискіпливість: за правилами лямбда ізольована, доки ви не побудували міст через [...]. Виправлення просте: явно додайте [minPriority] або [&minPriority] і свідомо оберіть модель — копію чи посилання.
Помилка № 2: захопили за значенням, але очікували, що зовнішня змінна зміниться.
Новачки часто пишуть [count](){ ++count; } і чекають, що зовнішній count збільшиться. Але збільшиться лише копія, а зовнішній лічильник залишиться тим самим. Якщо мета — змінити зовнішній стан, потрібне захоплення за посиланням: [&count]. Якщо ж мета — щоб лямбда рахувала щось усередині себе, тоді потрібен mutable. Але важливо памʼятати: це буде «внутрішній стан лямбди», а не зовнішня змінна.
Помилка № 3: захопили за посиланням і отримали проблему з часом життя.
Найнебезпечніша категорія. Ви захопили &x, а потім лямбда пережила x — наприклад, ви зберегли лямбду у змінну й викликали її пізніше або повернули з функції. У результаті отримуєте звернення до даних, яких уже не існує. На практиці це часто проявляється як «інколи працює», а це гірше за будь-яку чесну помилку компіляції. Поки ми не вивчаємо просунуті техніки проєктування callable-обʼєктів, тримайтеся простого правила: захоплення за посиланням — для негайного використання поруч; якщо лямбду потрібно використати пізніше, частіше беріть захоплення за значенням.
Помилка № 4: надмірне використання [&] і несподівані побічні ефекти.
[&] виглядає як «суперзручно», але читач, включно з вами через тиждень, уже не бачить, які змінні реально беруть участь. А ще лямбда отримує можливість змінювати майже все довкола, і один зайвий рядок перетворює локальну перевірку на джерело побічних ефектів. Зазвичай простіше й безпечніше написати точково: [&matched, minPriority] або [&total].
Помилка № 5: надмірне використання [=] і випадкові важкі копії.
[=] може непомітно скопіювати великі обʼєкти, наприклад std::string чи std::vector. Іноді це нормально, але іноді ви раптом починаєте копіювати половину застосунку заради перевірки на 1 рядок. На поточному рівні достатньо памʼятати: якщо ви захоплюєте щось велике й не хочете копію, значить, вам потрібне посилання ([&big]). Але тоді одразу постає вимога щодо часу життя.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ