Урок №118. Лямбда-вирази

  Юрій  | 

  Оновл. 1 Вер 2021  | 

 255

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

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

Розглянемо наступний приклад:

Вищенаведений код перебирає масив рядків в пошуках першого-ліпшого елемента, який містить підрядок nut. Таким чином, результат виконання програми:

Found walnut

Хоча це і робочий код, але ми можемо його покращити.

Проблема криється в тому, що функція std::find_if() вимагає вказівник на функцію в якості аргументу. Через це ми змушені визначити нову функцію, яка буде використана тільки один раз, дати їй ім’я і помістити її в глобальну область видимості (тому що функції не можуть бути вкладеними!). При цьому вона буде настільки короткою, що швидше і простіше зрозуміти її сенс, подивившись лише на один рядок коду, ніж вивчати опис цієї функції і її ім’я.

Введення в лямбда-вирази

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

Синтаксис лямбда-виразів є одним з найбільш дивних в мові C++, і вам може знадобитися деякий час, щоб до нього звикнути.

Лямбда-вирази мають наступний синтаксис:

[ captureClause ] ( параметри ) -> ТипЩоПовертається
{
стейтменти;
}

Поля captureClause і параметри можуть бути пустими, якщо вони не потрібні програмісту.

Поле ТипЩоПовертається є опціональним, і, якщо його немає, буде використовуватися вивід типу за допомогою ключового слова auto. Хоча ми раніше вже відзначали, що слід уникати використання виводу типу для значень, що повертаються, в даному контексті подібне використання допускається (оскільки зазвичай такі функції є тривіальними).

Також зверніть увагу, що лямбда-вирази не мають імен, тому нам і не потрібно буде їх надавати. З цього факту випливає, що тривіальне визначення лямбди може мати наступний вигляд:

Давайте перепишемо попередній приклад, але вже з використанням лямбда-виразів:

При цьому все працює точно так само, як і у випадку з вказівником на функцію. Результат виконання програми аналогічний:

Found walnut

Зверніть увагу, наскільки наша лямбда схожа на функцію containsNut(). Вони обидві мають однакові параметри і тіла функцій. Відзначимо, що у лямбди відсутнє поле captureClause (детально про captureClause ми поговоримо на наступному уроці), тому що воно не потрібне. Також для стислості ми пропустили синтаксис trailing-типу значення, що повертається, але через те, що operator!= повертає значення типу bool, наша лямбда також повертатиме логічне значення.

Тип лямбда-виразів

У прикладі, наведеному вище, ми визначили лямбду прямо в тому місці, де вона була нам потрібна. Таке використання лямбда-виразу іноді ще називають функціональним літералом.

Проте написання лямбди в тому ж рядку, де вона використовується, іноді може погіршити читання коду. Подібно до того, як ми можемо ініціалізувати змінну за допомогою літералу (або вказівника на функцію) для використання в подальшому, так само ми можемо ініціалізувати і лямбда-змінну за допомогою лямбда-визначення для її подальшого використання. Іменована лямбда разом з вдалим ім’ям функції може полегшити читання коду.

Наприклад, в наступному фрагменті коду ми використовуємо функцію std::all_of() для того, щоб перевірити, чи є всі елементи масиву парними:

Ми можемо покращити читабельність коду наступним чином:

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

Але якого типу є лямбда в isEven?

Виявляється, у лямбд немає типу, який ми могли б явно використати. Коли ми пишемо лямбду, компілятор генерує унікальний тип лямбди, який нам не видно.

Для просунутих читачів: Насправді, лямбди не є функціями (що і допомагає їм уникати обмежень мови C++, які накладаються на використання вкладених функцій). Лямбди є особливим типом об’єктів, який називається функтором. Функтори — це об’єкти, які містять перевантажений operator(), який і робить їх викликаючими подібно до звичайних функцій.

Хоча ми не знаємо тип лямбди, є декілька способів її зберігання для використання після визначення. Якщо лямбда нічого не захоплює, то ми можемо використовувати звичайний вказівник на функцію. Як тільки лямбда що-небудь захоплює, вказівник на функцію більше не працюватиме. Однак std::function може використовуватися для лямбд, навіть якщо вони щось захоплюють:

За допомогою auto ми можемо використовувати фактичний тип лямбди. При цьому ми можемо отримати перевагу у вигляді відсутності накладних витрат у порівнянні з використанням std::function.

На жаль, ми не завжди можемо використовувати auto. У тих випадках, коли фактичний тип лямбди невідомий (наприклад, через те, що ми передаємо лямбду в функцію в якості параметру, і викликаючий об’єкт сам визначає якого типу лямбда буде передана), ми не можемо використовувати auto. У таких випадках слід використовувати std::function:

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

0
1
2

Правило: Використовуйте auto при ініціалізації змінних за допомогою лямбд і std::function, якщо ви не можете ініціалізувати змінну за допомогою лямбд.

Загальні/Узагальнені лямбди

Здебільшого лямбда-параметри працюють так само, як і звичайні параметри функцій.

Одним примітним винятком є те, що, починаючи з C++14, нам дозволено використовувати auto з параметрами функцій.

Примітка: В C++20 звичайні функції також можуть використовувати auto з параметрами.

Якщо у лямбди є один або кілька параметрів auto, то компілятор визначить необхідні типи параметрів з викликів лямбда-виразів.

Оскільки лямбди з одним або декількома параметрами типу auto потенційно можуть працювати з великою кількістю типів даних, то вони називаються загальними (або «узагальненими», від англ. “generic lambdas”) лямбдами.

Розглянемо використання загальної лямбди на практиці:

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

June and July start with the same letter

У прикладі, наведеному вище, ми використовували auto-параметри для захоплення наших рядків з використанням константного посилання. Оскільки всі рядкові типи надають доступ до своїх окремих символів через оператор [], то нам не потрібно хвилюватися про те, чи передає користувач в якості параметру std::string, рядок C-style або щось інше. Це дозволяє нам написати лямбду, яка могла б прийняти будь-який з цих об’єктів, тобто, якщо пізніше ми змінимо тип months, — нам не доведеться переписувати лямбду.

Однак auto не завжди є кращим вибором. Розглянемо наступну програму:

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

There are 2 months with 5 letters

У цьому прикладі використання auto виводить тип const char*. Ми знаємо, що з рядками C-style важко працювати (крім використання оператора []). Тому в даному випадку для нас краще явно визначити тип параметру, як std::string_view, який дозволить нам працювати з базовими типами даних набагато простіше (наприклад, ми можемо запросити у представлення значення довжини рядка, навіть якщо користувач передав в якості аргументу масив C-style).

Загальні лямбди і статичні змінні

Слід мати на увазі, що для кожного окремого типу, виведеного за допомогою auto, буде згенерована унікальна лямбда. У наступному прикладі показано, як загальна лямбда ділиться на дві окремі:

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

0: hello
1: world
0: 1
1: 2
2: ding dong

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

У більшості випадків це не суттєво. Однак, зверніть увагу, що якщо в загальній лямбді використовуються статичні змінні, то ці змінні не є загальними для створених лямбд.

Ми можемо побачити це у вищенаведеному прикладі, де кожен тип (рядкові літерали і цілі числа) має свій власний унікальний рахунок! Хоча ми написали лямбду один раз, були згенеровані дві лямбди, і у кожної є своя версія callCount.

Якби ми хотіли, щоб callCount був загальним для лямбд, то нам довелося б оголосити його поза лямбдою і захопити його по посиланню, щоб він міг бути змінений лямбдою.

trailing-типи, що повертаються

Якщо використовувався вивід типу, що повертається, то тип, що повертається, лямбди виводиться зі стейтментів return всередині лямбди. Якщо використовувався вивід типу, що повертається, то всі стейтменти, що повертаються, всередині лямбди повинні повертати значення одного і того ж типу (інакше компілятор не знатиме, який з них йому слід використовувати). Наприклад:

Це призведе до помилки компіляції, так як тип значення, що повертається, першого стейтменту return (int) не збігається з типом значення, що повертається, другого стейтменту return (double).

У разі, коли у нас використовуються різні типи, що повертаються, у нас є два варіанти:

   виконати явні конвертації в один тип;

   явно вказати тип значення, що повертається, для лямбди і дозволити компілятору виконати неявні конвертації.

Другий варіант зазвичай є кращим:

Таким чином, якщо ви коли-небудь вирішите змінити тип значення, що повертається, вам (як правило) потрібно буде змінити тільки тип значення, що повертається, лямбди і нічого всередині основної частини.

Функціональні об’єкти Стандартної бібліотеки С++

Для основних операцій (наприклад, додавання, віднімання або порівняння) вам не потрібно писати свої власні лямбди, тому що Стандартна бібліотека С++ поставляється з багатьма базовими об’єктами, що викликаються, які ви можете використовувати. Ці об’єкти визначені в заголовку functional. Наприклад:

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

99 90 80 40 13 5

Замість конвертації функції greater() в лямбду, ми можемо використати std::greater:

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

99 90 80 40 13 5

Висновки

Лямбда-вирази і бібліотека алгоритмів можуть видатися надто складними в порівнянні зі звичайними рішеннями, в яких використовуються цикли. Однак ця комбінація дозволяє виконувати деякі дуже потужні операції за всього лише декілька рядків коду і це може бути куди читабельнішим, ніж ваші «самописні» цикли. Крім того, бібліотека алгоритмів володіє потужним і простим у використанні паралелізмом, який ви не отримаєте при використанні циклів. Оновлення вихідного коду, який використовує бібліотечні функції, є простішим, ніж оновлення коду, який використовує цикли.

Лямбди прекрасні, але вони не замінюють звичайні функції для всіх ситуацій. Використовуйте звичайні функції для нетривіальних випадків.

Тест

Завдання №1

Створіть структуру Student, яка зберігатиме ім’я і бали студента. Створіть масив студентів і використайте функцію std::max_element() для пошуку студента з найбільшими балами, а потім виведіть на екран ім’я знайденого студента. Функція std::max_element() приймає begin і end списку, і функцію з двома параметрами, яка повертає true, якщо перший аргумент менше другого.

При використанні наступного масиву:

Результатом виконання вашої програми повинно бути наступне:

Dan is the best student

Показати підказку

Відповідь №1

Завдання №2

Використовуйте std::sort() і лямбду в наступному коді для сортування пір року по зростанню середньої температури:

Результатом виконання вашої програми повинно бути наступне:

Winter
Spring
Fall
Summer

Відповідь №2

Оцінити статтю:

1 Зірка2 Зірки3 Зірки4 Зірки5 Зірок (3 оцінок, середня: 5,00 з 5)
Loading...

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

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