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\n"; // зараз int
}
}
Тут думка проста: ми не вгадуємо, ми перевіряємо.
Безпечне діставання: 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 << "це не рядок\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{"потрібен рівно один символ"};
char c = s[0];
if (c < '0' || c > '9') return std::string{"це не цифра"};
return static_cast<int>(c - '0');
}
Тут тип каже правду: це або int, або string. Жодних «-1 — це помилка, але тільки якщо ви памʼятаєте».
Так, далі доведеться акуратно дістати значення через get_if або holds_alternative, але це чесна ціна за те, що зміст «помилка» й «успіх» більше не змішується в одному числі.
5. Приклад TodoCLI: variant як результат парсингу
Тепер зберемо невеликий фрагмент застосунку, який будемо «вирощувати» далі протягом курсу. Нехай це буде дуже проста консольна програма для списку справ. Сьогодні наша мета — не зробити повноцінний список справ, а навчитися моделювати результат розбору команди через 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: розбір числа ще не реалізовано"};
}
return ParseError{"невідома команда"};
}
Так, тут є застереження: 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 << "ПОМИЛКА: " << e->message << '\n';
return;
}
std::cout << "Необроблений стан команди\n";
}
Тут є два важливі моменти.
Перший: ми використовуємо ранній return, щоб код читався лінійно, без каскадів із else if.
Другий: ми не робимо жодних «небезпечних діставань». Ми запитуємо у variant: «ти зараз AddCmd?» Якщо так — отримали вказівник і використовуємо його. Якщо ні — маємо nullptr й ідемо далі.
6. std::monostate: явний стан «нічого»
Іноді виникає ситуація: ви хочете зберігати в variant стан, коли «поки що нічого не вибрано». Але ми вже обговорили, що variant не буває порожнім, а за замовчуванням створює першу альтернативу. Тому стандартна практика — додати до списку альтернатив std::monostate. Це спеціальний порожній тип-заглушка.
Уявіть сценарій інтерфейсу, навіть консольного: «команду ще не введено, а змінна для результату вже є». Або «операцію ще не виконували». Саме тут 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 першою альтернативою й ставитися до нього як до нормального стану програми, а не як до «ну тут наче порожньо».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ