JavaRush /Курси /C++ SELF /std::function — коли ...

std::function — коли він потрібен і якою є його ціна

C++ SELF
Рівень 22 , Лекція 3
Відкрита

1. Навіщо взагалі потрібен std::function

До std::function зазвичай доходять не тому, що «хочеться краси», а тому, що компілятор укотре каже: «Ні, так не можна». І це нормально. Компілятор не лиходій — він просто не хоче вгадувати, який саме тип ви мали на увазі.

Уявіть: ви написали два різні правила обробки даних. Вони приймають однакові параметри й повертають той самий тип, але записані по-різному. Наприклад, одне — лямбда, інше — звичайна функція. Для людини це «той самий інтерфейс», а для C++ — різні сутності.

Ключова проблема така: кожна лямбда має власний тип, навіть якщо дві лямбди виглядають однаково «за змістом». Тому змінна auto може зберігати лише один конкретний тип лямбди, а замінити її іншою лямбдою зазвичай не вдасться.

Ось невелика демонстрація цього «болю»:

#include <iostream>

int main() {
    auto f = [](int x) { return x + 1; };

    // f = [](int x) { return x * 2; }; // <- помилка компіляції: інший тип лямбди

    std::cout << f(10) << '\n'; // 11
}

Чому так? Тому що auto f = ... вивело тип конкретної лямбди (її closure type), і далі f має залишатися саме цього типу.

Що таке std::function і яку задачу він розвʼязує

Коли ми кажемо «хочу зберігати щось викличне», то насправді маємо на увазі контейнер для поведінки: «ось сигнатура, а реалізація може бути різною». Саме це й робить std::function.

std::function<R(Args...)> — це обʼєкт-обгортка, який може зберігати всередині різні викличні сутності (callable) з однією й тією самою сигнатурою: звичайні функції, лямбди, зокрема із захопленнями, та інші функтори. Усередині він приховує конкретний тип і дає вам єдиний інтерфейс operator().

Найважливіше на цьому етапі таке: std::function потрібен тоді, коли ви хочете мати один тип змінної для різних реалізацій callable з однаковою сигнатурою.

Базовий синтаксис: оголошення, присвоювання, виклик

Синтаксис у std::function спочатку може здаватися трохи «канцелярським», але він доволі прямолінійний: ви буквально задаєте контракт виклику.

Вам майже завжди знадобиться:

  • #include <functional>
  • запис сигнатури: std::function<повернення(параметри...)>

Мініприклад: зберігаємо «щось, що приймає int і повертає int»:

#include <functional>
#include <iostream>

int main() {
    std::function<int(int)> f = [](int x) { return x + 1; };

    std::cout << f(10) << '\n'; // 11

    f = [](int x) { return x * 2; };
    std::cout << f(10) << '\n'; // 20
}

Ось у чому практична користь: змінна f одна, тип у неї теж один — std::function<int(int)>, а реалізацію можна змінювати як завгодно.

std::function може бути порожнім

У std::function є «порожній» стан: він може не зберігати жодної функції. Це зручно, наприклад, коли колбек опціональний, але небезпечно, якщо ви забули зробити перевірку.

#include <functional>
#include <iostream>

int main() {
    std::function<void()> action; // порожньо

    if (!action) {
        std::cout << "action is empty\n"; // action is empty
    }

    action = [] { std::cout << "run\n"; };

    if (action) {
        action(); // run
    }
}

Якщо викликати порожній std::function, виникне помилка під час виконання. Зазвичай це виняток std::bad_function_call. Поки ми не обговорюємо try/catch, ваш основний захист — перевірка на порожнечу: if (f) { f(...); }.

2. Коли використовувати і скільки це коштує

Коли std::function справді потрібен

Дуже легко «закохатися» в std::function і почати використовувати його всюди «про всяк випадок». Це додає гнучкості, але іноді — ще й зайвої складності та накладних витрат. Тому корисно розрізняти типові ситуації.

Обираємо поведінку під час виконання

Наприклад, у вас є налаштування: «сортувати за довжиною» або «сортувати лексикографічно». Ви хочете зберегти вибране правило в змінній і застосувати його пізніше.

З auto ви впираєтеся в «різні типи лямбд». З std::function — усе гаразд:

#include <functional>
#include <iostream>
#include <string>

int main() {
    bool by_length = true;

    std::function<bool(const std::string&, const std::string&)> cmp;

    if (by_length) {
        cmp = [](const std::string& a, const std::string& b) { return a.size() < b.size(); };
    } else {
        cmp = [](const std::string& a, const std::string& b) { return a < b; };
    }

    std::cout << cmp("cat", "horse") << '\n'; // 1 (true)
}

Зручно повертати callable із функції

Іноді хочеться написати фабрику: «дай мені правило або фільтр за параметром». Повернути лямбду безпосередньо можна, але тип лямбди без auto у сигнатурі не запишете. std::function робить тип, що повертається, явним і зручним для API.

#include <functional>

std::function<bool(int)> make_is_big(int limit) {
    return [limit](int x) { return x >= limit; };
}

Тобто ми повертаємо «функцію-перевірку», яка зберігає всередині limit.

Коли std::function найчастіше зайвий

Якщо ви пишете:

std::sort(v.begin(), v.end(), [](int a, int b){ return a < b; });

то не потрібно обгортати компаратор у std::function. Алгоритми чудово приймають лямбди безпосередньо, а компілятор зазвичай краще оптимізує такі виклики.

Проста евристика: якщо callable живе лише в межах одного рядка і вам не потрібно зберігати його в змінній «на потім», std::function зазвичай не потрібен.

Яка ціна у std::function

std::function — не «безплатний поліморфізм». Він розвʼязує задачу, але бере за це свою плату.

Ціна № 1: прихована обгортка й непрямий виклик

Коли лямбда зберігається в auto, компілятор знає її точний тип і часто може «вбудувати» виклик (inline) та агресивніше його оптимізувати.

Коли callable захований усередині std::function, виклик зазвичай відбувається через додатковий рівень непрямості, тобто за принципом «виклич те, що лежить усередині обгортки».

Ціна № 2: можливі виділення памʼяті

Деякі callable невеликі, наприклад лямбда без захоплень, і можуть зберігатися всередині std::function без динамічної памʼяті. Але якщо callable великий, наприклад ви захопили великий обʼєкт за значенням, std::function може виділяти памʼять.

На цьому етапі важливіше не запамʼятати «точні правила», а зрозуміти загальну ідею: std::function може бути важчим, ніж здається.

Ціна № 3: копіювання може бути дорогим

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

Мінітаблиця: auto vs вказівник на функцію vs std::function

Що зберігаємо Приклад типу Чи можна захоплювати змінні? Чи можна зберігати різні реалізації в одній змінній? Коментар
Лямбда конкретного типу
auto f = []{...};
Так Ні Найлегший варіант, але тип фіксований
Вказівник на функцію
int (*pf)(int)
Ні Так, але лише функції й лямбди без захоплень Простий і швидкий варіант, але без захоплень
std::function
std::function<int(int)>
Так Так Найгнучкіший із трьох, але дорожчий

3. Практичний приклад: міні-диспетчер команд

Тепер зробимо практичну річ, яка добре показує, навіщо потрібен std::function: побудуємо таблицю команд, де кожна команда — це імʼя та обробник. Обробники будуть різними, наприклад лямбдами із захопленнями, але ми хочемо зберігати їх в одному контейнері.

Уявімо, що маємо просту модель задачі:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Тепер визначимо «команду» як пару: імʼя + обробник. Нехай обробник приймає список задач і аргумент команди, наприклад рядок після команди:

#include <functional>
#include <string>
#include <string_view>
#include <vector>

struct Command {
    std::string name;
    std::function<void(std::vector<Task>&, std::string_view)> run;
};

Зверніть увагу: саме тут std::function особливо зручний. Ми хочемо, щоб run мав один і той самий тип для всіх команд, хоча реалізації будуть різними.

Найпростіша команда: list

Зробимо функцію друку, поки без особливої краси:

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const auto& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.id << ": " << t.title << '\n';
    }
}

Тепер створимо команду list за допомогою лямбди без захоплення:

Command make_list_command() {
    return Command{
        "list",
        [](std::vector<Task>& tasks, std::string_view) {
            print_tasks(tasks);
        }
    };
}

Команда із захопленням: add, де є «наступний id»

Щоб додавати задачі, потрібен лічильник next_id. Його можна зберігати зовні й захоплювати за посиланням. Це хороший приклад, але тут одразу треба памʼятати про час життя: next_id має жити довше за команду.

Command make_add_command(int& next_id) {
    return Command{
        "add",
        [&next_id](std::vector<Task>& tasks, std::string_view arg) {
            tasks.push_back(Task{next_id, std::string(arg), false});
            ++next_id;
        }
    };
}

Пошук команди і запуск

Зберемо команди у вектор і знайдемо потрібну за імʼям за допомогою std::find_if.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;
    int next_id = 1;

    std::vector<Command> cmds;
    cmds.push_back(make_list_command());
    cmds.push_back(make_add_command(next_id));

    std::string cmd = "add";
    std::string arg = "Buy milk";

    auto it = std::find_if(cmds.begin(), cmds.end(),
                           [&](const Command& c) { return c.name == cmd; });

    if (it != cmds.end() && it->run) {
        it->run(tasks, arg);
    }

    print_tasks(tasks); // [ ] 1: Buy milk
}

Тут є важлива деталь: it->run — це std::function, і ми перевіряємо його на порожнечу. У нашому коді він точно не порожній, але така звичка корисна.

4. Типові помилки під час роботи зі std::function

Помилка № 1: «А давайте всюди ставити std::function, він же універсальний».
Універсальність — не завжди плюс. Якщо callable не треба зберігати «на потім» і його використовують один раз, особливо всередині std::sort/std::find_if/std::transform, то std::function часто лише додає накладні витрати й ускладнює читання. У таких місцях лямбда безпосередньо зазвичай зрозуміліша: правило видно поруч із місцем використання.

Помилка № 2: неправильна сигнатура std::function.
Часта ситуація: пишуть std::function<void(int)>, а потім намагаються присвоїти туди лямбду, яка приймає const int& або повертає bool. Формально це вже інший контракт. Корисна звичка така: спочатку сформулюйте словами, «що приймає і що повертає», а вже потім записуйте R(Args...).

Помилка № 3: виклик порожнього std::function.
std::function може бути «порожнім» — це нормальний стан. Але якщо викликати такий обʼєкт як функцію, виникне помилка під час виконання. Зазвичай це виняток, який без обробки призводить до завершення програми. Поки ми не вивчили try/catch, ваш основний захист — перевірка if (f) { f(...); }.

Помилка № 4: захоплення за посиланням там, де час життя не гарантований.
Легко зробити команду, яка захопила посилання на локальну змінну, а потім ви поклали цю команду в контейнер і використовуєте її пізніше. Якщо змінна вже «померла», поведінка програми буде некоректною. У нашому прикладі з make_add_command(int& next_id) важливо, щоб next_id жив довше за команди. Якщо не впевнені — захоплюйте за значенням або продумайте архітектуру зберігання стану.

Помилка № 5: зберігати в std::function надто важкі захоплення без розуміння ціни.
Якщо ви захоплюєте в лямбду великий std::string або цілий std::vector за значенням, це захоплення опиниться всередині std::function. Копіювання чи переміщення такого std::function стає важчим, а іноді він ще й виділяє памʼять. Захоплюйте за значенням лише те, що справді хочете «зафіксувати як налаштування», і намагайтеся, щоб це було щось невелике.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ