Лямбда-захваты в С++

  Дмитрий Бушуев  | 

  Обновл. 28 Сен 2020  | 

 8117

 ǀ   9 

На этом уроке мы рассмотрим, что такое лямбда-захваты в языке С++, как они работают, какие есть типы и как их использовать.

Зачем нужен лямбда-захват?

На предыдущем уроке мы рассматривали следующий пример:

Давайте изменим его так, чтобы пользователь сам выбирал подстроку для поиска. Это не настолько интуитивно легко, как может показаться на первый взгляд:

Данный код не скомпилируется. В отличие от вложенных блоков кода, где любой идентификатор, определенный во внешнем блоке, доступен и во внутреннем, лямбды в языке С++ могут получить доступ только к определенным видам идентификаторов: глобальные идентификаторы, объекты, известные во время компиляции и со статической продолжительностью жизни. Переменная search не соответствует ни одному из этих требований, поэтому лямбда не может её увидеть. Вот для этого и существует лямбда-захват (англ. «capture clause»).

Введение в лямбда-захваты


Поле capture clause используется для того, чтобы предоставить (косвенно) лямбде доступ к переменным из окружающей области видимости, к которым она обычно не имеет доступ. Всё, что нам нужно для этого сделать, так это перечислить в поле capture clause объекты, к которым мы хотим получить доступ внутри лямбды. В нашем примере мы хотим предоставить лямбде доступ к значению переменной search, поэтому добавляем её в захват:

Теперь пользователь сможет выполнить поиск нужного элемента в массиве:

search for: nana
Found banana

Суть работы лямбда-захватов

Хотя может показаться, будто в вышеприведенном примере наша лямбда напрямую обращается к значению переменной search (относящейся к блоку кода функции main()), но это не так. Да, лямбды могут выглядеть и функционировать как вложенные блоки, но на самом деле они работают немного по-другому, и при этом существует довольно важное отличие.

Когда выполняется лямбда-определение, то для каждой захватываемой переменной внутри лямбды создается клон этой переменной (с идентичным именем). Данные переменные-клоны инициализируются с помощью переменных из внешней области видимости с тем же именем.

Таким образом, в примере, приведенном выше, при создании объекта лямбды, она получает свою собственную переменную-клон с именем search. Эта переменная имеет такое же значение, что и переменная search из функции main(), поэтому кажется будто мы получаем доступ непосредственно к переменной search функции main(), но это не так.

Несмотря на то, что эти переменные-клоны имеют одно и то же имя, их тип может отличаться от типа исходной переменной (об этом чуть позже).

Ключевой момент: Переменные, захваченные лямбдой, являются клонами переменных из внешней области видимости, а не фактическими «внешними» переменными.

Для продвинутых читателей: Когда компилятор обнаруживает определение лямбды, он создает для нее определение как для пользовательского объекта. Каждая захваченная переменная становится элементом данных этого объекта. Во время выполнения программы, при обнаружении определения лямбды, создается экземпляр объекта лямбды и в этот момент инициализируются члены лямбды.

Захваты переменных и const


По умолчанию переменные захватываются как константные значения. Это означает, что при создании лямбды, она захватывает константную копию переменной из внешней области видимости, что означает, что значения этих переменных лямбда изменить не может. В следующем примере мы захватим переменную ammo и попытаемся выполнить декремент:

В примере, приведенном выше, когда мы захватываем переменную ammo, внутри лямбды создается константная переменная с таким же именем и значением. Мы не может изменить её, потому что она имеет спецификатор const. Подобная попытка изменения приведет к ошибке компиляции.

Захват по значению

Чтобы разрешить изменения значения переменных, которые были захвачены по значению, мы можем пометить лямбду как mutable. В данном контексте, ключевое слово mutable удаляет спецификатор const со всех переменных, захваченных по значению:

Результат выполнения программы:

Pew! 9 shot(s) left.
Pew! 8 shot(s) left.
10 shot(s) left

Хотя теперь этот код и скомпилируется, но в нем все еще есть логическая ошибка. Какая именно? При вызове лямбда захватила копию переменной ammo. Затем, когда лямбда уменьшает значение переменной ammo с 10 до 9 и до 8, то, на самом деле, она уменьшает значение копии, а не исходной переменной.

Обратите внимание, что значение переменной ammo сохраняется, несмотря на вызовы лямбды.

Захват по ссылке


Подобно тому, как функции могут изменять значения аргументов, передаваемых им по ссылке, мы также можем захватывать переменные по ссылке, чтобы позволить нашей лямбде влиять на значения аргументов.

Чтобы захватить переменную по ссылке, мы должны добавить знак амперсанда (&) к имени переменной, которую хотим захватить. В отличие от переменных, которые захватываются по значению, переменные, которые захватываются по ссылке, не являются константными (если только переменная, которую они захватывают, не является изначально const). Если вы предпочитаете передавать аргумент функции по ссылке (например, для типов, не являющихся базовыми), то вместо захвата по значению, предпочтительнее использовать захват по ссылке.

Вот вышеприведенный код, но уже с захватом переменной ammo по ссылке:

Результат выполнения программы:

Pew! 9 shot(s) left.
9 shot(s) left

Теперь давайте воспользуемся захватом по ссылке, чтобы подсчитать, сколько сравнений делает алгоритм std::sort() при сортировке массива:

Результат выполнения программы:

Comparisons: 2
Honda Civic
Toyota Corolla
Volkswagen Golf

Захват нескольких переменных

Мы можем захватить несколько переменных, разделив их запятыми. Мы также можем использовать как захват по значению, так и захват по ссылке:

Захваты по умолчанию


Необходимость явно перечислять переменные для захвата иногда может быть несколько обременительной. Если вы изменяете свою лямбду, то вы можете забыть добавить или удалить захватываемые переменные. К счастью, есть возможность заручиться помощью компилятора для автоматической генерации списка переменных, которые нам нужно захватить.

Захват по умолчанию захватывает все переменные, упомянутые в лямбде. Если используется захват по умолчанию, то переменные, не упомянутые в лямбде, не будут захвачены.

Чтобы захватить все задействованные переменные по значению, используйте = в качестве значения для захвата. Чтобы захватить все задействованные переменные по ссылке, используйте & в качестве значения для захвата.

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

Захваты по умолчанию могут быть смешаны с обычными захватами. Вполне допускается захватить некоторые переменные по значению, а другие — по ссылке, но при этом каждая переменная может быть захвачена только один раз:

Определение новых переменных в лямбда-захвате

Допустим, что нам нужно захватить переменную с небольшой модификацией или объявить новую переменную, которая видна только в области видимости лямбды. Мы можем это сделать, определив переменную в лямбда-захвате без указания её типа:

Переменная userArea будет рассчитана только один раз: во время определения лямбды. Вычисляемая площадь хранится в объекте лямбды и одинакова для каждого вызова. Если лямбда имеет модификатор mutable и изменяет переменную, которая определена в захвате, то исходное значение переменной будет переопределено.

Совет: Инициализируйте переменные в захвате только в том случае, если их значения не являются слишком большими и их тип очевиден. В противном случае, лучше всего определить переменную вне лямбды, а затем захватить её.

Висячие захваченные переменные

Переменные захватываются в точке определения лямбды. Если переменная, захваченная по ссылке, прекращает свое существование до прекращения существования лямбды, то лямбда остается с висячей ссылкой:

Вызов функции makeWalrus() создает временный объект std::string из строкового литерала "Roofus". Лямбда в функции makeWalrus() захватывает временную строку по ссылке. Данная строка уничтожается при выполнении возврата makeWalrus(), но при этом лямбда все еще ссылается на нее. Затем, когда мы вызываем sayName(), происходит попытка доступа к висячей ссылке, что чревато неопределенными результатами.

Обратите внимание, это также происходит, если переменная name передается в функцию makeWalrus() по значению. Переменная name все равно прекратит свое существование в конце работы функции makeWalrus(), и лямбда останется с висячей ссылкой.

Предупреждение: Будьте особенно осторожны при захвате переменных по ссылке, особенно при указанном захвате по умолчанию по ссылке. Захваченные переменные должны существовать дольше, чем сама лямбда.

Если мы хотим, чтобы захваченная переменная name была валидна, когда используется лямбда, то нам нужно захватить данную переменную по значению (либо явно, либо с помощью захвата по умолчанию по значению).

Непреднамеренные копии лямбд

Поскольку лямбды являются объектами, то их можно копировать. В некоторых случаях это может вызвать проблемы. Рассмотрим следующий код:

Результат выполнения программы:

1
2
2

Вместо вывода 1 2 3 программа дважды выводит число 2. Создавая объект otherCount, как копию объекта count, мы копируем его текущее состояние. Значением переменной i, принадлежащей объекту count, является 1 и значением переменной i, принадлежащей объекту otherCount, так же является 1. Поскольку otherCount — это копия count, то у каждого объекта имеется своя собственная переменная i.

Теперь давайте рассмотрим менее очевидный пример:

Результат выполнения программы:

1
1
1

Данный пример демонстрирует возникновение той же проблемы, что и предыдущий пример. Когда с помощью лямбды создается объект std::function, то он внутри себя создает копию лямбда-объекта. Таким образом, наш вызов fn() фактически выполняется при использовании копии лямбды, а не самой лямбды.

Если нам нужно передать изменяемую лямбду, и при этом мы хотим избежать непреднамеренного копирования, то есть два варианта решения данной проблемы. Один из них — использовать вместо этого лямбду, не содержащую захватов. В примере, приведеном выше, мы могли бы удалить захват и отслеживать наше состояние, используя статическую локальную переменную. Но статические локальные переменные могут быть трудны для отслеживания и делают наш код менее читабельным. Лучший вариант — это с самого начала не допустить возможности копирования нашей лямбды. Но, поскольку мы не можем повлиять на реализацию std::function (или любой другой функции или объекта из Стандартной библиотеки С++), как мы можем это сделать?

К счастью, C++ предоставляет тип std::ref (как часть заголовочного файла functional), который позволяет нам передавать обычный тип, как если бы это была ссылка. Обёртывая нашу лямбду в std::ref всякий раз, когда кто-либо пытается сделать копию нашей лямбды, он будет делать копию ссылки, а не фактического объекта.

Вот наш обновленный код с использованием std::ref:

Результат выполнения программы:

1
2
3

Обратите внимание, выходные данные не изменяются, даже если invoke() принимает fn() по значению. std::function не создает копию лямбды, если мы используем std::ref.

Правило: Стандартные библиотечные функции могут копировать функциональные объекты (напомним, что лямбды принадлежат к категории функциональных объектов). Если вы хотите использовать лямбду вместе с изменяемыми захваченными переменными, то передавайте их по ссылке с помощью std::ref.

Тест

Задание №1

Какие из следующих переменных могут использоваться лямбдой в функции main() без их явного захвата?

Ответ №1

Переменная Использование без явного захвата
a Нет. Переменная a имеет автоматическую продолжительность.
b Да. Переменная b используется в константном выражении.
c Да. Переменная c имеет статическую продолжительность.
d Да.
e Да. Переменная e используется в константном выражении.
f Нет. Значение переменной f зависит от getValue(), что может потребовать запуска программы.
g Да.
h Да. Переменная h имеет статическую продолжительность.
i Да. Переменная i является глобальной переменной.
j Да. Переменная j доступна в целом файле.

Задание №2

Что выведет на экран следующая программа? Не запускайте код, а выполните его в уме:

Ответ №2

Результат выполнения программы:

I like grapes

Лямбда printFavoriteFruit захватывает favoriteFruit по значению. Изменение переменной favoriteFruit в функции main() никак не влияет на изменение переменной favoriteFruit в лямбде.

Задание №3

Мы собираемся написать небольшую игру с квадратами чисел.

Суть игры:

   Попросите пользователя ввести 2 числа: первое — стартовое число, которое нужно возвести в квадрат, второе — количество чисел, которые нужно возвести в квадрат.

   Сгенерируйте случайное целое число от 2 до 4 и возведите в квадрат указанное пользователем количество чисел, начиная со стартового.

   Умножьте каждое возведенное в квадрат число на сгенерированное ранее число (от 2 до 4).

   Пользователь должен вычислить, какие числа были сгенерированы — он указывает свои предположения.

   Программа проверяет, угадал ли пользователь число, и, если угадал — удаляет угаданное число из списка.

   Если пользователь не угадал число, то игра заканчивается, и программа выводит число, которое было ближе всего к окончательному предположению пользователя, но только если последнее предположение не отличалось больше чем на 4 единицы от числа из списка.

Вот первый запуск игры:

Start where? 4
How many? 8
I generated 8 square numbers. Do you know what each number is after multiplying it by 2?
> 32
Nice! 7 number(s) left.
> 72
Nice! 6 number(s) left.
> 50
Nice! 5 number(s) left.
> 126
126 is wrong! Try 128 next time.

Разбираемся:

   Пользователь решил начать с числа 4 и хочет 8 чисел.

   Квадрат каждого числа будет умножен на 2. Число 2 было выбрано программой случайным образом.

   Программа сгенерировала 8 квадратов чисел, начиная с числа 4: 16 25 36 49 64 81 100 121.

   Но при этом каждое число было умножено на 2, поэтому мы получаем следующие числа: 32 50 72 98 128 162 200 242.

   Теперь пользователь начинает угадывать. Порядок, в котором вводятся догадки, не имеет значения.

   Число 32 значится в списке.

   Число 72 значится в списке.

   Числа 126 нет в списке, поэтому пользователь проиграл. В списке есть число 128, которое отличается не более чем на 4 единицы от предположения пользователя, поэтому его мы и выводим в качестве подсказки.

Вот второй запуск игры:

Start where? 1
How many? 3
I generated 3 square numbers. Do you know what each number is after multiplying it by 4?
> 4
Nice! 2 numbers left.
> 16
Nice! 1 numbers left.
> 36
Nice! You found all numbers, good job!

Разбираемся:

   Пользователь решил начать с числа 1 и хочет 3 числа.

   Квадрат каждого числа будет умножен на 4.

   Программа сгенерировала следующие числа: 1 4 9.

   Умножаем их на 4: 4 16 36.

   Пользователь выиграл, угадав все числа.

Вот третий запуск игры:

Start where? 2
How many? 2
I generated 2 square numbers. Do you know what each number is after multiplying it by 4?
> 21
21 is wrong!

Разбираемся:

   Пользователь решил начать с числа 2 и хочет 2 числа.

   Квадрат каждого числа умножается на 4.

   Программа сгенерировала следующие числа: 16 36.

   Пользователь выдвигает предположение — 21, и проигрывает. 21 не достаточно близко к любому из оставшихся чисел, поэтому число-подсказка не выводится.

Подсказки:

   Используйте std::find() для поиска номера в списке.

   Используйте std::vector::erase() для удаления элемента, например:

   Используйте std::min_element() и лямбду, чтобы найти число, наиболее близкое к предположению пользователя. std::min_element() работает аналогично std::max_element() из теста предыдущего урока.

   Используйте std::abs() из заголовочного файла cmath, чтобы вычислить положительную разницу между двумя числами:

Ответ №3

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (38 оценок, среднее: 4,66 из 5)
Загрузка...

Комментариев: 9

  1. Аватар Furxie Fluke:

    Хех, как я понимаю, ответ на третье задание сделан для того чтобы те кто будет пытаться вникнуть в это, поняли как много простых деталей\мелочей\важностей они ещё не понимают? :3

    Но конечно здорово разобраться, много интересного понять оттуда можно

  2. Аватар Валера:

  3. Аватар Bampi:

    Оставлю так же свой вариант:

  4. Аватар pleb:

    Приму ваши замечания

  5. Аватар Viktor:

    Здравствуйте) Возможно я просто чего то не понимаю, но во время прочтения главы у меня возник вопрос по поводу std::min_element.
    Я вообще не понимаю на что влияет лямбда, которую мы передали третим параметром. Если сама функция возвращает указатель на найменьший елемент из вектора или масива, то какую функцию в таком случае выполняет лямбда, нам достаточно указать всего начало и конец? Если чесно, я не понимаю как и в каких случаях использовать третий параметр( В интернете ответа на этот вопрос не нашол. Помогите если кто то знает. Заранее спасибо)

    1. Дмитрий Бушуев Дмитрий Бушуев:

      >>Я вообще не понимаю на что влияет лямбда, которую мы передали третим параметром.
      Это в каком моменте?

    2. Аватар Максим:

      То, что для работы std::min_element достаточно указать всего начало и конец массива, ты прав, но отчасти.
      В твоем случае функция пройдет по массиву и выведет указатель наименьшего элемента.
      Это, так скачать, "First version" использования функции.

      Есть, еще "Second version" для этой функции, когда можно дописать третий параметр (указатель на функцию или лямбду) как свой вариант сравнения элементов массива.

      В данном тесте этот третий параметр сравнивает и находит наиболее "близкий" элемент из массива к введенному значению от пользователя (guess):

      В интернете есть описание работы функции std::min_element:

      First version:

      Second version:

      Если нет третьего параметра, происходит сравнение элементов:

      Если есть третий параметр (указатель или лямбда), функция использует его для сравнения элементов массива, "передавая" ему сравниваемые элементы в качестве аргументов:

  6. Аватар Михаил:

    Мой вариант. Работает, проверял.

  7. Аватар Анастасия:

    Оставлю здесь своё решение для третьего задания, т.к. оно получилось гораздо короче, чем в ответе, а std::min_element мой компилятор почему-то не знает, и лямбду я с ним не использовала.

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *