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
| Что храним | Пример типа | Можно захватывать переменные? | Можно хранить разные реализации в одной переменной? | Комментарий |
|---|---|---|---|---|
| Лямбда конкретного типа | |
Да | Нет | Самый лёгкий вариант, но тип фиксирован |
| Указатель на функцию | |
Нет | Да, но только функции/лямбды без захватов | Простой и быстрый, но без захватов |
|
|
Да | Да | Самый гибкий из трёх, но дороже |
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 становится тяжелее, а иногда он ещё и выделит память. Захватывайте по значению только то, что действительно хотите «зафиксировать как настройку», и старайтесь, чтобы это было небольшим.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ