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] и т.д.

Кстати, в стандартных обсуждениях лямбд встречаются даже отдельные формулировки и issue’ы, связанные с тем, как захваты взаимодействуют с именами и скрытием (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. Читаемость: скрытие имён и захваты по умолчанию

На этом этапе часто появляется проблема не в том, что программа не работает, а в том, что её невозможно читать без телепатии. Лямбды — очень компактные, а захват‑дефолты ([&], [=]) делают их ещё компактнее. И тут можно случайно начать писать код, где непонятно, откуда взялась переменная и кто её меняет.

Есть даже отдельные обсуждения и issue’ы про «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]) — но тогда включается требование по времени жизни.

1
Задача
C++ SELF, 22 уровень, 1 лекция
Недоступна
Замороженный порог
Замороженный порог
1
Задача
C++ SELF, 22 уровень, 1 лекция
Недоступна
Счётчик проходов
Счётчик проходов
1
Задача
C++ SELF, 22 уровень, 1 лекция
Недоступна
Настройки и сумма
Настройки и сумма
1
Задача
C++ SELF, 22 уровень, 1 лекция
Недоступна
Генератор пропусков
Генератор пропусков
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ