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). |
В текущей статье рассмотрим следующие концепции:
- RAII для указателей,
- Семантика перемещения,
- Автоматическое выполнение действий при выходе из области видимости,
- Манипуляции наборами типов на этапе компиляции,
- Mножественная диспетчеризация.
1. RAII для указателей
Уточню и раскрою тезис о auto_ptr и политиках владения подробнее. Ключевая проблема auto_ptr была не в самой идее уникального владения, а в отсутствии в языке семантики перемещения, из-за чего владение приходилось передавать через копирование, что порождало опасные побочные эффекты.
Фундаментальный изъян std::auto_ptr
auto_ptr появился в C++98 с благой целью — обеспечить базовый RAII для указателей. Однако он был спроектирован до появления rvalue-ссылок и семантики перемещения .
Чтобы реализовать передачу уникального владения (что по определению должно быть перемещением, а не копированием), auto_ptr использовал деструктивное копирование: конструктор копирования и оператор присваивания auto_ptr не копировали указатель, а передавали владение и обнуляли источник .
Это приводило к трем критическим проблемам:
- Фундаментальная небезопасность: Передача
auto_ptrв функцию по значению «незаметно» уничтожала объект у вызывающего, что приводило к использованию памяти после освобождения (use-after-free) и неопределенному поведению . - Несовместимость с STL: Поскольку алгоритмы и контейнеры STL активно используют копирование,
auto_ptrнельзя было безопасно хранить вstd::vectorилиstd::list. - Отсутствие гибкости:
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 предложил новаторское решение, он имел очевидные недостатки, которые подробно обсуждались в сообществе:
- Нарушение RVO/NRVO: Mojo мешал компилятору выполнять оптимизацию возвращаемого значения (RVO) и оптимизацию именованного возвращаемого значения (NRVO), что были важными оптимизациями того времени.
- Неестественный синтаксис: использование Mojo требовало объявления функций в специальном стиле (например,
void f(mojo::temporary<X> x)), что изменяло привычные сигнатуры функций и снижало читаемость кода. - Несовместимость с обычными копируемыми классами: перемещаемые классы 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). Она возвращает количество активных, ещё не перехваченных исключений .
Идиома проста:
- В конструкторе guard-объекта запоминаем текущее значение
uncaught_exceptions(). - В деструкторе снова вызываем
uncaught_exceptions()и сравниваем. - Если число увеличилось — значит, во время жизни объекта было выброшено исключение, и деструктор вызывается в процессе разворачивания стека. Если нет — выход нормальный .
Стандартизация: <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> занял почти два десятилетия:
- 2000: Библиотечный
ScopeGuardс ручнымDismiss(). - C++17:
std::uncaught_exceptions()даёт портируемый способ отличать исключение от нормального выхода. - 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) |
| Глубина рекурсии | Упирается в лимиты компилятора | Часто устраняется вовсе |
| Читаемость | Низкая | Значительно выше |
| Отладка ошибок | «Простыни» из инстансов шаблонов | Все еще сложно, но лучше |
🎯 Куда это переросло в стандарте
Идея «работать с набором типов как с коллекцией» прошла эволюцию:
- C++11:
std::tuple,sizeof...,std::integral_constant— база. - C++14:
std::index_sequence— способ «пронумеровать» пакет и развернуть его. - C++17: fold expressions,
std::apply,std::void_t,if constexpr— резко упростили метапрограммирование. - C++20: concepts +
requires— позволяют писать ограничения на пакеты типов декларативно, почти как обычный код. - 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&)).
Поскольку в языке такой поддержки не было, он предложил библиотечные решения. Основная идея заключалась в том, чтобы вручную определить тип каждого объекта во время выполнения и сопоставить их комбинацию с нужной функцией.
Он рассматривал три основных подхода, которые отличались по скорости и сложности:
Brute Force (Грубая сила): Самый простой, но медленный способ. Он заключается в использовании цепочки
dynamic_castдля проверки типов. Сначала проверяется тип первого аргумента, затем второго, и так далее, пока не будет найдена подходящая комбинация. Это легко реализовать, но работает за линейное время (O(N)), и при большом числе типов код становится громоздким.Static Dispatcher (на основе списков типов): Более элегантное решение, использующее TypeLists (списки типов). Оно генерирует на этапе компиляции вложенные структуры для перебора возможных типов. Это позволяло избежать ручного написания
if-else, но всё ещё требовало много ресурсов для поддержки и имело логарифмическую сложность (O(log N)).Matrix Dispatch (на основе матрицы): Самый быстрый подход, который обеспечивал константное время (O(1)). Идея заключалась в том, чтобы присвоить каждому классу уникальный целочисленный индекс и использовать двумерный массив (матрицу), где пересечение строки и столбца (индексов типов) давало указатель на нужную функцию. Это работало быстро, но требовало дополнительной инфраструктуры для управления индексами классов.
Все эти подходы были объединены в библиотеке Loki, которая сопровождала книгу. Хотя Loki так и не вошла в стандарт, она показала сообществу, что множественная диспетчеризация в C++ возможна, и подготовила почву для будущих языковых средств.
Как это делается через std::variant и std::visit
С появлением C++17 в стандартной библиотеке появился std::variant — типобезопасное объединение (union), которое может хранить одно значение из фиксированного набора типов. А std::visit — это механизм для применения функции (визитора) к значению, хранящемуся в variant.
Вместе они позволяют реализовать закрытую множественную диспетчеризацию (когда набор типов известен на этапе компиляции).
Основной паттерн: overloaded
Самый распространенный способ — использовать визитор, который умеет обрабатывать несколько типов. Для этого применяется идиома, известная как overload pattern.
Создание визитора: Определяется вспомогательная структура
overloaded, которая наследуется от нескольких лямбда-функций (каждая для своего типа). Ключевой момент — этоusing Ts::operator()..., который объединяет всеoperator()из базовых классов в один набор перегрузок внутриoverloaded.Вызов
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
Обший итог
Прямого списка «функций, добавленных в стандарт благодаря этой книге» не существует. Влияние «Modern C++ Design» было каталитическим: она продемонстрировала, что можно делать с помощью шаблонов, и создала спрос на языковые средства, которые сделали бы эти практики более доступными. Многие вещи, которые в 2001 году требовали сложных трюков метапрограммирования, в современном C++ стали обыденными и безопасными благодаря стандартизации идей, впервые популяризированных Александреску.
Если вас интересуют детали по какому-то из пунктов (например, история появления unique_ptr или вариативных шаблонов), дайте знать.
Комментарии
Отправить комментарий