1. Вступ
Коли ви лише починаєте програмувати, здається, що типи — це просто формальність: «ну що там, int і string». А потім ви пишете невелику, але корисну перевірку й раптом хочете застосовувати її до різних типів: до int, до double, до std::string, до std::vector<int>.
Generic-лямбди — це компроміс: на вигляд майже магія, а на практиці — дуже прагматичне рішення. Ви пишете правило один раз, а компілятор «підлаштовує» його під конкретний тип аргументу під час компіляції.
Можете уявити це так: ви не «скасовуєте типи», а просто кажете компілятору: «Ось місце, де тип можна вивести з аргументу. Я не хочу писати його вручну.»
2. Синтаксис: auto у параметрах
Коли ви пишете звичайну лямбду, параметри мають такий самий вигляд, як і в звичайної функції:
auto sum = [](int a, int b) { return a + b; };
А generic-лямбда використовує auto просто в параметрах:
auto twice = [](auto x) { return x + x; };
Ідея проста: twice можна викликати з різними типами, і щоразу тип x буде «підлаштовано» під аргумент. Важливо розуміти обмеження: лямбда «універсальна» лише там, де операції всередині її тіла мають сенс. Якщо всередині ви пишете x.size(), то викликати її з int уже не можна — у int немає size().
Мініприклад: «подвоїти» для числа й для рядка
#include <iostream>
#include <string>
int main() {
auto twice = [](auto x) { return x + x; };
std::cout << twice(7) << '\n'; // 14
std::cout << twice(std::string{"ha"}) << '\n'; // haha
}
Тут один і той самий код працює, бо і для int, і для std::string визначено оператор + (для рядка він означає конкатенацію).
Мініприклад: generic-лямбда — це все ще вираз
#include <iostream>
int main() {
int result = [](auto x) { return x * x; }(6);
std::cout << result << '\n'; // 36
}
Так, її можна створити й одразу викликати. Це корисно для невеликих обчислень, коли ви не хочете захаращувати область видимості окремим імʼям.
3. «Мʼякий шаблон»: як це читати без знання шаблонів
Слово «шаблон» звучить як щось із майбутнього, де компілятор свариться на 200 рядків і вимагає жертвопринесення у вигляді typename. Сьогодні туди не йдемо. Але інтуїцію все одно треба вибудувати, інакше auto у параметрах виглядатиме як магія.
Правильна побутова модель така: generic-лямбда — це наче набір перевантажень, який компілятор створює сам. Ви написали одну лямбду, а компілятор робить «версію для int», «версію для double», «версію для std::string» — але лише тоді, коли ви справді викликаєте лямбду з такими типами.
Можете уявити це у вигляді схеми:
flowchart TD
A["Ви пишете: [](auto x){ body }"] --> B["Викликаєте: f(аргумент)"]
B --> C["Компілятор виводить тип параметра з аргументу"]
C --> D["Перевіряє, чи body компілюється для цього типу"]
D --> E["Якщо так — збирає програму"]
D --> F["Якщо ні — помилка компіляції в місці виклику"]
Зверніть увагу на останній блок: помилка часто зʼявляється саме під час виклику, а не під час оголошення. І це нормально: доки ви не викликали лямбду, компілятор не зобовʼязаний доводити, що вона працює для всіх типів на світі.
Чому generic-лямбда інколи «раптом не компілюється»
Є тонкий психологічний нюанс: слово «generic» провокує очікування «працює з усім». Але в C++ «універсальність» майже завжди означає «універсально для тих типів, які підтримують потрібні операції».
Якщо ваша лямбда робить x + x, то вона працює для int і std::string, але не зобовʼязана працювати для Task. Якщо вона робить obj.size(), вона працює для std::string і std::vector, але не зобовʼязана працювати для double.
У цьому сенсі generic-лямбда схожа на «універсальний ключ», який підходить до всіх дверей за умови, що в усіх дверей однаковий замок. Щойно ви вставили такий ключ у домофон — а це взагалі не двері, — дивуватися не варто.
Найкорисніша звичка для новачка тут — коли компілятор свариться на generic-лямбду, зосереджуватися не на «страшному тексті», а на двох запитаннях:
- По-перше, з яким типом ви насправді викликаєте лямбду?
- По-друге, яка саме операція всередині тіла не має сенсу для цього типу?
4. Найважливіше на практиці: auto vs auto& vs const auto&
У параметрах generic-лямбди ви обираєте не лише «універсальність», а й спосіб передавання: копія чи посилання. І це впливає на продуктивність, коректність і зручність виклику.
Одразу домовимося: тут немає «єдиного правильного» варіанта. Є базовий варіант і є варіанти для конкретної ситуації. Важливо лише, щоб ви розуміли наслідки.
Таблиця-інтуїція
| Параметр лямбди | Що відбувається | Коли зручно | Типовий ризик |
|---|---|---|---|
|
створюється копія | коли обʼєкт маленький (числа) або копія потрібна навмисно | можна випадково копіювати std::string/std::vector |
|
посилання на оригінал | коли потрібно змінювати аргумент | не можна викликати з тимчасовим значенням, легко випадково змінити те, чого ви не хотіли |
|
посилання на оригінал, але без змін | коли потрібно «читати без копій» (дуже частий випадок) | не можна змінювати x усередині, і це добре |
Тепер закріпімо це короткими прикладами.
auto x: передавання за значенням
Іноді копія — це нормально. Іноді це навіть зручно: ви захищені від зовнішніх змін.
#include <iostream>
#include <string>
int main() {
auto show_twice = [](auto x) {
std::cout << x << " | " << x << '\n';
};
std::string s = "cat";
show_twice(s); // cat | cat
}
Тут рядок буде скопійовано. Для невеликих рядків це не страшно, але в загальному випадку краще памʼятати: std::string може бути «важким».
const auto& x: читати без копій
Якщо ваша лямбда нічого не змінює, а лише читає, const auto& зазвичай є найпрактичнішим вибором.
#include <iostream>
#include <string>
#include <vector>
int main() {
auto print_size = [](const auto& obj) {
std::cout << obj.size() << '\n';
};
std::string s = "hello";
std::vector<int> v{1, 2, 3};
print_size(s); // 5
print_size(v); // 3
}
Це саме той «мʼякий шаблон»: один і той самий код застосувався до різних типів, бо в обох є метод size().
auto& x: коли потрібно змінювати аргумент
Якщо ви хочете змінити те, що вам передали, використовуйте auto&. Але робіть це усвідомлено, бо ви справді змінюєте вихідний обʼєкт.
#include <iostream>
#include <string>
int main() {
auto add_excl = [](auto& text) {
text += '!';
};
std::string s = "Hi";
add_excl(s);
std::cout << s << '\n'; // Hi!
}
Тут лямбда не «для всього», а «для всього, що підтримує += '!'». Але головне тут інше: ви обрали саме посилання, бо хочете змінювати оригінал.
Тимчасові значення і auto&: чому f(10) не працює
Це типова пастка, на яку натрапляють навіть ті, хто «загалом уже все зрозумів».
Якщо у вас:
auto f = [](auto& x) { /*...*/ };
то так викликати не можна:
f(10); // 10 — тимчасове значення, воно не має “живого оригіналу”
А от так — можна:
int a = 10;
f(a);
const auto& у цьому сенсі гнучкіший: він може «привʼязатися» і до тимчасового значення теж, якщо йдеться лише про читання. Це одна з причин, чому const auto& — такий популярний параметр у предикатів і функцій у стилі «подивитися, але не чіпати».
Як обирати auto/auto&/const auto& без ворожіння
Зараз буде важливий практичний принцип, який заощаджує години. Коли студент не впевнений, він зазвичай або ставить усюди auto, або всюди auto&, а потім дивується, чому щось копіюється або не викликається.
Зазвичай варто починати з такого правила:
- Якщо лямбда має лише читати аргумент і аргумент потенційно може бути «важким» (рядок, вектор, структура), то const auto& — хороший стартовий варіант.
- Якщо лямбда має змінювати аргумент, то auto& — правильніше, бо це чесний контракт: «я змінюю те, що мені дали».
- Якщо лямбда працює з маленькими значеннями (числа) або вам навмисно потрібна копія, тоді auto за значенням — цілком нормально.
Щоб це не звучало надто абстрактно, порівняймо поведінку на одному й тому самому обʼєкті.
#include <iostream>
#include <string>
int main() {
std::string s = "A";
auto by_value = [](auto x) { x += "B"; };
auto by_ref = [](auto& x) { x += "B"; };
by_value(s);
std::cout << s << '\n'; // A
by_ref(s);
std::cout << s << '\n'; // AB
}
Один і той самий «код зовні» запускає дві різні політики: у першому випадку ви працювали з копією, у другому — змінили оригінал.
5. Практика: generic-лямбди в навчальному міні-застосунку
Щоб приклади не висіли в повітрі, продовжимо маленьку консольну модель застосунку, яку ми поступово розвиваємо: список задач.
Ви вже вмієте зберігати дані в std::vector, у вас є struct, ви вмієте друкувати й писати прості функції. Сьогодні розглянемо кілька універсальних «утиліт» на generic-лямбдах — таких, що не залежать від конкретного типу задачі.
Модель даних: Task
#include <string>
struct Task {
std::string title;
int priority{};
bool done{};
};
(Так, це лише 3 поля. І так, це нормально: великі системи теж починаються з трьох полів і легкого відчуття тривоги.)
Універсальна перевірка «чи порожньо» для контейнерів
Коли ви працюєте з рядками й векторами, ви постійно питаєте: «там узагалі щось є?». Це один із найкращих прикладів для generic-лямбди.
#include <iostream>
#include <string>
#include <vector>
int main() {
auto is_empty = [](const auto& c) { return c.empty(); };
std::string s = "";
std::vector<int> v{1, 2};
std::cout << is_empty(s) << '\n'; // 1
std::cout << is_empty(v) << '\n'; // 0
}
Зверніть увагу: це працює лише для типів із методом empty(). І це чесно: ви ж не хочете, щоб is_empty(10) «якось працювало». Нехай краще не компілюється.
Універсальний друк «усього, що можна надрукувати»
Друк — вічний супутник новачка й тимчасова заміна дебагера. Якщо тип можна вивести в std::cout, generic-лямбда чудово для цього підходить.
#include <iostream>
#include <string>
int main() {
auto print_line = [](const auto& x) {
std::cout << x << '\n';
};
print_line(123); // 123
print_line(std::string{"ok"}); // ok
}
Важливо: нашу Task поки що не можна вивести через << (перевантаження операторів ми розглядатимемо значно пізніше). Тому print_line(task) зараз не спрацює — і це очікувано.
Універсальне «обмеження діапазоном»: ідея clamp без складнощів
Іноді потрібно обмежити число певними межами: менше за мінімум — зробити мінімум, більше за максимум — зробити максимум. Пишеться це однаково і для int, і для double, і це хороший приклад «мʼякої універсальності».
#include <iostream>
int main() {
auto clamp = [](auto x, auto lo, auto hi) {
if (x < lo) return lo;
if (hi < x) return hi;
return x;
};
std::cout << clamp(15, 0, 10) << '\n'; // 10
std::cout << clamp(2.5, 0.0, 10.0) << '\n'; // 2.5
}
Зауважте: тут параметри передаються за значенням (auto x), бо числа маленькі, і ми не збираємося змінювати їх «ззовні». А от якби ви працювали з рядками або векторами, тоді вже варто було б подумати про const auto&.
6. Типові помилки
Помилка № 1: писати auto x і випадково копіювати великі обʼєкти.
Це дуже поширена історія: лямбда «просто перевіряє рядок», але параметр передається за значенням, і рядок копіюється на кожен виклик. На маленьких тестах ви цього не помітите, а потім здивуєтеся: «чому повільно». Якщо ви не хочете змінювати обʼєкт і не хочете зайвих копій, const auto& зазвичай безпечніше.
Помилка № 2: писати auto& x, а потім намагатися викликати лямбду з тимчасовим значенням.
Наприклад, f(10) або f(std::string{"tmp"}). Посилання auto& має привʼязатися до наявного обʼєкта, а тимчасове значення — це «одноразова штука». Якщо потрібно лише читати, використовуйте const auto&. Якщо потрібно змінювати — передавайте змінну, а не тимчасове значення.
Помилка № 3: вірити, що generic-лямбда «підходить для всього», і забувати про вимоги тіла.
Лямбда [](const auto& x){ return x.size(); } не є «універсальною» — вона універсальна лише для типів із size(). Якщо ви викликаєте її з int, помилка цілком справедлива. Правильна реакція тут не «зламався компілятор», а «я викликав не з тим типом».
Помилка № 4: змішувати типи так, що логіка стає нечіткою.
Наприклад, clamp(5, 0.0, 10.0) — це вже неоднозначна ситуація: типи різні, і компілятор починає добирати спільні правила. Новачку потім складно зрозуміти, чому результат став double (або чому порівняння поводяться несподівано). На старті курсу краще тримати аргументи в межах одного «сімейства» типів: int/int/int або double/double/double.
Помилка № 5: робити generic-лямбду надто «розумною» і довгою.
Якщо всередині 30 рядків, 5 розгалужень і 3 рівні вкладеності, то це вже не «лямбда на місці», а функція, якій просто не дали імʼя. У такій ситуації читабельність падає, а помилки компіляції стають значно менш зрозумілими. Тримайте generic-лямбди короткими: зазвичай це 1–5 рядків.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ