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: " << add->text << '\n';
} else if (std::get_if<CmdList>(&r)) {
std::cout << "LIST\n";
} else if (const auto* e = std::get_if<ParseError>(&r)) {
std::cout << "ERROR: " << 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 << "ADD: " << c.text << '\n';
}
void operator()(const CmdList&) const {
std::cout << "LIST\n";
}
void operator()(const ParseError& e) const {
std::cout << "ERROR: " << 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 "error: " + e.message;
}
};
Применим:
#include <iostream>
#include <variant>
std::string format(const Parsed& p) {
return std::visit(FormatVisitor{}, p);
}
int main() {
Parsed p = ParseError{"unknown command"};
std::cout << format(p) << '\n'; // error: unknown command
}
Если вы случайно сделаете одну перегрузку возвращающей 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{"expected 'list' or '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 << "added\n"; // added
}
void operator()(const CmdList&) const {
std::cout << "tasks=" << tasks.size() << '\n';
}
void operator()(const ParseError& e) const {
std::cout << "error: " << 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 buy milk"); // added
process_line(tasks, "list"); // tasks=1
process_line(tasks, "wat"); // error: expected 'list' or '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 остаётся диспетчером, а не свалкой ответственности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ