JavaRush /Курсы /C++ SELF /std::visit: обработка std::variant через visitor

std::visit: обработка std::variant через visitor

C++ SELF
23 уровень , 1 лекция
Открыта

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 остаётся диспетчером, а не свалкой ответственности.

1
Задача
C++ SELF, 23 уровень, 1 лекция
Недоступна
Метка значения
Метка значения
1
Задача
C++ SELF, 23 уровень, 1 лекция
Недоступна
Ответ команды
Ответ команды
1
Задача
C++ SELF, 23 уровень, 1 лекция
Недоступна
Периметр мира
Периметр мира
1
Задача
C++ SELF, 23 уровень, 1 лекция
Недоступна
Команды стека
Команды стека
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ