Занятие 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’ом вручную:

1
2
3
4
5
6
7
struct Token {
    enum class Kind { Num, Op } kind;   // тег
    union {
        double num;                      // активен при Kind::Num
        char   op;                       // активен при Kind::Op
    };
};

Контракт «kind соответствует активному члену union» держится на честном слове: проверок нет, ошибка — UB. Все пишут этот код одинаково и одинаково в нём ошибаются — значит, он обязан жить в стандартной библиотеке. C++17 так и сделал.

std::variant: union с манерами

1
2
3
4
5
6
7
8
std::variant<int, double, std::string> v;

v = 42;                            // активен int
v = 3.14;                          // теперь double
v = std::string("x");              // теперь string

v.index();                         // 2
std::holds_alternative<double>(v); // false

Память — как у union (максимум из альтернатив) плюс тег-индекс:

union {float, uint32}:           4
variant<float, uint32_t>:        8    # + тег
variant<char, double>:          16    # выравнивание (занятие 18)
variant<monostate,int,string>:  32    # string 24 + тег + выравнивание

variant не аллоцирует: он ровно такого размера, каков худший жилец. И он никогда не пуст — по умолчанию строится первая альтернатива; «пока ничего» выражается явной альтернативой std::monostate.

Доступ: с исключением или с проверкой

1
2
3
4
5
6
7
std::variant<int, double> v = 3.14;

int a = std::get<int>(v);              // не тот член — бросает:
                                       //   bad_variant_access

if (auto* p = std::get_if<double>(&v)) // вариант без исключений:
    std::cout << *p;                   //   указатель или nullptr

Сравните с занятием 18: у union чтение не того члена — молчаливое UB, у variant — громкое исключение. Ошибка не исчезла, но стала диагностируемой. Родство с enum class + switch из занятия 11 прямое: variant — это «enum с грузом», где каждая метка несёт своё значение.

visit: полиморфизм значений

1
2
3
4
5
6
7
struct Circle { double r; double area() const { return 3.14159 * r * r; } };
struct Square { double s; double area() const { return s * s; } };

using Shape = std::variant<Circle, Square>;

Shape sh = Circle{2.0};
double a = std::visit([](const auto& s) { return s.area(); }, sh);

visit смотрит на тег и вызывает лямбду с активной альтернативой. Generic-лямбда с auto (занятие 14) покрывает все типы одним текстом; нужны разные обработчики — идиома overloaded:

1
2
3
4
5
6
7
8
9
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
// мы компилируем под C++20; до него нужен ещё deduction guide:
// template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

std::visit(overloaded{
    [](const Circle& c) { /* круг */ },
    [](const Square& s) { /* квадрат */ },
}, sh);

Замер: 10⁷ фигур, сумма площадей

Вариант А — классика занятия 7/14: vector<unique_ptr<Shape>> и виртуальный area(). Вариант Б — vector<variant<Circle, Square>> и visit:

unique_ptr + virtual:   45.0 мс
variant + visit:        38.8 мс

память на фигуру:
  А: указатель 8 Б + объект в куче ~16 Б (+ накладные malloc)
  Б: 16 Б прямо в векторе, кучи нет

variant не медленнее виртуальных вызовов — здесь даже быстрее: фигуры лежат подряд (занятие 8), аллокаций ноль, прыжков по указателям ноль. Но выбирают между ними не по скорости.

Главный критерий: закрытый или открытый набор

variant — набор закрыт. Все альтернативы известны в точке объявления. Добавили Triangle — каждый visit с overloaded без ветки Triangle перестанет компилироваться: компилятор найдёт все места, которые нужно дописать. Родной брат -Wswitch у enum class (занятие 11). Ниши: токены парсера, состояния автомата, «значение или ошибка», фиксированная геометрия.

Наследование — набор открыт. Новые типы добавляются без перекомпиляции старого кода: плагин наследует Shape и просто работает. Цена — указатели, куча, vtable и невозможность проверить полноту обработки. Ниши: плагины, библиотеки, расширяемые чужим кодом.

Правило курса

Набор типов фиксирован — variant; набор расширяется чужим кодом — наследование. Вопрос-компас: «кто и когда узнаёт полный список типов — я при компиляции или программа в рантайме?»

std::any: коробка для чего угодно

1
2
3
4
5
6
7
8
9
std::any a = 42;                // int
a = std::string("привет");      // теперь string
a = 3.14;                       // теперь double

std::any_cast<double>(a);       // 3.14
std::any_cast<int>(a);          // не тот тип — бросает: bad any cast

if (auto* p = std::any_cast<double>(&a))   // вариант с проверкой
    std::cout << *p;

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) токены, состояния, «значение или ошибка» конфиги, границы модулей плагины, иерархии

Проверьте себя

  1. Что напечатает?

    1
    2
    3
    4
    5
    6
    
    std::variant<int, double, std::string> v;
    std::cout << v.index();
    v = 2.5;
    std::cout << v.index();
    v = "hi";
    std::cout << v.index();
    Ответ
    012. Пустой variant строит первую альтернативу (int, индекс 0); 2.5 — double (1); "hi" — это const char*, он конвертируется в string (2). Но неявный выбор альтернативы по конверсии — скользкое место: при похожих альтернативах (например, bool и string до C++20) литерал уезжал не туда. Явное std::string("hi") надёжнее.
  2. Почините занятие 18. Перепишите union Pun { float f; uint32_t u; } на variant. Что не получится?

    Ответ
    variant решает задачу «или-или»: variant<float, uint32_t> хранит либо float, либо uint32 — с тегом и проверками. Но задачу type punning — «посмотреть на те же байты как на другой тип» — он не решает принципиально: активная альтернатива одна, чужие байты не прочитать. Для punning легален только std::bit_cast (занятие 18). Из применений union: «или-или» уходит в variant, punning — в bit_cast, бинарные форматы — остаются за union + memcpy.
  3. Спроектируйте: токен калькулятора (число | оператор | скобка) и результат вычисления (значение | ошибка); печать результата через visit.

    Ответ
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    struct Op    { char c; };
    struct Paren { bool open; };
    using Token  = std::variant<double, Op, Paren>;
    
    struct Error { std::string text; };
    using Result = std::variant<double, Error>;
    
    void print(const Result& r) {
        std::visit(overloaded{
            [](double v)        { std::cout << "= " << v << '\n'; },
            [](const Error& e)  { std::cout << "ошибка: " << e.text << '\n'; },
        }, r);
    }

    Заметьте: добавите третий исход (например, Pending) — этот print перестанет компилироваться, пока не допишете ветку. Это и есть подарок закрытого набора.

Домашнее задание (сдача через Git)

  1. Токенизатор: разберите строку арифметического выражения в vector<Token> (Token — из задачи 3); печать списка токенов — visit с overloaded.
  2. Result-тип: using Result = std::variant<double, Error> — безопасное деление и цепочка вычислений с прокидыванием ошибки, без исключений.
  3. Свой бенчмарк: повторите замер virtual против visit на своей машине (методика занятия 16), добавив третью фигуру; объясните расхождения с числами конспекта.
  4. Мини-конфиг: map<string, any> с настройками разных типов + типобезопасная getOr<T>(key, default); продемонстрируйте bad_any_cast и его обработку.
  5. * Полнота на компиляции: добавьте Triangle в Shape-variant и зафиксируйте все места, которые компилятор заставил дописать; опишите, как ту же правку пережила бы иерархия с virtual — и что бы она не нашла.

Следующее занятие — сортировки за n log n: слиянием и быстрая. Дальнобойные перестановки, «разделяй и властвуй» из занятия 6 в главной роли — и обещанный занятием 19 выход из класса «соседних» алгоритмов.