Занятие 20. std::variant и std::any
Занятие 18 показало границы union: он не помнит активного члена, а чтение не того — UB. Эта лекция принимает работу у его воспитанного наследника: std::variant — tagged union со встроенными тегом и проверками, std::visit — диспетчеризация без vtable, и std::any — коробка для случаев, когда набор типов принципиально неизвестен. Замеры реальные (Apple M-серия, clang++ -O2).
Долг занятия 18: тег на честном слове
Задача «или-или» вечна: токен — число или оператор; результат — значение или ошибка. До C++17 её решали tagged union’ом вручную:
Контракт «kind соответствует активному члену union» держится на честном слове: проверок нет, ошибка — UB. Все пишут этот код одинаково и одинаково в нём ошибаются — значит, он обязан жить в стандартной библиотеке. C++17 так и сделал.
std::variant: union с манерами
Память — как у union (максимум из альтернатив) плюс тег-индекс:
variant не аллоцирует: он ровно такого размера, каков худший жилец. И он никогда не пуст — по умолчанию строится первая альтернатива; «пока ничего» выражается явной альтернативой std::monostate.
Доступ: с исключением или с проверкой
Сравните с занятием 18: у union чтение не того члена — молчаливое UB, у variant — громкое исключение. Ошибка не исчезла, но стала диагностируемой. Родство с enum class + switch из занятия 11 прямое: variant — это «enum с грузом», где каждая метка несёт своё значение.
visit: полиморфизм значений
visit смотрит на тег и вызывает лямбду с активной альтернативой. Generic-лямбда с auto (занятие 14) покрывает все типы одним текстом; нужны разные обработчики — идиома overloaded:
Замер: 10⁷ фигур, сумма площадей
Вариант А — классика занятия 7/14: vector<unique_ptr<Shape>> и виртуальный area(). Вариант Б — vector<variant<Circle, Square>> и visit:
variant не медленнее виртуальных вызовов — здесь даже быстрее: фигуры лежат подряд (занятие 8), аллокаций ноль, прыжков по указателям ноль. Но выбирают между ними не по скорости.
Главный критерий: закрытый или открытый набор
variant — набор закрыт. Все альтернативы известны в точке объявления. Добавили Triangle — каждый visit с overloaded без ветки Triangle перестанет компилироваться: компилятор найдёт все места, которые нужно дописать. Родной брат -Wswitch у enum class (занятие 11). Ниши: токены парсера, состояния автомата, «значение или ошибка», фиксированная геометрия.
Наследование — набор открыт. Новые типы добавляются без перекомпиляции старого кода: плагин наследует Shape и просто работает. Цена — указатели, куча, vtable и невозможность проверить полноту обработки. Ниши: плагины, библиотеки, расширяемые чужим кодом.
Правило курса
Набор типов фиксирован — variant; набор расширяется чужим кодом — наследование. Вопрос-компас: «кто и когда узнаёт полный список типов — я при компиляции или программа в рантайме?»
std::any: коробка для чего угодно
any хранит значение любого копируемого типа — список альтернатив не нужен вообще. Цена всеядности: sizeof(any) = 32, маленькие объекты живут внутри (small buffer), большие — в куче; каждая распаковка — проверка типа в рантайме; any_cast требует точный тип, никаких конверсий. Это type erasure: тип стёрт из сигнатуры, но запомнен внутри.
Честные ниши: конфиги с разнородными настройками, данные, прицепленные к чужому фреймворку, границы модулей с принципиально неизвестными типами. Запах: any там, где набор типов известен, — это variant, у которого отобрали проверку полноты. Лестница строгости: enum class → variant → any — чем правее, тем свободнее и тем меньше ловит компилятор; идти вправо стоит, только когда левое честно не выражает задачу.
Карта выбора: четыре инструмента «или-или»
| union | variant | any | наследование | |
|---|---|---|---|---|
| набор типов | фиксирован | фиксирован | любой | открытый |
| тег/проверки | вручную, UB | встроены | в рантайме | vtable |
| память | max членов | max + тег | 32 Б + куча для больших | указатель + объект в куче |
| полнота обработки | — | проверяет компилятор | нет | нет |
| расширение без перекомпиляции | нет | нет | да | да |
| ниша | форматы, протоколы (зан. 18) | токены, состояния, «значение или ошибка» | конфиги, границы модулей | плагины, иерархии |
Проверьте себя
-
Что напечатает?
-
Почините занятие 18. Перепишите
union Pun { float f; uint32_t u; }на variant. Что не получится? -
Спроектируйте: токен калькулятора (число | оператор | скобка) и результат вычисления (значение | ошибка); печать результата через visit.
Домашнее задание (сдача через Git)
- Токенизатор: разберите строку арифметического выражения в
vector<Token>(Token — из задачи 3); печать списка токенов — visit с overloaded. - Result-тип:
using Result = std::variant<double, Error>— безопасное деление и цепочка вычислений с прокидыванием ошибки, без исключений. - Свой бенчмарк: повторите замер virtual против visit на своей машине (методика занятия 16), добавив третью фигуру; объясните расхождения с числами конспекта.
- Мини-конфиг:
map<string, any>с настройками разных типов + типобезопаснаяgetOr<T>(key, default); продемонстрируйте bad_any_cast и его обработку. - * Полнота на компиляции: добавьте Triangle в Shape-variant и зафиксируйте все места, которые компилятор заставил дописать; опишите, как ту же правку пережила бы иерархия с virtual — и что бы она не нашла.
Следующее занятие — сортировки за n log n: слиянием и быстрая. Дальнобойные перестановки, «разделяй и властвуй» из занятия 6 в главной роли — и обещанный занятием 19 выход из класса «соседних» алгоритмов.