Программирование C++Современный C++Разработчик C++ системного программного обеспечения

При проектировании очереди одноразовых задач, где callback может быть некопируемым, какую стандартную обёрт...

При проектировании очереди одноразовых задач, где callback может быть некопируемым, какую стандартную обёртку C++23 выбрать вместо std::function и почему?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Выберите std::move_only_function: она стирает тип вызываемого объекта, но сама перемещается, а не копируется. Поэтому очередь может хранить callback с некопируемым состоянием, например объектом, владеющим ресурсом или представляющим одноразовую операцию.

std::function для этого не подходит: её целевой объект должен быть копируемым, поскольку сама обёртка поддерживает копирование.

Исторический контекст

В C++11 появилась std::function как типобезопасная type-erasure-обёртка для хранения вызываемых объектов разных типов в одном интерфейсе. Её копируемость была удобна для обычных callback, но стала ограничением для перемещаемых задач.

В современном C++ появились вызываемые объекты, которые намеренно нельзя копировать: например, одноразовые задачи, объекты синхронизации и операции с уникальным владением. std::move_only_function стандартизирована в C++23 для такого сценария.

Постановка проблемы

Очередь задач обычно должна принимать callback от вызывающего кода и хранить его до момента выполнения. Если callback содержит некопируемое состояние, попытка передать его в std::function приводит к ошибке компиляции.

Обходной путь через std::shared_ptr делает состояние копируемым, но меняет модель владения: задача уже не обязательно единолично владеет своим состоянием. Шаблонная функция может принимать любой callable без type erasure, однако тогда тип callback становится частью типа очереди или конкретного экземпляра алгоритма.

Подробное решение

std::move_only_function использует стирание типа, поэтому очередь может иметь единый тип элемента, независимо от конкретного callable. При перемещении обёртки владение внутренним callable передаётся новому объекту; копирование запрещено.

#include <functional> #include <utility> #include <vector> struct Job { int id; Job(int value) : id(value) {} Job(const Job&) = delete; Job(Job&&) = default; void operator()() { /* выполнить задачу id */ } }; int main() { std::vector<std::move_only_function<void()>> queue; queue.emplace_back(Job{42}); auto task = std::move(queue.front()); task(); }

Тип std::move_only_function<void()> задаёт вызываемую сигнатуру. В C++23 сигнатура может также фиксировать квалификаторы const, ref-квалификаторы и noexcept, поэтому требования к вызову выражаются непосредственно в типе обёртки.

Пустая std::move_only_function не должна вызываться: такое выполнение имеет неопределённое поведение. Это отличается от пустой std::function, вызов которой приводит к std::bad_function_call; перед вызовом нужно самостоятельно проверять состояние обёртки.

У std::move_only_function есть цена type erasure: возможны косвенный вызов и выделение памяти, зависящие от реализации и размера callable. Если конкретный тип callback известен на этапе компиляции, шаблонный интерфейс часто быстрее и позволяет лучше оптимизировать код, но не подходит для гетерогенного контейнера без дополнительной техники.

Ситуация из практики

Сервис отложенного выполнения принимает задачи, содержащие одноразовые ресурсы. На первом варианте использовали std::function<void()>, но задачи с некопируемым состоянием не компилировались.

Рассматривались три решения:

  • std::function с std::shared_ptr состояния: совместимо с копированием, но добавляет разделяемое владение и лишнюю косвенность;
  • шаблонная очередь: сохраняет типы и может хорошо оптимизироваться, но не позволяет просто хранить задачи разных типов в одном контейнере;
  • std::move_only_function: сохраняет type erasure и единый тип элемента, запрещая случайное копирование задачи.

Выбран третий вариант. Он соответствует одноразовой семантике задачи: постановка в очередь перемещает callback, а выполнение не требует его копирования. Дополнительно интерфейс явно показывает потребителям API, что callback должен передаваться с передачей владения.

Что кандидаты часто упускают

  1. Достаточно ли передать некопируемый callable в std::move_only_function по ссылке?

Нет. Ссылка сама по себе не решает задачу хранения: обёртка должна либо владеть callable, либо ссылаться на объект с гарантированным временем жизни. При инициализации из временного некопируемого callable владение обычно перемещается внутрь std::move_only_function; при передаче ссылки можно получить callback, который станет висячим после уничтожения исходного объекта.

  1. Что произойдёт с исходной обёрткой после её перемещения?

Она остаётся корректным объектом, но её состояние после перемещения не следует трактовать как конкретное содержимое. Нельзя полагаться на то, что она обязательно пуста, если это прямо не гарантировано используемым контрактом; безопасно проверять состояние перед вызовом и не использовать перемещённый-from объект как прежнюю задачу.

  1. Когда лучше использовать шаблонный callback, а не std::move_only_function?

Шаблонный параметр подходит, когда тип callable известен в месте вызова, а type erasure не нужен. Такой подход устраняет косвенный вызов и часто даёт компилятору больше возможностей для инлайнинга, но разные типы задач нельзя напрямую поместить в один контейнер.

Если требуется гетерогенная очередь, стабильный ABI-подобный интерфейс или передача callback через границы модулей, std::move_only_function практичнее. Компромисс — возможные накладные расходы type erasure и необходимость явно учитывать неопределённое поведение при вызове пустой обёртки.