1. Навіщо потрібен std::visit
Коли ви лише починаєте знайомитися зі std::variant, усе виглядає доволі мило: «перевірив тип — дістав значення — зробив, що треба». Але щойно альтернатив стає більше ніж дві, а обробка виявляється трохи складнішою за «просто вивести на екран», код починає розростатися. У якийсь момент ви ловите себе на тому, що вручну пишете мініінтерпретатор типів, і це вже не навчання C++, а добровільне перекладання цеглин без рукавиць.
Уявіть, що ми маємо результат парсингу команди в консольному застосунку. Ми поступово розвиватимемо той самий навчальний проєкт — мінітрекер задач. Парсер може повертати або команду, або помилку. Поки що ми не використовуємо std::expected — про нього поговоримо в наступній лекції, — тож на цьому етапі нам цілком достатньо std::variant.
Припустімо, маємо таке:
#include <string>
#include <variant>
struct CmdAdd { std::string text; };
struct CmdList {};
struct ParseError { std::string message; };
using ParseResult = std::variant<CmdAdd, CmdList, ParseError>;
Якщо обробляти це через get_if, вийде цілий «ліс» перевірок. Такий підхід працює, але масштабується погано:
#include <iostream>
void handle(const ParseResult& r) {
if (const auto* add = std::get_if<CmdAdd>(&r)) {
std::cout << "ДОДАТИ: " << add->text << '\n';
} else if (std::get_if<CmdList>(&r)) {
std::cout << "СПИСОК\n";
} else if (const auto* e = std::get_if<ParseError>(&r)) {
std::cout << "ПОМИЛКА: " << e->message << '\n';
}
}
Проблема навіть не в кількості рядків. Річ у тім, що відповідальність розпорошується: легко забути додати нову гілку, легко переплутати порядок, легко ускладнити все настільки, що читати код стане боляче навіть автору. І ось тут на сцену виходить std::visit.
Ідея std::visit: отримати реальний тип і обробити його
std::visit — це стандартний спосіб обробити std::variant: ви передаєте йому «обробник» — visitor, — а він викликає цей обробник з аргументом реального типу, який саме зараз зберігається всередині variant. У підсумку ви пишете логіку так, ніби у вас уже є конкретний тип, а не «коробка із сюрпризом».
Якщо вам колись здавалося, що стандартна бібліотека C++ інколи працює за принципом «ми винайшли велосипед, але на ньому ще можна смажити яєчню», то std::visit — якраз приклад хорошого велосипеда: це важливий, практичний і справді часто вживаний інструмент.
Формально std::visit виглядає так:
std::visit(visitor, variant_value);
Де visitor — це будь-який «викличний обʼєкт»: функція, лямбда або обʼєкт із operator(). А тепер найприємніше: visitor має вміти обробити кожну альтернативу. Це не «побажання автора курсу», а сама ідея типобезпечності: якщо ви забули обробити один із варіантів, компілятор не дасть вам «випадково забути».
2. Базовий синтаксис std::visit
Одна generic-лямбда для всіх випадків
Починати знайомство зі std::visit найкраще із ситуації, де дія однакова для всіх альтернатив. Наприклад, коли треба «просто вивести на екран». Тут generic-лямбда приймає const auto&, і цього цілком достатньо.
Ось мікроприклад. Тут важливе саме відчуття: visit сам обирає, що передати:
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> v = std::string{"hello"};
std::visit([](const auto& x) {
std::cout << x << '\n'; // hello
}, v);
}
Якщо пояснювати простими словами, то всередині v лежить std::string, отже visit викликає вашу лямбду з std::string — без ручних перевірок і без розгалуження за типом.
Але доволі швидко ви натрапите на реальність: для різних типів потрібна різна поведінка. Число ви хочете трактувати як число, рядок — як текст, помилку — як помилку. І ось тут починається найцікавіше: visitor через перевантаження.
Перевантаження вільних функцій + std::visit
Коли новачок уперше чує слово «visitor», він іноді очікує «патернової магії» рівня «зараз ми напишемо 12 класів і один інтерфейс, і все стане тільки гірше». Але в сучасному C++ для простих задач visitor легко зібрати зі звичайного перевантаження функцій — тобто з того, що ви вже знаєте.
Спочатку створімо набір функцій з одним іменем, але різними параметрами:
#include <string>
int score(int x) {
return x;
}
int score(const std::string& s) {
return static_cast<int>(s.size());
}
А тепер використаймо std::visit, щоб автоматично викликати потрібне перевантаження:
#include <iostream>
#include <string>
#include <variant>
int score(int x);
int score(const std::string& s);
int main() {
std::variant<int, std::string> v = std::string{"abcd"};
int s = std::visit([](const auto& x) {
return score(x);
}, v);
std::cout << s << '\n'; // 4
}
Ключова ідея тут проста: усередині лямбди немає «розгалуження за типом». Ви пишете score(x), а перевантаження компілятор обирає за реальним типом x. Тобто visit відповідає за те, щоб «доставити правильний тип», а перевантаження — за те, щоб «виконати правильну поведінку».
Visitor як struct із перевантаженим operator()
Іноді зручніше тримати обробник не як набір вільних функцій, а як один обʼєкт, який має кілька перевантажень operator(). Тоді visitor виглядає як «таблиця реакцій»: для такого типу — одна поведінка, для іншого — інша.
Повернімося до нашого мініпроєкту «TaskTracker» і визначмо команди:
#include <string>
struct CmdAdd { std::string text; };
struct CmdList {};
struct ParseError { std::string message; };
Створімо variant:
#include <variant>
using Parsed = std::variant<CmdAdd, CmdList, ParseError>;
А тепер — visitor-обробник:
#include <iostream>
struct PrintVisitor {
void operator()(const CmdAdd& c) const {
std::cout << "ДОДАТИ: " << c.text << '\n';
}
void operator()(const CmdList&) const {
std::cout << "СПИСОК\n";
}
void operator()(const ParseError& e) const {
std::cout << "ПОМИЛКА: " << e.message << '\n';
}
};
Використання:
#include <variant>
void debug_print(const Parsed& p) {
std::visit(PrintVisitor{}, p);
}
Тут добре те, що ми не можемо «забути» про тип. Якщо завтра ви додасте CmdDone, а visitor не оновите, компілятор нагадає. Причому одразу, а не вже в продакшені в користувача, який просто хотів позначити задачу як виконану.
Повернення значення зі std::visit: єдиний тип результату
Часта ситуація: ви хочете не просто «щось зробити», а обчислити результат. Наприклад, перетворити будь-яку альтернативу variant на рядок — для єдиного формату логів або виводу, — отримати код повернення або порахувати «вагу» команди.
std::visit — це вираз, і в нього один тип результату. Отже, усі перевантаження visitor мають повертати сумісний тип, зазвичай один і той самий.
Зробімо форматування результату парсингу:
#include <string>
struct FormatVisitor {
std::string operator()(const CmdAdd& c) const {
return "add \\"" + c.text + "\\"";
}
std::string operator()(const CmdList&) const {
return "list";
}
std::string operator()(const ParseError& e) const {
return "помилка: " + e.message;
}
};
Застосуймо:
#include <iostream>
#include <variant>
std::string format(const Parsed& p) {
return std::visit(FormatVisitor{}, p);
}
int main() {
Parsed p = ParseError{"невідома команда"};
std::cout << format(p) << '\n'; // помилка: невідома команда
}
Якщо ви випадково зробите одне перевантаження таким, що повертає std::string, а інше — int, то visit не «вгадає», що ви мали на увазі. Він чесно скаже: «у мене немає єдиного типу результату». І це нормально, бо інакше половина мови перетворилася б на телепатію.
3. Приклад: міні-CLI-трекер задач
Парсер, що повертає std::variant
Тепер зберімо все докупи, щоб було видно, навіщо std::visit потрібен не «у вакуумі», а в реальному коді. Уявіть консольний застосунок, який читає рядок і намагається розпізнати команду. На цьому етапі ми не будуємо ідеальний парсер — нам важливіше побачити архітектуру обробки результату.
Нехай команди будуть такі:
add текст задачі, list, а в разі помилки — ParseError.
Найпростіший парсер — дуже навчальний, без зайвого героїзму:
#include <string>
#include <string_view>
Parsed parse_line(std::string_view line) {
if (line == "list") return CmdList{};
if (line.starts_with("add ")) return CmdAdd{std::string(line.substr(4))};
return ParseError{"очікувалося 'list' або 'add <text>'"};
}
Тепер застосунок зберігає задачі:
#include <string>
#include <vector>
struct Task {
std::string text;
};
using Tasks = std::vector<Task>;
Виконання команд через visitor із контекстом
І ось тут std::visit особливо доречний: ми хочемо виконати команду, і для кожного типу потрібна своя дія.
#include <iostream>
struct ExecVisitor {
Tasks& tasks;
void operator()(const CmdAdd& c) const {
tasks.push_back(Task{c.text});
std::cout << "додано\n"; // додано
}
void operator()(const CmdList&) const {
std::cout << "tasks=" << tasks.size() << '\n';
}
void operator()(const ParseError& e) const {
std::cout << "помилка: " << e.message << '\n';
}
};
Запуск обробки одного рядка:
void process_line(Tasks& tasks, const std::string& line) {
Parsed p = parse_line(line);
std::visit(ExecVisitor{tasks}, p);
}
Зверніть увагу на маленьку, але важливу деталь: visitor зберігає посилання Tasks& tasks. Це нормальний і зрозумілий спосіб передати контекст обробки. Вам не потрібні глобальні змінні чи «магічні синглтони». Visitor — звичайний обʼєкт.
Мінідемо:
#include <iostream>
int main() {
Tasks tasks;
process_line(tasks, "add купити молоко"); // додано
process_line(tasks, "list"); // tasks=1
process_line(tasks, "wat"); // помилка: очікувалося 'list' або 'add <text>'
}
Саме так виглядає впорядкована обробка variant: парсер повертає типізований результат, а виконавець централізовано обробляє його через visit.
Чому це зручніше, ніж ланцюжки if/else if з get_if
Коли ви пишете через get_if, зазвичай думаєте так: «я ж акуратно все перевіряю, отже це безпечно». Так, безпечно, але крихко з погляду дизайну. Щойно у вас зʼявляється ще один тип результату — нова команда або новий тип помилки, — доводиться вручну оновлювати всі місця, де ви робили перевірки.
Підхід зі std::visit виходить більш «контрактним». variant каже: «ось усі мої альтернативи». Visitor відповідає: «гаразд, я вмію обробити кожну». А компілятор виступає нотаріусом: якщо все узгоджено, значить усе на місці.
Корисно тримати в голові ось таку схему — не формальну, а «для розуміння»:
flowchart TD
A["Parsed (variant)"] -->|std::visit| B["Visitor"]
B --> C["operator()(CmdAdd)"]
B --> D["operator()(CmdList)"]
B --> E["operator()(ParseError)"]
visit буквально виконує «маршрутизацію» виклику до потрібного operator().
4. Типові помилки під час використання std::visit
Помилка № 1: visitor не обробляє одну з альтернатив variant.
Це виглядає так: ви додали новий тип у std::variant, наприклад CmdDone, але забули додати перевантаження operator()(const CmdDone&). Якби ви робили ручні перевірки через get_if, програма могла б скомпілюватися і просто мовчки не зробити потрібного. З std::visit ви отримаєте помилку компіляції, і це якраз добре: краще нехай свариться компілятор, ніж користувач.
Помилка № 2: усередині visit знову намагатися діставати значення з variant.
Іноді студенти пишуть visitor, який приймає const auto& x, а потім усередині викликає std::get_if для початкового variant. Це зайве: visit уже передав вам конкретний тип. Якщо ви знову «лізете» в variant, це схоже на ситуацію, коли після доставки піци ви йдете в магазин по інгредієнти. Технічно можна, але навіщо.
Помилка № 3: різні типи повертаних значень у різних перевантаженнях.
Якщо одне перевантаження повертає std::string, а інше — int, компілятор не зможе вивести єдиний тип результату std::visit. У навчальних задачах це часто трапляється, коли «в одній гілці хочу повернути текст помилки, а в іншій — число». Тому спільний тип результату потрібно вибрати заздалегідь. Найчастіше це або std::string, або int — як код, — або ж ви взагалі робите обробку без повернення, тобто повертаєте void і все друкуєте чи змінюєте стан у гілках.
Помилка № 4: зберігати у visitor посилання на обʼєкти, які вже знищено.
Visitor — це обʼєкт. Якщо він зберігає Tasks&, то цей Tasks має жити довше, ніж виклик std::visit. У наших прикладах так і є, бо Tasks живе в main, а visitor створюється безпосередньо перед visit. Але якщо почати повертати visitor із функції або зберігати його кудись «на потім», можна натрапити на проблеми часу життя. На цьому етапі вам достатньо простого правила: створюйте visitor поруч зі std::visit і не намагайтеся перетворити його на «довгоживучий сервіс».
Помилка № 5: перетворювати visitor на «величезну функцію на 500 рядків».
Спокуса велика: раз уже visit — це «єдина точка обробки», то можна вмістити туди все на світі. На практиці це закінчується тим, що ви знову отримуєте моноліт. Краще тримати перевантаження короткими, а важку логіку виносити у звичайні функції на кшталт execute_add(tasks, cmd) або print_error(err). Тоді visit лишається диспетчером, а не звалищем відповідальності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ