JavaRush /Курсы /C++ SELF /Generic lambdas: []( ...

Generic lambdas: []( auto & x){...} как “мягкий шаблон”

C++ SELF
22 уровень , 2 лекция
Открыта

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-лямбды вы выбираете не только «универсальность», но и способ передачи: копия или ссылка. И это влияет и на производительность, и на корректность, и на удобство вызова.

Сразу договоримся: здесь нет «единственно правильного» варианта. Есть вариант «по умолчанию» и варианты «по ситуации». Но важно, чтобы вы умели читать последствия.

Таблица-интуиция

Параметр лямбды Что происходит Когда удобно Типичный риск
auto x
создаётся копия когда объект маленький (числа), или копия нужна специально можно случайно копировать std::string/std::vector
auto& x
ссылка на оригинал когда нужно изменять аргумент нельзя вызвать временным значением, легко случайно изменить то, что не хотели
const auto& x
ссылка на оригинал, но без изменения когда нужно «читать без копий» (очень частый случай) нельзя менять 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 строк.

1
Задача
C++ SELF, 22 уровень, 2 лекция
Недоступна
Двойной эффект
Двойной эффект
1
Задача
C++ SELF, 22 уровень, 2 лекция
Недоступна
Измеритель размера
Измеритель размера
1
Задача
C++ SELF, 22 уровень, 2 лекция
Недоступна
Ограничитель значений
Ограничитель значений
1
Задача
C++ SELF, 22 уровень, 2 лекция
Недоступна
Поиск пустоты
Поиск пустоты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ