JavaRush /Курси /C++ SELF /std::variant: значенн...

std::variant: значення як модель стану

C++ SELF
Рівень 23 , Лекція 0
Відкрита

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.

Зручно тримати в голові таку таблицю:

Інструмент Зміст Приклад «життєвої» моделі
std::optional<int>
або є
int
, або немає
знайшли індекс елемента / не знайшли
std::variant<int, std::string>
або
int
, або
std::string
результат може бути числом або текстовим повідомленням
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 першою альтернативою й ставитися до нього як до нормального стану програми, а не як до «ну тут наче порожньо».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ