C++

Лямбда-захоплення

Вивчіть механізм захоплення лямбда-виразів у C++ — захоплення за значенням та за посиланням, ключове слово mutable, захоплення за замовчуванням, визначення нових змінних у capture-списку та поширені пастки.

Лямбда-захоплення

Обмеження лямбд без захоплення

У попередній статті ми вивчили лямбда-вирази — анонімні функції, що визначаються прямо в місці використання. Але є важлива деталь, яку ми навмисно відклали: лямбди з порожнім [] бачать лише те, що їм передано через параметри. Вони не мають автоматичного доступу до змінних навколишньої функції.

Розглянемо конкретну проблему. Є масив слів і порогове значення довжини — нам потрібно знайти перше слово, довша за порогове:

NoCaptureError.cpp
#include <iostream>

int strLen(const char* str)
{
    int len = 0;
    while (str[len] != '\0')
        ++len;
    return len;
}

bool findFirstLonger(const char** words, int size, const std::function<bool(const char*)>& pred)
{
    for (int i = 0; i < size; ++i)
        if (pred(words[i]))
            return true;
    return false;
}

int main()
{
    const char* words[] = { "hi", "morning", "ok", "sunshine", "bye" };
    int minLength = 5; // ← ця змінна потрібна всередині лямбди

    // ❌ Спроба звернутися до minLength — помилка!
    auto isLong = [](const char* word) // [] — порожній capture, minLength невидима
    {
        return strLen(word) > minLength; // помилка компіляції: minLength not captured
    };

    return 0;
}

Компілятор повідомить про помилку: minLength знаходиться у зовнішній функції main, а лямбда не має до неї доступу. Це принципова відмінність від вкладених блоків коду, де змінні зовнішнього блоку видимі автоматично.

Причина в архітектурі: лямбда компілюється як окремий клас-функтор. Вона є незалежним об'єктом, а не «частиною» main. Щоб лямбда «побачила» зовнішні змінні — їх потрібно явно захопити.

Передумови. Ця стаття є прямим продовженням статті 25 (лямбда-вирази). Розуміння синтаксису [](params) { body }, типів лямбд (auto, std::function) і callback-патерну є обов'язковим.

Захоплення за значенням: [variable]

Найпростіший вид захоплення — за значенням. Лямбда отримує копію зовнішньої змінної прямо в момент свого визначення. Надалі лямбда працює зі своєю власною копією — зміни не впливають на оригінал і навпаки.

CaptureByValue.cpp
#include <iostream>

int strLen(const char* str)
{
    int len = 0;
    while (str[len] != '\0')
        ++len;
    return len;
}

int main()
{
    const char* words[] = { "hi", "morning", "ok", "sunshine", "bye" };
    const int SIZE = 5;
    int minLength = 5;

    // [minLength] — захоплюємо minLength за значенням
    // лямбда отримує КОПІЮ значення 5 прямо зараз
    auto isLong = [minLength](const char* word)
    {
        return strLen(word) > minLength; // використовуємо свою копію
    };

    for (int i = 0; i < SIZE; ++i)
    {
        if (isLong(words[i]))
            std::cout << words[i] << ' ';
    }
    std::cout << '\n';

    // Змінюємо оригінальну змінну — лямбда цього «не відчує»
    minLength = 10;

    std::cout << "Після зміни minLength = 10:\n";
    for (int i = 0; i < SIZE; ++i)
    {
        if (isLong(words[i]))
            std::cout << words[i] << ' ';
    }
    std::cout << '\n';

    return 0;
}
./CaptureByValue
$ ./CaptureByValue
morning sunshine
Після зміни minLength = 10:
morning sunshine

Розбір. Обидва виводи однакові — незважаючи на те, що minLength у main змінилась до 10, лямбда isLong продовжує використовувати свою копію зі значенням 5. Вона «сфотографувала» значення змінної в момент визначення і більше не залежить від оригіналу.

Ключова ідея: при захопленні за значенням лямбда — це незалежний знімок стану на момент визначення.


Захоплення за значенням і const — чому не можна змінювати

За замовчуванням захоплені за значенням змінні є константними всередині лямбди. Спроба їх змінити призведе до помилки компіляції:

MutableError.cpp
#include <iostream>

int main()
{
    int ammo = 10;

    auto shoot = [ammo]()
    {
        --ammo; // ❌ Помилка: 'ammo' is const — захоплена за значенням
        std::cout << "Pew! " << ammo << " shot(s) left.\n";
    };

    shoot();

    return 0;
}

Логіка: захоплена копія є константою, тому що лямбда є функціональним об'єктом і не повинна мати прихований побічний ефект зміни «своїх» змінних (які є копіями, а не оригіналами).

Ключове слово mutable

Якщо вам дійсно потрібно змінювати захоплену за значенням змінну — позначте лямбду ключовим словом mutable. Воно знімає const з усіх захоплених за значенням змінних:

Mutable.cpp
#include <iostream>

int main()
{
    int ammo = 10;

    // mutable — дозволяємо зміну захоплених за значенням змінних
    auto shoot = [ammo]() mutable
    {
        --ammo; // ✅ Тепер можна — але змінюємо КОПІЮ, не оригінал!
        std::cout << "Pew! " << ammo << " shot(s) left.\n";
    };

    shoot(); // Pew! 9 shot(s) left.
    shoot(); // Pew! 8 shot(s) left.
    shoot(); // Pew! 7 shot(s) left.

    // ammo в main() ніяк не змінився!
    std::cout << "Original ammo: " << ammo << '\n'; // 10

    return 0;
}
./Mutable
$ ./Mutable
Pew! 9 shot(s) left.
Pew! 8 shot(s) left.
Pew! 7 shot(s) left.
Original ammo: 10

Розбір по рядках.

Рядок 8. [ammo]() mutablemutable стоїть після списку параметрів і перед тілом лямбди. Це дозволяє зміну захоплених за значенням змінних. Без mutable компілятор видасть помилку.

Рядок 10. --ammo — тут ammo — це копія, що належить лямбді. Лямбда запам'ятовує її стан між викликами. Перший виклик: копія ammo = 10 → 9. Другий виклик: копія ammo = 9 → 8. Третій: 8 → 7.

Рядок 17. ammo в main() залишається 10. Ми весь час змінювали копію, що живе всередині лямбди. Оригінал недоторканний.

mutable — не "вирішення проблеми". Якщо вам потрібно реально змінити зовнішню змінну — використовуйте захоплення за посиланням. mutable лише дозволяє лямбді мати власний мутований стан — копію, що «живе» разом з лямбдою.

Захоплення за посиланням: [&variable]

Щоб лямбда дійсно впливала на зовнішню змінну — захопіть її за посиланням, додавши & перед ім'ям:

CaptureByRef.cpp
#include <iostream>

int main()
{
    int ammo = 10;

    // [&ammo] — захоплюємо ammo за посиланням
    auto shoot = [&ammo]()
    {
        --ammo; // ← змінюємо оригінальну змінну з main()
        std::cout << "Pew! " << ammo << " shot(s) left.\n";
    };

    shoot(); // Pew! 9 shot(s) left.
    shoot(); // Pew! 8 shot(s) left.

    std::cout << "Remaining ammo: " << ammo << '\n'; // 8

    return 0;
}
./CaptureByRef
$ ./CaptureByRef
Pew! 9 shot(s) left.
Pew! 8 shot(s) left.
Remaining ammo: 8

Рядок 8. [&ammo] — замість копії лямбда отримує посилання на оригінальну змінну ammo. Це означає, що --ammo всередині лямбди і --ammo безпосередньо в main() — геть однакові за ефектом.

Зверніть: при захопленні за посиланням не потрібен mutable — посилання за своєю природою не має обмеження const.

Практичний кейс: лічильник порівнянь у сортуванні

Захоплення за посиланням досить часто використовується для «зовнішнього спостереження» за тим, що відбувається всередині алгоритму:

CountComparisons.cpp
#include <iostream>
#include <utility> // для std::swap

void bubbleSort(int* array, int size, const std::function<bool(int, int)>& compare)
{
    for (int pass = 0; pass < size - 1; ++pass)
        for (int i = 0; i < size - pass - 1; ++i)
            if (compare(array[i], array[i + 1]))
                std::swap(array[i], array[i + 1]);
}

int main()
{
    int numbers[] = { 5, 2, 8, 1, 9, 3 };
    const int SIZE = 6;

    int comparisons = 0; // зовнішній лічильник

    // Захоплюємо comparisons за посиланням — щоб рахувати з лямбди
    bubbleSort(numbers, SIZE, [&comparisons](int a, int b)
    {
        ++comparisons; // кожен виклик лямбди — це одне порівняння
        return a > b;  // сортуємо за зростанням
    });

    std::cout << "Sorted: ";
    for (int i = 0; i < SIZE; ++i)
        std::cout << numbers[i] << ' ';
    std::cout << '\n';

    std::cout << "Total comparisons: " << comparisons << '\n';

    return 0;
}
./CountComparisons
$ ./CountComparisons
Sorted: 1 2 3 5 8 9
Total comparisons: 15

Рядки 20–24. Лямбда захоплює comparisons за посиланням і збільшує його на 1 при кожному виклику. bubbleSort нічого не знає про лічильник — він просто викликає compare(a, b). А завдяки &comparisons усі ці виклики «проходять» через оригінальну змінну в main(). Після сортування comparisons містить реальну кількість порівнянь.


Захоплення кількох змінних

Можна захоплювати кілька змінних одночасно, розділяючи їх комами. Кожну змінну можна захоплювати незалежно — одну за значенням, іншу за посиланням:

MultiCapture.cpp
#include <iostream>

int main()
{
    int health = 100;
    int damage = 15;
    int armor = 30;

    // health і armor — за значенням (копії)
    // damage — за посиланням (оригінал)
    auto takeDamage = [health, armor, &damage]() mutable
    {
        int reduced = damage - armor; // застосовуємо зменшення через armor
        if (reduced < 0) reduced = 0;

        health -= reduced; // health — копія, зміна не впливає на оригінал
        damage += 5;       // damage — посилання, оригінал збільшується!

        std::cout << "HP: " << health
                  << ", reduced by " << reduced
                  << ", next damage: " << damage << '\n';
    };

    takeDamage();
    takeDamage();
    takeDamage();

    std::cout << "\nОригінали після викликів:\n";
    std::cout << "health = " << health << " (незмінний — захоплено за значенням)\n";
    std::cout << "damage = " << damage << " (змінився — захоплено за посиланням)\n";
    std::cout << "armor  = " << armor  << " (незмінний — захоплено за значенням)\n";

    return 0;
}
./MultiCapture
$ ./MultiCapture
HP: 70, reduced by 0, next damage: 20
HP: 70, reduced by 0, next damage: 25
HP: 70, reduced by 0, next damage: 30
Оригінали після викликів:
health = 100 (незмінний — захоплено за значенням)
damage = 30 (змінився — захоплено за посиланням)
armor = 30 (незмінний — захоплено за значенням)

Розбір. [health, armor, &damage]health і armor — копії, damage — посилання. Виклики takeDamage зменшують health-копію (оригінал лишається 100), але damage зростає при кожному виклику, бо він захоплено за посиланням — оригінальний damage в main() збільшується.


Захоплення за замовчуванням: [=] і [&]

Якщо лямбда використовує багато зовнішніх змінних, перелічувати кожну вручну незручно. Для цього існує захоплення за замовчуванням:

СинтаксисПризначення
[=]Захопити всі використовувані зовнішні змінні за значенням
[&]Захопити всі використовувані зовнішні змінні за посиланням
DefaultCapture.cpp
#include <iostream>

int main()
{
    int base = 10;
    int multiplier = 3;
    int offset = 7;

    // [=] — всі три змінні захоплюються за значенням автоматично
    auto calculateA = [=]()
    {
        return base * multiplier + offset; // всі три видимі
    };

    // [&] — всі три захоплюються за посиланням автоматично
    auto calculateB = [&]()
    {
        base += 1;       // змінюємо оригінали!
        multiplier += 1;
        return base * multiplier + offset;
    };

    std::cout << "A: " << calculateA() << '\n'; // 10*3+7 = 37
    std::cout << "B: " << calculateB() << '\n'; // 11*4+7 = 51  (base і multiplier змінились)
    std::cout << "base=" << base << ", multiplier=" << multiplier << '\n'; // 11, 4

    return 0;
}
./DefaultCapture
$ ./DefaultCapture
A: 37
B: 51
base=11, multiplier=4

Змішані захоплення

Правило спрощене: захоплення за замовчуванням можна змішувати зі звичайними — але кожна змінна може бути захоплена лише один раз, а захоплення за замовчуванням завжди перше:

MixedCapture.cpp
int health = 100;
int armor  = 50;
int damage = 30;

// [=, &damage] — all by value, except damage — by reference
auto deal = [=, &damage]() mutable { /* ... */ };

// [&, armor] — all by reference, except armor — by value
auto deal2 = [&, armor]() { /* ... */ };

// ❌ Заборонено — дублювання: armor вже захоплено через [=]
// auto bad1 = [=, armor]() {};

// ❌ Заборонено — захоплення за замовчуванням не першим
// auto bad2 = [armor, =]() {};

// ❌ Заборонено — подвійне захоплення armor
// auto bad3 = [armor, &health, &armor]() {};
У сучасному C++ краще уникати [=] та [&] і завжди перераховувати змінні явно. Явне захоплення [minLen, &count] одразу показує читачеві, які саме зовнішні залежності має лямбда. [=] приховує цю інформацію.

Визначення нових змінних у capture-списку

У capture-списку можна не лише захоплювати існуючі змінні, але й оголошувати нові — прямо в дужках:

CaptureInit.cpp
#include <iostream>

int main()
{
    int width = 6;
    int height = 7;

    // Нова змінна area, видима лише всередині лямбди.
    // Вона обчислюється ОДИН РАЗ — при визначенні лямбди.
    auto printArea = [area = width * height]()
    {
        std::cout << "Area: " << area << '\n';
    };

    // Змінюємо width і height — area всередині лямбди не зміниться!
    width = 100;
    height = 200;

    printArea(); // Area: 42 — бо 6*7=42, обчислено при визначенні

    return 0;
}
./CaptureInit
$ ./CaptureInit
Area: 42

Рядок 9. [area = width * height] — це inicialized capture (C++14). Всередині [] ми оголошуємо нова змінна area і ініціалізуємо її виразом width * height. Вираз обчислюється одноразово — в момент визначення лямбди. Надалі width і height можуть змінитися як завгодно — area вже збережена.

Ще один варіант — захопити змінну з певною модифікацією:

CaptureInitMod.cpp
#include <iostream>

int main()
{
    int value = 5;

    // Захоплюємо value, але подвоєне — нова змінна doubleValue
    auto print = [doubleValue = value * 2]()
    {
        std::cout << "Double: " << doubleValue << '\n';
    };

    print(); // Double: 10

    return 0;
}

Initialized capture — потужна можливість: вона дозволяє не лише повторно використовувати зовнішні значення, але і підготувати «трохи перетворені» версії прямо в специфікаторі захоплення — без зайвих проміжних змінних.


Пастка 1: висячі посилання при захопленні за посиланням

Захоплення за посиланням несе ризик «висячого посилання» (dangling reference) — якщо оригінальна змінна знищується раніше, ніж лямбда перестає існувати.

Найчастіша причина — функція повертає лямбду, яка захопила по посиланню локальну змінну функції:

DanglingRef.cpp
#include <iostream>
#include <functional>

// Функція повертає лямбду — але & захоплює локальну змінну!
std::function<void()> makePrinter()
{
    int localValue = 42; // локальна змінна — існує лише в makePrinter()

    // [&] — захоплюємо localValue за посиланням
    return [&]()
    {
        // ❌ Невизначена поведінка!
        // localValue вже знищена після повернення з makePrinter()
        std::cout << localValue << '\n';
    };
}

int main()
{
    auto printer = makePrinter(); // makePrinter() завершилась, localValue знищена
    printer(); // ❌ UB: звертаємося до неіснуючої змінної

    return 0;
}
Код вище є невизначеною поведінкою (UB). Програма може вивести 42, 0, щось випадкове або аварійно завершитися — залежно від компілятора, оптимізацій і вмісту стеку. Жодна з цих відповідей не є «правильною» — UB є UB.

Виправлення: захопіть за значенням:

FixedDanglingRef.cpp
std::function<void()> makePrinter()
{
    int localValue = 42;

    // [localValue] — копія, живе разом з лямбдою, не залежить від стеку makePrinter()
    return [localValue]()
    {
        std::cout << localValue << '\n'; // ✅ безпечно
    };
}

Правило: якщо лямбда «виходить» назовні (повертається з функції або зберігається довше за навколишній блок) — захоплюйте за значенням.


Пастка 2: ненавмисне копіювання лямбди з mutable-станом

Лямбда — це об'єкт. При присвоєнні вона копіюється. Якщо лямбда має mutable-стан (захоплена за значенням змінна, яку вона змінює), то кожна копія отримує свою незалежну копію цього стану:

LambdaCopy.cpp
#include <iostream>

int main()
{
    int counter = 0;

    auto increment = [counter]() mutable
    {
        ++counter;
        std::cout << counter << '\n';
    };

    increment(); // 1  (counter лямбди: 0 → 1)

    auto copy = increment; // копіюємо лямбду — копіюється і внутрішній стан!
                           // copy.counter = 1 (поточне значення)

    increment(); // 2  (оригінал: 1 → 2)
    copy();      // 2  (копія: 1 → 2, незалежно від оригіналу!)

    return 0;
}
./LambdaCopy
$ ./LambdaCopy
1
2
2

Замість очікуваного 1 2 3 ми бачимо 1 2 2. Чому? auto copy = increment копіює весь стан лямбди — включно зі внутрішньою копією counter (значення 1 на момент копіювання). Після цього increment і copy — два незалежні об'єкти зі своїми counter.

Аналогічна пастка з std::function:

StdFuncCopy.cpp
#include <iostream>
#include <functional>

void callThreeTimes(const std::function<void()>& fn)
{
    fn(); // std::function зберігає КОПІЮ лямбди всередині себе
    fn(); // кожен виклик — одна й та сама копія
    fn();
}

int main()
{
    int counter = 0;
    auto increment = [counter]() mutable
    {
        ++counter;
        std::cout << counter << '\n';
    };

    callThreeTimes(increment);
    // Очікуємо: 1 2 3
    // Реальність: 1 1 1 — std::function кожного разу виконує СВОЮ копію

    return 0;
}
./StdFuncCopy
$ ./StdFuncCopy
1
1
1

std::function при прийнятті лямбди копіює її всередину себе. Кожен виклик fn() виконує ту саму внутрішню копію — з тим самим початковим станом. Після кожного виклику стан оновлюється лише у внутрішній копії, яка при наступному виклику відновлюється знову.

Виправлення: якщо потрібно зберігати стан між викликами — захоплюйте за посиланням:

FixedStdFunc.cpp
int counter = 0;
auto increment = [&counter]() // захоплення за посиланням — не копіюється
{
    ++counter;
    std::cout << counter << '\n';
};

callThreeTimes(increment); // 1 2 3 — тепер правильно

Зведення: способи захоплення

Значення: `[x]`

Копія x на момент визначення лямбди. Не відчуває змін оригіналу. Не може змінювати копію без mutable. Безпечно для «довгоживучих» лямбд.

Посилання: `[&x]`

Посилання на оригінальний x. Зміни в лямбді → зміни оригіналу. Не потрібен mutable. Небезпечно, якщо оригінал знищується раніше лямбди.

Всі за значенням: `[=]`

Автоматично захоплює всі згадані змінні за значенням. Зручно, але приховує залежності. Уникайте в складному коді.

Всі за посиланням: `[&]`

Автоматично захоплює всі згадані змінні за посиланням. Зручно для коротких лямбд. Небезпечно при зберіганні/поверненні лямбди.

Нова змінна: `[x = expr]`

Ініціалізоване захоплення (C++14). Оголошує нову змінну x з результатом expr. Обчислюється один раз — при визначенні лямбди.

`mutable`

Знімає const з захоплених за значенням змінних. Лямбда може змінювати свої копії. Але зміни не впливають на зовнішні оригінали.

Практичні завдання

Завдання

Рівень 1 — Базовий

Завдання 1. Що виведе код? Поясніть чому x у main не змінюється, а count змінюється:

int x = 10;
int count = 0;
auto fn = [x, &count]() mutable
{
    ++x;
    ++count;
    std::cout << "x=" << x << " count=" << count << '\n';
};
fn();
fn();
std::cout << "main: x=" << x << " count=" << count << '\n';

Завдання 2. Визначте лямбду inRange, що приймає int і повертає true, якщо воно від min до max включно. min і max — зовнішні змінні, захоплені за значенням. Протестуйте з min=5, max=15 і числами {3, 7, 15, 20, 10}.

Завдання 3. Знайдіть і виправте помилку. Поясніть, чому вона виникає:

std::function<void()> makeCounter()
{
    int n = 0;
    return [&n]() { std::cout << ++n << '\n'; };
}
auto c = makeCounter();
c(); c(); c();

Рівень 2 — Логіка

Завдання 4. Реалізуйте функцію int sumIf(int* array, int size, std::function<bool(int)> pred), що підсумовує елементи, для яких pred повертає true. Протестуйте:

  • Сума парних із {1,2,3,4,5,6} → 12
  • Сума чисел > threshold, де threshold отримується із зовнішньої змінної через capture.

Завдання 5. Напишіть функцію void forEachIndexed(int* array, int size, std::function<void(int, int)> fn), що викликає fn(index, value) для кожного елемента. Викличте її з лямбдою, що виводить лише індекси непарних елементів.

Завдання 6. Реалізуйте лямбду-акумулятор: кожен виклик додає передане число до внутрішньої суми і повертає поточну суму. Зовнішня змінна total повинна відображати актуальне значення після кожного виклику.

Рівень 3 — Архітектура

Завдання 7. Реалізуйте систему підрахунку статистики через лямбди із захопленням:

  • Три лямбди: trackMin, trackMax, trackSum — кожна захоплює за посиланням відповідну змінну minVal, maxVal, total.
  • Функція void forEach(int* array, int size, std::function<void(int)> fn).
  • Передайте кожну лямбду у forEach з масивом {7, 2, 14, 1, 9, 5, 11} і виведіть мін, макс і суму.

Підсумок

СинтаксисТипЗміна зовнішньої?Потребує mutable?
[x]Копія❌ Ні✅ Для зміни копії
[&x]Посилання✅ Так❌ Не потрібен
[=]Всі за копією❌ Ні✅ Для зміни копій
[&]Всі за посиланням✅ Так❌ Не потрібен
[x = expr]Нова змінна✅ Для зміни
[=, &x]Mix: все за копією, x за посиланнямТільки xЗалежить
[&, x]Mix: все за посиланням, x за копієюВсе крім x✅ Для x
Головні пастки: (1) захоплення [&] у лямбді, що переживає свою функцію → висяче посилання; (2) mutable + копіювання лямбди → незалежні стани у кожної копії.

У наступній статті ми переходимо до теми еліпсису (...) — синтаксису функцій зі змінною кількістю аргументів.