Урок №119. Лямбда-захоплення

  Юрій  | 

  Оновл. 26 Січ 2021  | 

 21

На цьому уроці ми розглянемо, що таке лямбда-захоплення в мові С++, як вони працюють, які є типи і як їх використовувати.

Навіщо потрібні лямбда-захоплення?

На попередньому уроці ми розглядали наступний приклад:

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

Даний код не скомпілюється. На відміну від вкладених блоків коду, де будь-який ідентифікатор, визначений в зовнішньому блоці, доступний і у внутрішньому, лямбди в мові С++ можуть отримати доступ тільки до певних видів ідентифікаторів: глобальні ідентифікатори, об’єкти, відомі під час компіляції і зі статичною тривалістю життя. Змінна 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 Зірок (1 оцінок, середня: 5,00 з 5)
Loading...

Залишити відповідь

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