1. Введение
Когда вы только начинаете программировать, кажется, что функция всегда возвращает «какое-то одно значение»: число, строку, булево. Но довольно быстро выясняется, что в реальном коде один и тот же «результат» по смыслу бывает разным. Например, парсер команды может вернуть «успешно распарсенную команду», а может вернуть «ошибка: неизвестная команда» — и это не один и тот же смысл.
Частая новичковая реакция: «ладно, верну int, а -1 будет означать ошибку». Проблема в том, что -1 может быть и нормальным значением (сегодня нельзя, завтра можно), и вы начинаете жить в мире тайных договорённостей: код вроде работает… пока кто-то не забудет про договорённость. std::variant как раз позволяет выразить мысль типами: «результат — это один из нескольких возможных типов».
std::variant: ровно один активный вариант
std::variant<T1, T2, ...> — это контейнер-значение, который в любой момент хранит ровно одну из перечисленных альтернатив. Это важный контраст с «несколькими полями в структуре»: у variant есть чёткая идея «сейчас активен вот этот тип». Не «все поля одновременно», не «поле A заполнено, поле B игнорируем», а именно «внутри сейчас лежит конкретный тип из списка».
Можно представлять variant как коробку с наклейками: на коробке написано «там может быть либо int, либо std::string», и в каждый момент времени внутри лежит что-то одно. А какая именно наклейка актуальна — это и есть активная альтернатива.
Мини-пример — просто почувствовать синтаксис:
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> v = 10; // сейчас внутри int
v = std::string{"hi"}; // теперь внутри string
}
Здесь важно привыкнуть к мысли: переменная v одна, а «реальный тип значения внутри» может меняться во время выполнения.
optional и variant: в чём разница
Перед тем как углубляться в методы variant, полезно закрепить смысловую разницу. std::optional<T> отвечает на вопрос: «есть ли значение типа T?» и если да — даёт T. А std::variant<A, B, C> отвечает на вопрос: «какое из состояний сейчас?» и даёт либо A, либо B, либо C.
Удобно держать в голове такую таблицу:
| Инструмент | Смысл | Пример «жизненной» модели |
|---|---|---|
|
либо есть , либо нет |
нашли индекс элемента / не нашли |
|
либо , либо |
результат может быть числом или текстовым сообщением |
| enum class Status { Ok, Error } + отдельные поля | статус отдельно, данные отдельно | часто работает, но легко рассинхронизировать статус и данные |
У enum class плюс структуры есть типичная проблема: можно случайно создать «невозможное состояние» (например, Status::Ok, но сообщение об ошибке заполнено; или наоборот). variant сам по себе запрещает хранить одновременно «успех» и «ошибку»: он всегда один.
2. Проверка и извлечение: index(), holds_alternative и get_if
Активная альтернатива и index()
Активная альтернатива — это тот тип из списка variant, который реально хранится внутри прямо сейчас. Именно активная альтернатива определяет, что вы имеете право безопасно извлекать из variant.
Ещё одна важная деталь: variant не бывает «пустым» в обычном смысле. Если вы его создаёте «по умолчанию», он попытается сконструировать первую альтернативу (если это возможно). Это иногда полезно, а иногда — неожиданно.
Посмотрим:
#include <iostream>
#include <variant>
int main() {
std::variant<int, double> v; // по умолчанию хранит int со значением 0
std::cout << v.index() << '\n'; // 0
}
вывод: 0
v.index() возвращает номер активной альтернативы (нумерация с нуля). Это не самый «человечный» способ проверки, но для диагностики он удобен: можно быстро увидеть, что реально лежит внутри.
Как проверить тип: holds_alternative<T>(v)
Когда вы держите в руках variant, главный вопрос: «а что там сейчас?». В стандартной библиотеке есть читаемый способ узнать это «по типу»: std::holds_alternative<T>(v). Он отвечает на вопрос «активная альтернатива — это T?». Это обычно то, что нужно в прикладном коде.
Пример:
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> v = 42;
if (std::holds_alternative<int>(v)) {
std::cout << "int inside\n"; // int inside
}
}
Здесь мысль простая: мы не гадаем, мы проверяем.
Безопасное извлечение: std::get_if<T>(&v) и паттерн с nullptr
Сейчас будет очень приятный момент: вы уже знаете всё нужное, чтобы понять get_if. Функция std::get_if<T>(&v) возвращает:
- указатель T*, если внутри variant сейчас активна альтернатива T,
- nullptr, если внутри сейчас что-то другое.
И это буквально тот же паттерн, что и «поиск элемента в контейнере вернул указатель» или «функция может вернуть nullptr».
Вот пример:
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> v = std::string{"abcd"};
if (const std::string* p = std::get_if<std::string>(&v)) {
std::cout << p->size() << '\n'; // 4
} else {
std::cout << "not a string\n";
}
}
Обратите внимание на форму: именно &v, потому что get_if работает с адресом variant и возвращает адрес значения внутри. Новички часто пытаются написать get_if<T>(v) и получают ошибку компиляции. Это как пытаться открыть дверь ключом, но держать ключ у соседа: «формально предмет похож, но не то».
Почему get_if так хорош для начинающих? Потому что он делает опасное действие (извлечение конкретного типа) безопасным: если тип не совпал, вы не падаете, а получаете nullptr и можете нормально обработать ситуацию.
Антипример по смыслу: магические значения против variant
Давайте сравним два подхода на понятной задаче. Предположим, мы хотим разобрать строку и получить число от 0 до 9. Если не получилось — вернуть ошибку.
«Магический» подход:
#include <string_view>
int parse_digit_magic(std::string_view s) {
if (s.size() != 1) return -1;
char c = s[0];
if (c < '0' || c > '9') return -1;
return c - '0';
}
Он компактный, но у него проблема: -1 — это «тайный пароль ошибки». Вам придётся помнить и проверять его везде.
Подход с variant (результат либо число, либо сообщение об ошибке):
#include <string>
#include <string_view>
#include <variant>
using ParseResult = std::variant<int, std::string>;
ParseResult parse_digit(std::string_view s) {
if (s.size() != 1) return std::string{"need exactly one char"};
char c = s[0];
if (c < '0' || c > '9') return std::string{"not a digit"};
return static_cast<int>(c - '0');
}
Здесь тип говорит правду: это или int, или string. Никаких «-1 это ошибка, но только если вы помните».
Да, дальше придётся аккуратно извлечь значение (через get_if или holds_alternative), но это честная цена за то, что смысл «ошибка» и «успех» больше не смешиваются в одном числе.
5. Пример TodoCLI: variant как результат парсинга
Теперь соберём небольшой кусок приложения, который будем «растить» дальше по курсу. Пусть это будет очень простая консольная программа для списка задач (todo). Сегодня наша цель не сделать полноценный todo-лист, а научиться моделировать результат разбора команды через variant.
Представим, что пользователь вводит команды такими строками:
- add купить молоко
- done 2
- list
Мы сделаем модель команд через struct, а результат парсинга — через variant.
Модели команд
Сделаем несколько маленьких структур, где поля соответствуют смыслу команды. Здесь нет магии: мы просто моделируем данные.
#include <string>
struct AddCmd {
std::string text;
};
struct DoneCmd {
std::size_t index; // номер задачи
};
struct ListCmd { };
struct ParseError {
std::string message;
};
Теперь вводим общий тип «команда или ошибка»:
#include <variant>
using Command = std::variant<AddCmd, DoneCmd, ListCmd, ParseError>;
Обратите внимание: Command — это не «класс команд», а именно «одно из»: либо AddCmd, либо DoneCmd, либо ListCmd, либо ParseError.
Парсер: возвращаем variant
Парсер сделаем нарочито простой, чтобы не отвлекаться. Пусть он распознаёт ровно три команды и ошибку.
#include <string>
#include <string_view>
#include <variant>
Command parse_command(std::string_view line) {
if (line == "list") {
return ListCmd{};
}
if (line.starts_with("add ")) {
return AddCmd{std::string{line.substr(4)}};
}
if (line.starts_with("done ")) {
return ParseError{"done: parsing number not implemented yet"};
}
return ParseError{"unknown command"};
}
Да, тут есть оговорка: starts_with доступен в C++20, а мы работаем в современном C++ (C++23), так что это нормально. Если ваше окружение по какой-то причине без starts_with, то в вашем проекте это заменяется на проверку через find(...) == 0, но сейчас нам важнее идея variant.
Обработка результата через get_if
В этой лекции мы сознательно обходимся простыми проверками. Мы берём Command, и дальше аккуратно проверяем, что там лежит, через get_if.
#include <iostream>
#include <string>
#include <variant>
void handle_command(const Command& cmd) {
if (const AddCmd* p = std::get_if<AddCmd>(&cmd)) {
std::cout << "ADD: " << p->text << '\n';
return;
}
if (const ListCmd* = std::get_if<ListCmd>(&cmd)) {
std::cout << "LIST\n";
return;
}
if (const ParseError* e = std::get_if<ParseError>(&cmd)) {
std::cout << "ERROR: " << e->message << '\n';
return;
}
std::cout << "Unhandled command state\n";
}
Здесь есть два важных момента.
Первый: мы используем ранний return, чтобы код читался линейно, без «лесов» из else if.
Второй: мы не делаем никаких «опасных доставаний». Мы спрашиваем у variant: «ты сейчас AddCmd?» Если да — получили указатель, используем. Если нет — указатель nullptr, идём дальше.
6. std::monostate: явное состояние «ничего»
Иногда возникает ситуация: вы хотите хранить в variant состояние, когда «пока ничего не выбрано». Но мы уже обсудили, что variant не бывает пустым, а по умолчанию создаёт первую альтернативу. Поэтому стандартная практика — добавить в список альтернатив std::monostate. Это специальный пустой тип-заглушка.
Представьте UI-сценарий (даже консольный): «команда ещё не введена, но переменная для результата уже есть». Или «операция ещё не выполнялась». Вот там monostate очень кстати.
#include <variant>
using MaybeCommand = std::variant<std::monostate, AddCmd, DoneCmd, ListCmd, ParseError>;
Теперь «пустое состояние» выражено явно и типобезопасно: это std::monostate, а не «пустая строка в message» и не «index = 0, но это не индекс».
7. Типичные ошибки при работе со std::variant
Ошибка №1: доставать значение «на удачу», без проверки активной альтернативы.
Иногда очень хочется написать «ну я же знаю, что тут AddCmd», и попытаться извлечь его без проверки. В лучшем случае вы получите исключение или аварийное завершение (в зависимости от способа извлечения), в худшем — начнёте латать симптомы. Лечится это дисциплиной: сначала holds_alternative или get_if, потом работа со значением.
Ошибка №2: путать get_if и «обычный» get, а ещё забывать &.
std::get_if<T>(&v) требует адрес variant. Если передать не адрес, вы получите ошибку компиляции и будете думать, что «variant сломан». На самом деле сломан только вызов. Полезная привычка: проговаривать вслух «get_if берёт адрес variant и даёт указатель на значение».
Ошибка №3: проектировать альтернативы так, что они плохо различимы по смыслу.
variant<int, long long> или variant<int, unsigned> почти всегда превращается в головоломку: вы начинаете постоянно думать «а почему тут именно unsigned?» и ловите неявные преобразования. На старте курса лучше выбирать альтернативы так, чтобы они отражали разные состояния: Command или ParseError, int или std::string, «данные» или «ошибка».
Ошибка №4: пытаться использовать variant там, где достаточно optional.
Если у вас реально только два состояния «есть значение / нет значения», то optional будет проще, читабельнее и привычнее. variant хорош там, где состояний больше двух, или где важно выразить различие именно типами (например, «команда разных форм» или «успех/ошибка с разными данными»).
Ошибка №5: забывать, что variant по умолчанию создаёт первую альтернативу.
Новички иногда удивляются: «я создал variant, а там уже что-то есть». Да, есть: первый тип. Если вам нужно состояние «ничего», это сигнал добавить std::monostate первой альтернативой и относиться к нему как к нормальному состоянию программы, а не как к «ну тут вроде пусто».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ