25 лет книге Александреску

Книга Андрея Александреску «Modern C++ Design» (2001) оказала глубокое влияние на развитие C++, но ее влияние на стандарты проявлялось не в прямом заимствовании готового кода, а через демонстрацию новых возможностей шаблонов и формирование запроса сообщества на языковые средства, которые сделали бы эти техники более удобными и безопасными.

Сам термин «современный C++» (modern C++) сегодня часто ассоциируется именно со стилем, который популяризировала эта книга . Вот основные направления влияния.

Формирование «Современного C++» как парадигмы

Книга показала, что шаблоны C++ — это не просто механизм для контейнеров (как в STL), а полноценный язык метапрограммирования, позволяющий генерировать высокоэффективный и гибкий код на этапе компиляции . Идея policy-based design (проектирование на основе стратегий), представленная в книге, стала основой для создания компонентов, которые легко адаптируются к контексту использования .

Конкретные технические концепции, повлиявшие на стандарт

Хотя библиотека Loki (сопровождавшая книгу) не была напрямую включена в стандарт, многие ее идеи нашли отражение в C++11 и последующих стандартах в виде более безопасных и удобных языковых средств.

Концепция из книги / Loki Как повлияла на стандарты C++
Управление ресурсами и SmartPtr Книга показала ограниченность std::auto_ptr и предложила гибкие политики владения . Это подготовило почву для std::unique_ptr и std::shared_ptr (C++11), которые реализуют идеи RAII и семантики перемещения.
Семантика перемещения Статья Александреску «Mojo» (развитие идей книги) привлекла внимание сообщества к проблеме лишнего копирования. Это привело к работе Дэвида Абрахамса и Говарда Хиннанта, результатом которой стало появление rvalue-ссылок (&&) в C++11 .
ScopeGuard Идея автоматического выполнения действий при выходе из области видимости . Хотя полноценный ScopeGuard так и не вошел в стандарт, его дух жив в RAII и std::lock_guard, а также в библиотеке std::experimental::scope_guard (Library Fundamentals TS). Кроме того, потребность в реализации надежного ScopeGuard стимулировала добавление std::uncaught_exceptions() в C++17 .
Typelists (Списки типов) Механизм для манипуляции наборами типов на этапе компиляции . Прямой аналогии в стандарте нет, но эта идея является концептуальным предшественником вариативных шаблонов (variadic templates) и std::tuple.
Мультиметоды Реализация множественной диспетчеризации . В стандарт эта концепция в явном виде не вошла, но запрос на подобную функциональность частично удовлетворяется через std::variant и std::visit (C++17).

В текущей статье рассмотрим следующие концепции:

  1. RAII для указателей,
  2. Семантика перемещения,
  3. Автоматическое выполнение действий при выходе из области видимости,
  4. Манипуляции наборами типов на этапе компиляции,
  5. Mножественная диспетчеризация.

1. RAII для указателей

Уточню и раскрою тезис о auto_ptr и политиках владения подробнее. Ключевая проблема auto_ptr была не в самой идее уникального владения, а в отсутствии в языке семантики перемещения, из-за чего владение приходилось передавать через копирование, что порождало опасные побочные эффекты.

Фундаментальный изъян std::auto_ptr

auto_ptr появился в C++98 с благой целью — обеспечить базовый RAII для указателей. Однако он был спроектирован до появления rvalue-ссылок и семантики перемещения .

Чтобы реализовать передачу уникального владения (что по определению должно быть перемещением, а не копированием), auto_ptr использовал деструктивное копирование: конструктор копирования и оператор присваивания auto_ptr не копировали указатель, а передавали владение и обнуляли источник .

Это приводило к трем критическим проблемам:

  1. Фундаментальная небезопасность: Передача auto_ptr в функцию по значению «незаметно» уничтожала объект у вызывающего, что приводило к использованию памяти после освобождения (use-after-free) и неопределенному поведению .
  2. Несовместимость с STL: Поскольку алгоритмы и контейнеры STL активно используют копирование, auto_ptr нельзя было безопасно хранить в std::vector или std::list .
  3. Отсутствие гибкости: auto_ptr всегда использовал delete (а не delete[]) и не поддерживал пользовательские удалители (deleters), что исключало его применение для массивов или ресурсов, отличных от памяти .

По сути, auto_ptr — это «героическая попытка создать unique_ptr до того, как в C++ появилась семантика перемещения» . Он был удален из стандарта в C++17.

Вклад «Modern C++ Design»: политики владения

Александреску в книге не просто констатировал проблемы auto_ptr, а предложил архитектурное решение: параметризовать умный указатель политиками (policies) .

В Loki::SmartPtr можно было независимо выбирать несколько осей поведения :

  • OwnershipPolicy (Политика владения): Определяла, как управляется время жизни объекта. Loki предоставлял готовые реализации: RefCounted (подсчет ссылок), DeepCopy (глубокое копирование), DestructiveCopy (аналог auto_ptr), NoCopy (запрет копирования), COMRefCounted (для COM-объектов) и другие .
  • ConversionPolicy (Политика преобразований): Разрешала или запрещала неявные преобразования.
  • CheckingPolicy (Политика проверок): Определяла, как проверяется указатель (например, на nullptr).
  • StoragePolicy (Политика хранения): Определяла, как хранится сам указатель (например, для T* или для void*).

Это был конструктор умных указателей. Можно было собрать SmartPtr<Widget, UniqueOwnership, NoCheck, SingleThreaded> для скорости или SmartPtr<Widget, RefCounted, AssertCheck, MultiThreaded> для безопасности .

Как это повлияло на std::unique_ptr и std::shared_ptr

Стандартные умные указатели C++11 не являются копией Loki::SmartPtr с его политиками. Создатели стандарта пошли другим путем, изучив уроки auto_ptr и идей Александреску.

1. Появление семантики перемещения (rvalue-ссылок) решило главную проблему. Теперь передача владения могла быть явной и безопасной:

  • std::unique_ptr: Запрещает копирование (удалены конструктор копирования и присваивание). Владение передается только через std::move (перемещение). Это делает ошибки вроде «незаметной» потери владения ошибками компиляции, а не runtime-катастрофами .
  • std::shared_ptr: Реализует идею разделяемого владения через подсчет ссылок (аналог RefCounted из Loki). Копирование разрешено и увеличивает счетчик .

2. Гибкость через Deleter, а не через политики. Вместо того чтобы параметризовать весь класс политиками, std::unique_ptr получил второй шаблонный параметр — тип удалителя (Deleter). Это решило проблему поддержки массивов (unique_ptr<T[]>) и произвольных ресурсов, но сделало интерфейс проще, чем у Loki::SmartPtr .

3. Явное разделение ролей. Стандарт предложил не один «универсальный конструктор», а два четких инструмента:

  • unique_ptr — для исключительного владения (модель «нулевой стоимости»).
  • shared_ptr — для разделяемого владения.

Разработчики shared_ptr в Boost (где он изначально появился) прямо ссылались на книгу Александреску, но сознательно отказались от параметризации политиками, чтобы не усложнять интерфейс и обеспечить совместимость между библиотеками .

auto_ptr был обречен из-за отсутствия в языке подходящего механизма для перемещения. «Modern C++ Design» показала, как можно было бы абстрагировать управление ресурсами, предоставив максимальную гибкость через политики. Однако C++11 выбрал путь языковой поддержки (move-семантика) и специализированных, более простых инструментов (unique_ptr с кастомизируемым удалителем и shared_ptr с подсчетом ссылок), которые убрали необходимость в сложной политико-ориентированной архитектуре для большинства задач.

2. Семантика перемещения

Заслуга статьи Александреску «Mojo» заключается в том, что она впервые привлекла внимание сообщества C++ к проблеме «временных объектов, вызывающих ненужное копирование», и в качестве решения этой проблемы предложила концепцию «move constructor» (конструктор перемещения).

Эта идея напрямую вдохновила последующую работу David Abrahams, Howard Hinnant и других, которая в конечном итоге привела к появлению ключевой возможности C++11 — rvalue reference (ссылка на rvalue).

Что такое «Mojo»?

Mojo — это библиотека, реализованная Андреем Александреску, и одноименная статья, опубликованная им в 2003 году в Dr. Dobb's Journal. Она предлагала библиотечное решение для устранения ненужного копирования временных объектов.

Основные характеристики дизайна Mojo включали:

  • 100% переносимость: полное устранение копирования временных объектов (при соблюдении определенных правил).
  • Изменение сигнатур функций: возвращаемый тип должен быть изменён на mojo::fnresult<T>, а параметры должны следовать «протоколу mojo».
  • Непрозрачность: решение не было полностью прозрачным, что увеличивало сложность использования.

Ограничения Mojo и путь к «rvalue reference»

Хотя Mojo предложил новаторское решение, он имел очевидные недостатки, которые подробно обсуждались в сообществе:

  1. Нарушение RVO/NRVO: Mojo мешал компилятору выполнять оптимизацию возвращаемого значения (RVO) и оптимизацию именованного возвращаемого значения (NRVO), что были важными оптимизациями того времени.
  2. Неестественный синтаксис: использование Mojo требовало объявления функций в специальном стиле (например, void f(mojo::temporary<X> x)), что изменяло привычные сигнатуры функций и снижало читаемость кода.
  3. Несовместимость с обычными копируемыми классами: перемещаемые классы Mojo не могли быть «обычными копируемыми» классами; их конструктор копирования должен был принимать неконстантную ссылку на rvalue, что противоречило традиционным соглашениям C++.

В это время David Abrahams также экспериментировал со схожей техникой (которая позже стала известна как «ref» в некоторых обсуждениях), но в итоге сообщество пришло к выводу, что наиболее элегантное решение — это введение нового языкового механизма, а не попытки реализации исключительно средствами библиотеки.

Рождение «rvalue reference»

В 2002 году Howard Hinnant, Peter Dimov и David Abrahams совместно предложили документ N1377 «A Proposal to Add Move Semantics Support to the C++ Language», стремясь ввести поддержку семантики перемещения на уровне языка. В последующей серии работ, включая N1771 (влияние rvalue reference на стандартную библиотеку) и N2027 «A Brief Introduction to Rvalue References» (краткое введение в rvalue reference, написанное Hinnant, Stroustrup и Kozicki), эта концепция постепенно оформилась и была принята.

Итоговое решение C++11 элегантно решило проблемы, существовавшие в Mojo:

  • Новый синтаксис: добавлен синтаксис T&& для представления rvalue reference, что позволило перегружать функции в зависимости от того, является ли аргумент lvalue или rvalue. Аргумент-lvalue копируется, аргумент-rvalue может быть перемещён.
  • Move constructor и move assignment: классы могут определять конструктор перемещения и оператор присваивания перемещением для реализации семантики передачи владения ресурсами.
  • std::move: предоставлена стандартная функция для явного преобразования lvalue в rvalue, что позволяет использовать семантику перемещения.
  • Поддержка стандартной библиотеки: std::vector, std::string и другие контейнеры получили операции перемещения, что значительно повысило производительность.

Резюме

Статья Александреску «Mojo» первой выявила проблему ненужного копирования в C++ и предложила библиотечное решение. Хотя это решение имело недостатки, оно успешно привлекло широкое внимание сообщества и напрямую способствовало появлению Howard Hinnant, David Abrahams и других, которые в конечном итоге реализовали языковую возможность rvalue reference в C++11, тем самым изменив способ управления ресурсами и производительность в C++. Это пример того, как «библиотечная инновация стимулирует эволюцию языкового стандарта».

3. Автоматическое выполнение действий при выходе из области видимости

Идея «автоматического выполнения действий при выходе из области видимости» — это фундамент RAII, и её эволюция от библиотечного хака до стандартного средства хорошо иллюстрирует, как C++ впитывает удачные идеи.

Истоки: ScopeGuard Александреску

В 2000 году Андрей Александреску опубликовал статью «Change the Way You Write Exception-Safe Code – Forever» в Dr. Dobb's, где представил ScopeGuard . Это был библиотечный класс, который в своём деструкторе вызывал переданную функцию (обычно лямбду), если только пользователь явно не отменял это вызовом Dismiss().

Проблема была в том, что такой ScopeGuard не мог автоматически определить, покидаем ли мы область видимости нормально или из-за исключения. Программисту приходилось вручную вызывать Dismiss(), если всё прошло успешно — что было «утомительно и хрупко» . Это убивало главное преимущество идеи — автоматизм.

Решение: std::uncaught_exceptions()

Чтобы ScopeGuard мог различать успешный выход и выход через исключение, нужен был способ узнать, происходит ли разворачивание стека (stack unwinding) прямо сейчас. Александреску мотивировал создание для этого специального средства .

В C++17 появилась функция std::uncaught_exceptions() (заменившая небезопасную std::uncaught_exception() из C++98). Она возвращает количество активных, ещё не перехваченных исключений .

Идиома проста:

  1. В конструкторе guard-объекта запоминаем текущее значение uncaught_exceptions().
  2. В деструкторе снова вызываем uncaught_exceptions() и сравниваем.
  3. Если число увеличилось — значит, во время жизни объекта было выброшено исключение, и деструктор вызывается в процессе разворачивания стека. Если нет — выход нормальный .

Стандартизация: <scope> и scope_exit

Эти идеи, уже как Generic Scope Guard, были предложены к стандартизации в серии документов (N4189, P0052) . В Library Fundamentals TS v3 (и далее) появились:

  • std::experimental::scope_exit — вызывает функцию всегда при выходе из области видимости.
  • std::experimental::scope_fail — вызывает функцию только при выходе через исключение.
  • std::experimental::scope_success — вызывает функцию только при нормальном выходе .

Эти классы используют uncaught_exceptions() именно так, как описано выше . Также есть release(), чтобы отменить вызов (аналог Dismiss()) .

Как это соотносится с классическим RAII (lock_guard)

std::lock_guard — это узкоспециализированный RAII для мьютексов . Он делает одно действие (unlock) при выходе из области видимости, независимо от того, как мы вышли. Это работает, потому что блокировка должна быть снята всегда.

ScopeGuard — это обобщение той же идеи на произвольные действия, но с дополнительной возможностью условного выполнения (только при ошибке или только при успехе). По сути, scope_exit — это «RAII для лямбды», а scope_fail / scope_success — это «RAII с пониманием контекста выхода».

Итог

Путь от идеи Александреску до стандарта <scope> занял почти два десятилетия:

  1. 2000: Библиотечный ScopeGuard с ручным Dismiss().
  2. C++17: std::uncaught_exceptions() даёт портируемый способ отличать исключение от нормального выхода.
  3. TS v3 / стандарт: scope_exit, scope_fail, scope_success — стандартные обёртки, автоматически использующие эту информацию.

Это ещё один пример того, как библиотечная техника (показавшая полезность и жизнеспособность) в итоге привела к добавлению языкового/стандартного средства, которое сделало её проще и безопаснее.

4. Манипуляции наборами типов на этапе компиляции

Давайте разберем, что представляли собой Typelists у Александреску, и как та же функциональность выражается через вариативные шаблоны (C++11) и связанные с ними средства.

Typelists у Александреску: что это было

В книге (глава 3 «Typelists») Александреску предложил структуру для представления списка типов как единой сущности, с которой можно работать на этапе компиляции. Ключевая идея: тип, который содержит другие типы и позволяет их рекурсивно обрабатывать.

Базовая структура выглядела так:

// Пустой список — терминатор рекурсии
struct NullType {};

// Узел: голова (Head) + хвост (Tail)
template <class H, class T>
struct Typelist {
    typedef H Head;
    typedef T Tail;
};

// Пример: список из int, double, char
typedef Typelist<int, Typelist<double, Typelist<char, NullType>>> MyList;

Это рекурсивная cons-структура (как в Lisp). С ней работали через шаблонные метафункции:

  • Length<TList> — длина списка
  • TypeAt<TList, i> — тип по индексу
  • IndexOf<TList, T> — индекс типа
  • Append<TList, T> — добавление в конец
  • Erase<TList, T> — удаление типа

Все они реализовывались через частичную специализацию шаблонов и рекурсию:

// Длина списка
template <class TList> struct Length;

template <> struct Length<NullType> {
    enum { value = 0 };
};

template <class H, class T>
struct Length<Typelist<H, T>> {
    enum { value = 1 + Length<T>::value };
};

Это работало, но было многословно, сложно для отладки и упиралось в лимиты компилятора на глубину рекурсии. Каждая операция — это отдельная шаблонная структура с частичными специализациями.

Вариативные шаблоны: та же идея, но нативно

C++11 добавил variadic templates — возможность принимать произвольное число параметров типа. Это заменило ручную рекурсивную структуру Typelist на встроенный языковой механизм.

Тот же список теперь выглядит так:

template <class... Ts>
struct TypeList {};
// или просто используем std::tuple как носитель типов
using MyList = std::tuple<int, double, char>;

Но главное — появился pack expansion и fold expressions (C++17), которые позволили выражать операции над списками типов без ручной рекурсии.

🔧 Сравнение операций: Typelist vs вариативные шаблоны

1. Длина списка

Typelist (Loki): рекурсивная метафункция Length<T> (см. выше).

Вариативные шаблоны:

template <class... Ts>
constexpr size_t length = sizeof...(Ts);   // встроенный оператор!

sizeof... — это встроенный в язык оператор, который возвращает число элементов в пакете. Никакой рекурсии.

2. Тип по индексу

Typelist (Loki): TypeAt<TList, i> через рекурсивную специализацию.

Вариативные шаблоны (C++11/14):

template <size_t I, class... Ts>
struct TypeAt;

template <class T, class... Rest>
struct TypeAt<0, T, Rest...> {
    using type = T;
};

template <size_t I, class T, class... Rest>
struct TypeAt<I, T, Rest...> : TypeAt<I - 1, Rest...> {};

Вариативные шаблоны (C++17): то же самое, но теперь есть std::tuple_element_t:

using T = std::tuple_element_t<2, std::tuple<int, double, char>>;  // char

3. Применение функции ко всем типам

Typelist (Loki): ручной обход через рекурсию с частичной специализацией.

Вариативные шаблоны (C++17, fold expressions):

template <class... Ts>
void printAllSizes() {
    ((std::cout << sizeof(Ts) << '\n'), ...);  // fold над запятой
}

Или через std::apply для значений:

std::apply([](auto&&... args) {
    ((std::cout << args << '\n'), ...);
}, myTuple);

4. Фильтрация типов (например, только целочисленные)

Typelist (Loki): рекурсивная метафункция с ветвлением по is_integral.

Вариативные шаблоны (C++17/20):

template <class... Ts>
struct FilterIntegral;

template <class... Ts>
using FilterIntegral_t = typename FilterIntegral<Ts...>::type;

template <>
struct FilterIntegral<> { using type = std::tuple<>; };

template <class T, class... Rest>
struct FilterIntegral<T, Rest...> {
    using tail = FilterIntegral_t<Rest...>;
    using type = std::conditional_t<
        std::is_integral_v<T>,
        decltype(std::tuple_cat(std::declval<std::tuple<T>>(), std::declval<tail>())),
        tail
    >;
};

Это все еще рекурсия, но гораздо компактнее, и с C++20 концепциями можно добавить ограничения для читаемости.

5. Декартово произведение списков типов

Typelist (Loki): сложная рекурсивная метафункция CrossProduct.

Вариативные шаблоны:

template <class... Ts>
struct Cartesian {};

template <class... As, class... Bs>
struct Cartesian<std::tuple<As...>, std::tuple<Bs...>> {
    using type = std::tuple<std::tuple<As, Bs>...>;  // pack expansion над двумя пакетами
};

Один ... — и все комбинации сгенерированы. Это именно то, что делает std::visit для двух variant внутри себя.

🆚 Что изменилось концептуально

Аспект Typelists (Loki, 2001) Вариативные шаблоны (C++11+)
Носитель списка Ручная cons-структура Typelist<H, T> Встроенный пакет Ts...
Терминатор NullType Не нужен (пакет может быть пустым)
Длина Рекурсивная метафункция sizeof...
Обход Рекурсивная специализация fold expression (C++17)
Глубина рекурсии Упирается в лимиты компилятора Часто устраняется вовсе
Читаемость Низкая Значительно выше
Отладка ошибок «Простыни» из инстансов шаблонов Все еще сложно, но лучше

🎯 Куда это переросло в стандарте

Идея «работать с набором типов как с коллекцией» прошла эволюцию:

  1. C++11: std::tuple, sizeof..., std::integral_constant — база.
  2. C++14: std::index_sequence — способ «пронумеровать» пакет и развернуть его.
  3. C++17: fold expressions, std::apply, std::void_t, if constexpr — резко упростили метапрограммирование.
  4. C++20: concepts + requires — позволяют писать ограничения на пакеты типов декларативно, почти как обычный код.
  5. C++23: std::tuple продолжает развиваться, появляются утилиты для работы с пакетами.

💎 Итог

Александреску показал, что можно делать со списками типов, используя доступные в 2001 году средства — рекурсию и частичную специализацию. Вариативные шаблоны сделали эти операции встроенными в язык, убрав большую часть ручной работы и ограничений. По сути, вся метапрограммная машинерия Loki сегодня либо не нужна (заменена sizeof..., fold, tuple), либо выражается в разы компактнее.

5. Mножественная диспетчеризация

Реализация множественной диспетчеризации . В стандарт эта концепция в явном виде не вошла, но запрос на подобную функциональность частично удовлетворяется через std::variant и std::visit (C++17). Рассмотрим подробно эту концепцию 1) Что предлагал автор в "Мультиметодах" и 2) как это делается через variant - visit

Что предлагал автор в «Мультиметодах»

В главе 11 «Multimethods» Александреску решал фундаментальную проблему C++: виртуальные функции позволяют выбрать поведение в зависимости от типа одного объекта (динамическая диспетчеризация), но не могут сделать это для нескольких объектов одновременно. Он хотел дать программистам инструмент для ситуаций, когда функция должна вести себя по-разному в зависимости от типов двух и более аргументов (например, Collide(Rectangle&, Circle&)).

Поскольку в языке такой поддержки не было, он предложил библиотечные решения. Основная идея заключалась в том, чтобы вручную определить тип каждого объекта во время выполнения и сопоставить их комбинацию с нужной функцией.

Он рассматривал три основных подхода, которые отличались по скорости и сложности:

  1. Brute Force (Грубая сила): Самый простой, но медленный способ. Он заключается в использовании цепочки dynamic_cast для проверки типов. Сначала проверяется тип первого аргумента, затем второго, и так далее, пока не будет найдена подходящая комбинация. Это легко реализовать, но работает за линейное время (O(N)), и при большом числе типов код становится громоздким.

  2. Static Dispatcher (на основе списков типов): Более элегантное решение, использующее TypeLists (списки типов). Оно генерирует на этапе компиляции вложенные структуры для перебора возможных типов. Это позволяло избежать ручного написания if-else, но всё ещё требовало много ресурсов для поддержки и имело логарифмическую сложность (O(log N)).

  3. Matrix Dispatch (на основе матрицы): Самый быстрый подход, который обеспечивал константное время (O(1)). Идея заключалась в том, чтобы присвоить каждому классу уникальный целочисленный индекс и использовать двумерный массив (матрицу), где пересечение строки и столбца (индексов типов) давало указатель на нужную функцию. Это работало быстро, но требовало дополнительной инфраструктуры для управления индексами классов.

Все эти подходы были объединены в библиотеке Loki, которая сопровождала книгу. Хотя Loki так и не вошла в стандарт, она показала сообществу, что множественная диспетчеризация в C++ возможна, и подготовила почву для будущих языковых средств.

Как это делается через std::variant и std::visit

С появлением C++17 в стандартной библиотеке появился std::variant — типобезопасное объединение (union), которое может хранить одно значение из фиксированного набора типов. А std::visit — это механизм для применения функции (визитора) к значению, хранящемуся в variant.

Вместе они позволяют реализовать закрытую множественную диспетчеризацию (когда набор типов известен на этапе компиляции).

Основной паттерн: overloaded

Самый распространенный способ — использовать визитор, который умеет обрабатывать несколько типов. Для этого применяется идиома, известная как overload pattern.

  1. Создание визитора: Определяется вспомогательная структура overloaded, которая наследуется от нескольких лямбда-функций (каждая для своего типа). Ключевой момент — это using Ts::operator()..., который объединяет все operator() из базовых классов в один набор перегрузок внутри overloaded.

  2. Вызов std::visit: Вы передаете этот объект overloaded в std::visit вместе с вашим variant. std::visit во время выполнения смотрит, какой тип сейчас хранится в variant, и вызывает соответствующую перегрузку operator() из overloaded.

Вот как это выглядит в коде:

#include <iostream>
#include <variant>
#include <string>

// 1. Вспомогательная структура для объединения лямбд
template <class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};
// Правило вывода типов для C++17
template <class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    // Тип variant, который может хранить int, double или string
    using var_t = std::variant<int, double, std::string>;
    var_t v = 42; // Сейчас хранится int

    // 2. Вызов visit с объектом overloaded
    std::visit(overloaded{
        [](int i) { std::cout << "int: " << i << '\n'; },
        [](double d) { std::cout << "double: " << d << '\n'; },
        [](const std::string& s) { std::cout << "string: " << s << '\n'; }
    }, v);
    // Вывод: int: 42
}

Множественная диспетчеризация (два и более variant)

Главное преимущество std::visit в том, что он может принимать несколько variant одновременно. Это и есть прямая аналогия мультиметодам.

Если у вас есть два объекта, типы которых известны только в runtime, вы можете упаковать их в variant и передать в std::visit. std::visit переберет все возможные комбинации типов из обоих variant и вызовет визитор с двумя аргументами. Ваш overloaded должен предоставить перегрузки для каждой такой комбинации.

// Пример для двух variant
using Shape1 = std::variant<Circle, Square>;
using Shape2 = std::variant<Circle, Square>;

Shape1 a = Circle{};
Shape2 b = Square{};

std::visit(overloaded{
    [](const Circle&, const Circle&) { /* ... */ },
    [](const Circle&, const Square&) { /* ... */ },
    [](const Square&, const Circle&) { /* ... */ },
    [](const Square&, const Square&) { /* ... */ }
}, a, b);

Сравнение подходов

Подход Александреску (Loki) и подход std::variant решают одну задачу, но с разных сторон:

Характеристика Мультиметоды в Loki (книга) std::variant + std::visit (C++17)
Набор типов Открытый. Вы можете добавлять новые классы, не меняя существующий код. Закрытый. Набор типов фиксирован в определении variant. Добавление нового типа требует изменения variant и перекомпиляции визитора.
Диспетчеризация Runtime, на основе dynamic_cast или матрицы индексов. Runtime, на основе индекса, хранящегося в variant.
Производительность От O(N) (Brute Force) до O(1) (Matrix). Обычно очень высокая. Компилятор часто генерирует таблицу переходов (jump table), что близко к O(1).
Типобезопасность Ошибки (отсутствие перегрузки для комбинации) могут проявиться только в runtime. Компилятор проверяет исчерпывающость перегрузок. Если вы забыли обработать какую-то комбинацию типов, это часто ошибка компиляции.
Синтаксис Требует изучения библиотеки Loki и макросов. Стандартный, идиоматичный C++17.

Ключевое отличие: Loki давал возможность добавлять новые типы без изменения существующего кода (решение проблемы Expression Problem со стороны типов). std::variant — это инструмент для работы с заранее известным, закрытым набором типов, но он обеспечивает гораздо лучшую типобезопасность, производительность и интеграцию с языком.

У подхода std::variant + std::visit есть несколько существенных недостатков, которые ограничивают его применение в реальных проектах. Вот основные из них.

Закрытый набор типов (главный архитектурный минус)

Это фундаментальное ограничение: список типов в std::variant фиксирован на этапе компиляции . Если вы захотите добавить новый тип-альтернативу, вам придется изменить определение variant, и все места, где используется std::visit, должны быть перекомпилированы. Это прямо противоположно классическому полиморфизму через виртуальные функции, где новый наследник добавляется без изменения существующего кода.

Проблемы с производительностью

Хотя в идеале std::visit должен генерировать быструю таблицу переходов, на практике все сложнее.

  • Зависимость от компилятора: Исследования показывают, что производительность сильно варьируется . В некоторых тестах std::visit оказывался в разы медленнее альтернатив, и его скорость была близка к виртуальным функциям .
  • Проблемы оптимизации: Есть задокументированные случаи, когда компиляторы (например, GCC) не могут должным образом оптимизировать std::visit даже для нескольких variant . Компилятор может сгенерировать множество избыточных функций вместо одной эффективной.
  • Сложность для consteval: Если ваш визитор использует consteval-функции (вычисления на этапе компиляции), реализация std::visit становится нетривиальной и может потребовать динамических проверок, что убивает всю производительность .

Проблема valueless_by_exception

Это, пожалуй, самый неприятный рантайм-баг. std::variant может перейти в состояние «без значения», если во время операции присваивания или emplace выбрасывается исключение . Если вы попытаетесь применить std::visit к такому «испорченному» variant, программа выбросит исключение std::bad_variant_access . Это означает, что код, который выглядит типобезопасным, может неожиданно упасть в рантайме, и вам приходится либо проверять это состояние вручную, либо мириться с потенциальными сбоями.

Проблемы с синтаксисом и гибкостью

  • Многословность: Реализация паттерна overloaded для объединения лямбд — это уже признанный «хак», который требует дополнительного шаблонного кода . Без него писать визиторы неудобно.
  • Проблемы с наследованием: Если вы попытаетесь расширить класс-визитор, добавив в наследника новые перегрузки operator(), это может привести к ошибке компиляции, потому что std::visit требует «исчерпывающего» набора перегрузок . Вам придется явно вносить методы базового класса в область видимости наследника через using.
  • Нет единого интерфейса: В отличие от виртуальных функций, где контракт задается базовым классом, здесь весь «интерфейс» — это просто набор лямбд или перегрузок, который неявно разбросан по коду.

Итог

std::variant + std::visit — это мощный инструмент, но его стоит использовать только когда у вас есть закрытый, заранее известный набор типов, и вы готовы смириться с потенциальными проблемами производительности и состояния valueless_by_exception. Для сценариев с открытым набором типов или жесткими требованиями к производительности классический полиморфизм или CRTP остаются более предсказуемыми и безопасными решениями.

CRTP (Curiously Recurring Template Pattern или «странно рекурсивный шаблон проектирования») — это идиома в языке программирования C++, при которой класс Derived наследуется от шаблонного базового класса Base, передавая самого себя (Derived) в качестве аргумента шаблона

Обший итог

Прямого списка «функций, добавленных в стандарт благодаря этой книге» не существует. Влияние «Modern C++ Design» было каталитическим: она продемонстрировала, что можно делать с помощью шаблонов, и создала спрос на языковые средства, которые сделали бы эти практики более доступными. Многие вещи, которые в 2001 году требовали сложных трюков метапрограммирования, в современном C++ стали обыденными и безопасными благодаря стандартизации идей, впервые популяризированных Александреску.

Если вас интересуют детали по какому-то из пунктов (например, история появления unique_ptr или вариативных шаблонов), дайте знать.

Комментарии

Популярные сообщения из этого блога

Полезные новации С++17. Выражения свертки (Fold expressions)

Перегрузка и специализация шаблонных функций.

Ещё о перегрузке шаблонных функций (Partial ordering)