JavaRush /Курси /C++ SELF /Захоплення змінних: [&], [=], [x], [&x] і типові ...

Захоплення змінних: [&], [=], [x], [&x] і типові помилки

C++ SELF
Рівень 22 , Лекція 1
Відкрита

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 усередині лямбди хочемо змінювати 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-генератор на 35 рядків — цілком нормальний інструмент.

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]). Але тоді одразу постає вимога щодо часу життя.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ