На цьому уроці ми розглянемо лямбда-вирази, їх типи і використання в мові C++.
Навіщо потрібно лямбда-вирази?
Розглянемо наступний приклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> static bool containsNut(std::string_view str) // static в даному контексті означає внутрішнє зв'язування { // Функція std::string_view::find() повертає std::string_view::npos, якщо вона не знайшла підрядок. // В протилежному випадку, вона повертає індекс, де відбувається входження підрядка в рядок str return (str.find("nut") != std::string_view::npos); } int main() { std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" }; // std::find_if() приймає вказівник на функцію auto found{ std::find_if(arr.begin(), arr.end(), containsNut) }; if (found == arr.end()) { std::cout << "No nuts\n"; } else { std::cout << "Found " << *found << '\n'; } return 0; } |
Цей код перебирає масив рядків в пошуках першого-ліпшого елемента, який містить підрядок nut
. Таким чином, результат виконання програми:
Found walnut
Хоча це і робочий код, але ми можемо його покращити.
Проблема криється в тому, що функція std::find_if() вимагає вказівник на функцію в якості аргументу. Через це ми змушені визначити нову функцію, яка буде використана тільки один раз, дати їй ім’я і помістити її в глобальну область видимості (тому що функції не можуть бути вкладеними!). При цьому вона буде настільки короткою, що швидше і простіше зрозуміти її сенс, подивившись лише на один рядок коду, ніж вивчати опис цієї функції і її ім’я.
Введення в лямбда-вирази
Лямбда-вираз (або просто «лямбда») в програмуванні дозволяє визначити анонімну функцію всередині іншої функції. Можливість зробити функцію вкладеною є дуже важливою перевагою, так як дозволяє уникнути як захаращення простору імен зайвими об’єктами, так і визначити функцію якомога ближче до місця її першого використання.
Синтаксис лямбда-виразів є одним з найбільш дивних в мові C++, і вам може знадобитися деякий час, щоб до нього звикнути.
Лямбда-вирази мають наступний синтаксис:
[ captureClause ] ( параметри ) -> ТипЩоПовертається
{
стейтменти;
}
Поля captureClause
і параметри
можуть бути пустими, якщо вони не потрібні програмісту.
Поле ТипЩоПовертається
є опціональним, і, якщо його немає, то буде використовуватися вивід типу за допомогою ключового слова auto. Хоча ми раніше вже відзначали, що слід уникати використання виводу типу для значень, що повертаються, в даному контексті подібне використання допускається (оскільки зазвичай такі функції є тривіальними).
Також зверніть увагу, що лямбда-вирази не мають імен, тому нам і не потрібно буде їх надавати. З цього факту випливає, що тривіальне визначення лямбди може мати наступний вигляд:
1 2 3 4 5 6 7 8 |
#include <iostream> int main() { []() {}; // визначаємо лямбда-вираз без captureClause, параметрів і типу, що повертається return 0; } |
Давайте перепишемо попередній приклад, але вже з використанням лямбда-виразів:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> int main() { std::array<std::string_view, 4> arr{ "apple", "banana", "walnut", "lemon" }; // Визначаємо функцію безпосередньо в тому місці, де збираємося її використовувати auto found{ std::find_if(arr.begin(), arr.end(), [](std::string_view str) // ось наша лямбда, без поля captureClause { return (str.find("nut") != std::string_view::npos); }) }; if (found == arr.end()) { std::cout << "No nuts\n"; } else { std::cout << "Found " << *found << '\n'; } return 0; } |
При цьому все працює точно так само, як і у випадку з вказівником на функцію. Результат виконання програми аналогічний:
Found walnut
Зверніть увагу, наскільки наша лямбда схожа на функцію containsNut(). Вони обидві мають однакові параметри і тіла функцій. Відзначимо, що у лямбди відсутнє поле captureClause
(детально про captureClause
ми поговоримо на наступному уроці), тому що воно не потрібне. Також для стислості ми пропустили синтаксис trailing-типу значення, що повертається, але через те, що operator!=
повертає значення типу bool, наша лямбда також буде повертати логічне значення.
Тип лямбда-виразів
У прикладі, наведеному вище, ми визначили лямбду прямо в тому місці, де вона була нам потрібна. Таке використання лямбда-виразу іноді ще називають функціональним літералом.
Проте написання лямбди в тому ж рядку, де вона використовується, іноді може погіршити читання коду. Подібно до того, як ми можемо ініціалізувати змінну за допомогою літералу (або вказівника на функцію) для використання в подальшому, так само ми можемо ініціалізувати і лямбда-змінну за допомогою лямбда-визначення для її подальшого використання. Іменована лямбда разом з вдалим ім’ям функції може полегшити читання коду.
Наприклад, в наступному фрагменті коду ми використовуємо функцію std::all_of() для того, щоб перевірити, чи є всі елементи масиву парними:
1 2 |
// Погано: Ми повинні прочитати лямбду, щоб зрозуміти, що відбувається return std::all_of(array.begin(), array.end(), [](int i){ return ((i % 2) == 0); }); |
Ми можемо покращити читабельність коду наступним чином:
1 2 3 4 5 6 7 8 9 |
// Добре: Ми можемо зберегти лямбду в іменованій змінній і передавати її в функцію в якості параметру auto isEven{ [](int i) { return ((i % 2) == 0); } }; return std::all_of(array.begin(), array.end(), isEven); |
Зверніть увагу, як просто читається останній рядок коду: “…повертаємо всі елементи масиву, які є парними…”.
Але якого типу є лямбда в isEven
?
Виявляється, у лямбд немає типу, який ми могли б явно використати. Коли ми пишемо лямбду, компілятор генерує унікальний тип лямбди, який нам не видно.
Для просунутих читачів: Насправді, лямбди не є функціями (що і допомагає їм уникати обмежень C++, які накладаються на використання вкладених функцій). Лямбди є особливим типом об’єктів, який називається функтором. Функтори — це об’єкти, які містять перевантажений operator(), який і робить їх викликаючими подібно до звичайних функцій.
Хоча ми не знаємо тип лямбди, є декілька способів її зберігання для використання після визначення. Якщо лямбда нічого не захоплює, то ми можемо використовувати звичайний вказівник на функцію. Як тільки лямбда що-небудь захоплює, вказівник на функцію більше не буде працювати. Однак std::function
може використовуватися для лямбд, навіть якщо вони щось захоплюють:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
#include <functional> int main() { // Звичайний вказівник на функцію. Лямбда не може нічого захоплювати double (*addNumbers1)(double, double){ [](double a, double b) { return (a + b); } }; addNumbers1(1, 2); // Використовуємо std::function. Лямбда може захоплювати змінні std::function addNumbers2{ // примітка: Якщо у вас не підтримується C++17, то використовуйте std::function<double(double, double)> [](double a, double b) { return (a + b); } }; addNumbers2(3, 4); // Використовуємо auto. Зберігаємо лямбду з її реальним типом auto addNumbers3{ [](double a, double b) { return (a + b); } }; addNumbers3(5, 6); return 0; } |
За допомогою auto ми можемо використати фактичний тип лямбди. При цьому ми можемо отримати перевагу у вигляді відсутності накладних витрат у порівнянні з використанням std::function
.
На жаль, ми не завжди можемо використовувати auto. У тих випадках, коли фактичний тип лямбди невідомий (наприклад, через те, що ми передаємо лямбду в функцію в якості параметру, і викликаючий об’єкт сам визначає якого типу лямбда буде передана), ми не можемо використовувати auto. У таких випадках слід використовувати std::function
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <functional> #include <iostream> // Ми не знаємо, чим буде fn. std::function працює зі звичайними функціями і лямбдами void repeat(int repetitions, const std::function<void(int)>& fn) { for (int i{ 0 }; i < repetitions; ++i) { fn(i); } } int main() { repeat(3, [](int i) { std::cout << i << '\n'; }); return 0; } |
Результат виконання програми:
0
1
2
Правило: Використовуйте auto при ініціалізації змінних за допомогою лямбд і std::function, якщо ви не можете ініціалізувати змінну за допомогою лямбд.
Загальні/Узагальнені лямбди
Здебільшого лямбда-параметри працюють так само, як і звичайні параметри функцій.
Одним примітним винятком є те, що, починаючи з C++14, нам дозволено використовувати auto з параметрами функцій.
Примітка: В C++20 звичайні функції також можуть використовувати auto з параметрами.
Якщо у лямбди є один або кілька параметрів auto, то компілятор визначить необхідні типи параметрів з викликів лямбда-виразів.
Оскільки лямбди з одним або декількома параметрами типу auto потенційно можуть працювати з великою кількістю типів даних, то вони називаються загальними (або «узагальненими» від англ. “generic lambdas”) лямбдами.
Розглянемо використання загальної лямбди на практиці:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> int main() { std::array months{ // якщо у вас не підтримується C++17, то використовуйте std::array<std::string_view, 12> "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; // Пошук двох послідовних місяців, які починаються з однакової букви auto sameLetter{ std::adjacent_find(months.begin(), months.end(), [](const auto& a, const auto& b) { return (a[0] == b[0]); }) }; // Переконуємося, що ці два місяці були знайдені if (sameLetter != months.end()) { std::cout << *sameLetter << " and " << *std::next(sameLetter) << " start with the same letter\n"; } return 0; } |
Результат виконання програми:
June and July start with the same letter
У прикладі, наведеному вище, ми використовували auto-параметри для захоплення наших рядків з використанням константного посилання. Оскільки всі рядкові типи надають доступ до своїх окремих символів через оператор []
, то нам не потрібно хвилюватися про те, чи передає користувач в якості параметру std::string, рядок C-style або щось інше. Це дозволяє нам написати лямбду, яка могла б прийняти будь-який з цих об’єктів, тобто, якщо пізніше ми змінимо тип months
, — нам не доведеться переписувати лямбду.
Однак auto не завжди є кращим вибором. Розглянемо наступну програму:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> int main() { std::array months{ // якщо у вас не підтримується C++17, то використовуйте std::array<const char*, 12> "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; // Рахуємо кількість місяців з назвами в 5 букв auto fiveLetterMonths{ std::count_if(months.begin(), months.end(), [](std::string_view str) { return (str.length() == 5); }) }; std::cout << "There are " << fiveLetterMonths << " months with 5 letters\n"; return 0; } |
Результат виконання програми:
There are 2 months with 5 letters
У цьому прикладі використання auto виводить тип const char*
. Ми знаємо, що з рядками C-style важко працювати (крім використання оператора []
). Тому в даному випадку для нас краще явно визначити тип параметру, як std::string_view, який дозволить нам працювати з базовими типами даних набагато простіше (наприклад, ми можемо запросити у представлення значення довжини рядка, навіть якщо користувач передав в якості аргументу масив C-style).
Загальні лямбди і статичні змінні
Слід мати на увазі, що для кожного окремого типу, виведеного за допомогою auto, буде згенерована унікальна лямбда. У наступному прикладі показано, як загальна лямбда розділяється на дві окремі:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> int main() { // Виводимо значення і підраховуємо, скільки разів буде викликаний print auto print{ [](auto value) { static int callCount{ 0 }; std::cout << callCount++ << ": " << value << '\n'; } }; print("hello"); // 0: hello print("world"); // 1: world print(1); // 0: 1 print(2); // 1: 2 print("ding dong"); // 2: ding dong return 0; } |
Результат виконання програми:
0: hello
1: world
0: 1
1: 2
2: ding dong
У прикладі, наведеному вище, ми визначаємо лямбду і потім викликаємо її з двома різними параметрами (рядковим літералом і цілочисельним типом). При цьому генеруються дві різні версії лямбди (одна з параметром рядкового літералу, а інша — з параметром у вигляді цілочисельного типу).
У більшості випадків це не суттєво. Однак, зверніть увагу, що якщо в загальній лямбді використовуються статичні змінні, то ці змінні не є загальними для створених лямбд.
Ми можемо побачити це у вищенаведеному прикладі, де кожен тип (рядкові літерали і цілі числа) має свій власний унікальний рахунок! Хоча ми написали лямбду один раз, були згенеровані дві лямбди, і у кожної є своя версія callCount
.
Якби ми хотіли, щоб callCount
був загальним для лямбд, то нам довелося б оголосити його поза лямбдою і захопити його по посиланню, щоб він міг бути змінений лямбдою.
trailing-типи, що повертаються
Якщо використовувався вивід типу, що повертається, то тип, що повертається, лямбди виводиться зі стейтментів return всередині лямбди. Якщо використовувався вивід типу, що повертається, то всі стейтменти, що повертаються, всередині лямбди повинні повертати значення одного і того ж типу (інакше компілятор не знатиме, який з них йому слід використовувати). Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <iostream> int main() { auto divide{ [](int x, int y, bool bInteger) { // примітка: Не вказаний тип значення, що повертається if (bInteger) return x / y; else return static_cast<double>(x) / y; // ПОМИЛКА: Тип значення, що повертається, не співпадає з попереднім типом, що повертається } }; std::cout << divide(3, 2, true) << '\n'; std::cout << divide(3, 2, false) << '\n'; return 0; } |
Це призведе до помилки компіляції, так як тип значення, що повертається, першого стейтменту return (int
) не збігається з типом значення, що повертається, другого стейтменту return (double
).
У разі, коли у нас використовуються різні типи, що повертаються, у нас є два варіанти:
виконати явні перетворення в один тип;
явно вказати тип значення, що повертається, для лямбди і дозволити компілятору виконати неявні перетворення.
Другий варіант зазвичай є кращим:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> int main() { // Примітка: Явно вказуємо тип double для значення, що повертається auto divide{ [](int x, int y, bool bInteger) -> double { if (bInteger) return x / y; // виконується неявна конвертація в тип double else return static_cast<double>(x) / y; } }; std::cout << divide(3, 2, true) << '\n'; std::cout << divide(3, 2, false) << '\n'; return 0; } |
Таким чином, якщо ви коли-небудь вирішите змінити тип значення, що повертається, вам (як правило) потрібно буде змінити тільки тип значення, що повертається, лямбди і нічого всередині основної частини.
Функціональні об’єкти Стандартної бібліотеки С++
Для основних операцій (наприклад, додавання, віднімання або порівняння) вам не потрібно писати свої власні лямбди, тому що Стандартна бібліотека С++ поставляється з багатьма базовими об’єктами, що викликаються, які ви можете використовувати. Ці об’єкти визначені в заголовку functional. Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include <algorithm> #include <array> #include <iostream> bool greater(int a, int b) { // Розміщуємо a перед b, якщо a більше b return (a > b); } int main() { std::array arr{ 13, 90, 99, 5, 40, 80 }; // Передаємо greater в якості параметру в std::sort() std::sort(arr.begin(), arr.end(), greater); for (int i : arr) { std::cout << i << ' '; } std::cout << '\n'; return 0; } |
Результат виконання програми:
99 90 80 40 13 5
Замість конвертації функції greater() в лямбду, ми можемо використати std::greater
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#include <algorithm> #include <array> #include <iostream> #include <functional> // для std::greater int main() { std::array arr{ 13, 90, 99, 5, 40, 80 }; // Передаємо std::greater в якості параметру в std::sort() std::sort(arr.begin(), arr.end(), std::greater{}); // примітка: Потрібні фігурні дужки для створення об'єкта for (int i : arr) { std::cout << i << ' '; } std::cout << '\n'; return 0; } |
Результат виконання програми:
Висновки
Лямбда-вирази і бібліотека алгоритмів можуть видатися надто складними в порівнянні зі звичайними рішеннями, в яких використовуються цикли. Однак ця комбінація дозволяє виконувати деякі дуже потужні операції за всього лише декілька рядків коду і це може бути куди читабельнішим, ніж ваші «самописні» цикли. Крім того, бібліотека алгоритмів володіє потужним і простим у використанні паралелізмом, який ви не отримаєте при використанні циклів. Оновлення вихідного коду, який використовує бібліотечні функції, є простішим, ніж оновлення коду, який використовує цикли.
Лямбди прекрасні, але вони не замінюють звичайні функції для всіх ситуацій. Використовуйте звичайні функції для нетривіальних випадків.
Тест
Завдання №1
Створіть структуру Student
, яка буде зберігати ім’я і бали студента. Створіть масив студентів і використайте функцію std::max_element() для пошуку студента з найбільшими балами, а потім виведіть на екран ім’я знайденого студента. Функція std::max_element() приймає begin
і end
списку, і функцію з двома параметрами, яка повертає true
, якщо перший аргумент менше другого.
При використанні наступного масиву:
1 2 3 4 5 6 7 8 9 10 |
std::array<Student, 8> arr{ { { "Albert", 3 }, { "Ben", 5 }, { "Christine", 2 }, { "Dan", 8 }, // Dan має найбільше балів (8) { "Enchilada", 4 }, { "Francis", 1 }, { "Greg", 3 }, { "Hagrid", 5 } } }; |
Результатом виконання вашої програми повинно бути наступне:
Dan is the best student
Показати підказку
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
#include <array> #include <iostream> #include <string> struct Student { std::string name{}; int points{}; }; int main() { std::array<Student, 8> arr{ { { "Albert", 3 }, { "Ben", 5 }, { "Christine", 2 }, { "Dan", 8 }, { "Enchilada", 4 }, { "Francis", 1 }, { "Greg", 3 }, { "Hagrid", 5 } } }; auto best{ std::max_element(arr.begin(), arr.end(), /* лямбда */) }; std::cout << best->name << " is the best student\n"; return 0; } |
Відповідь №1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
#include <array> #include <iostream> #include <string> struct Student { std::string name{}; int points{}; }; int main() { std::array<Student, 8> arr{ { { "Albert", 3 }, { "Ben", 5 }, { "Christine", 2 }, { "Dan", 8 }, { "Enchilada", 4 }, { "Francis", 1 }, { "Greg", 3 }, { "Hagrid", 5 } } }; auto best{ std::max_element(arr.begin(), arr.end(), [](const auto& a, const auto& b) { return (a.points < b.points); }) }; std::cout << best->name << " is the best student\n"; return 0; } |
Завдання №2
Використовуйте std::sort() і лямбду в наступному коді для сортування пір року по зростанню середньої температури:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> struct Season { std::string_view name{}; double averageTemperature{}; }; int main() { std::array<Season, 4> seasons{ { { "Spring", 285.0 }, { "Summer", 296.0 }, { "Fall", 288.0 }, { "Winter", 263.0 } } }; /* * Використовуйте std::sort() тут */ for (const auto& season : seasons) { std::cout << season.name << '\n'; } return 0; } |
Результатом виконання вашої програми повинно бути наступне:
Winter
Spring
Fall
Summer
Відповідь №2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <algorithm> #include <array> #include <iostream> #include <string_view> struct Season { std::string_view name{}; double averageTemperature{}; }; int main() { std::array<Season, 4> seasons{ { { "Spring", 285.0 }, { "Summer", 296.0 }, { "Fall", 288.0 }, { "Winter", 263.0 } } }; // Лямбді не потрібно навіть нічого захоплювати, тому що порядок залежить тільки від елементів масиву, які ми отримуємо в якості параметрів. // Ми можемо порівняти averageTemperature двох аргументів для сортування масиву std::sort(seasons.begin(), seasons.end(), [](const auto& a, const auto& b) { return (a.averageTemperature < b.averageTemperature); }); for (const auto& season : seasons) { std::cout << season.name << '\n'; } return 0; } |