CodeGym /Cursos /C++ SELF /std::visit: processamento de std::variant via visitor

std::visit: processamento de std::variant via visitor

C++ SELF
Nível 23, Lição 1
Disponível

1. Por que usar std::visit

Quando você está começando com std::variant, tudo parece simples: “chequei o tipo — peguei o valor — fiz o que precisava”. Mas assim que as alternativas passam a ser mais que duas, e o processamento é um pouco mais complexo que “só imprimir na tela”, o código começa a inflar. Em certo ponto você se pega escrevendo um mini-interpreter de tipos manualmente, e isso já não é aprender C++, é trabalho voluntário de mover tijolos sem luvas.

Imagine que temos o resultado do parsing de um comando em uma aplicação de console (vamos evoluir o mesmo projeto didático — um mini-tracker de tarefas). O parser pode retornar ou um comando, ou um erro. Ainda não usamos std::expected (isso ficará para a próxima aula), então por agora std::variant é suficiente.

Suponha algo assim:


#include <string>
#include <variant>

struct CmdAdd { std::string text; };
struct CmdList {};
struct ParseError { std::string message; };

using ParseResult = std::variant<CmdAdd, CmdList, ParseError>;

Se você processar isso com get_if, vira uma "selva" de verificações. Funciona, mas escala mal:

#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';
    }
}

O problema nem é a quantidade de linhas. É que a responsabilidade fica espalhada: fácil esquecer de adicionar uma nova ramificação, fácil confundir a ordem, fácil complicar até que até o autor tenha dor para ler. E é aí que std::visit entra em cena.

Ideia do std::visit: obter o tipo real e processá-lo

std::visit é a forma padrão de processar um std::variant: você fornece um "handler" (visitor), e ele chama esse handler com um argumento do tipo real que está atualmente dentro do variant. No fim você escreve a lógica como se já tivesse o tipo concreto, em vez de uma "caixa surpresa".

Se alguma vez você achou que a STL às vezes parece "inventamos a roda, mas ainda dá para fritar ovo nela", então std::visit é um exemplo de boa roda: uma ferramenta útil, prática e realmente comum no código.

Formalmente std::visit tem a forma:

std::visit(visitor, variant_value);

Onde visitor é qualquer "invocable": função, lambda, objeto com operator(). E a melhor parte: o visitor deve saber lidar com cada alternativa. Isso não é "capricho do instrutor", é ideia de segurança de tipos: se você esquecer um dos casos, o compilador não deixará passar.

2. Sintaxe básica de std::visit

Uma lambda genérica "para todas as ocasiões"

Comece com situações em que a ação é igual para todas as alternativas. Por exemplo, "simplesmente imprimir". Aqui a lambda genérica recebe const auto&, e isso basta.

Exemplo pequeno (o importante é a sensação: visit escolhe o que passar):

#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);
}

O que acontece "na prática": dentro de v está um std::string, então visit chama sua lambda com std::string — sem verificações manuais ou ramificações por tipo.

Mas logo você verá a realidade: para tipos diferentes precisa de comportamentos diferentes. Um número você trata como número, string como texto, erro como erro. E aí vem o melhor: visitor via overloads.

Overloads de funções livres + std::visit

Quando um iniciante ouve "visitor", às vezes espera mágica de design pattern tipo "vamos escrever 12 classes e uma interface e tudo piora". Mas em C++ moderno para casos simples o visitor se monta facilmente com overloads de funções comuns — algo que você já conhece.

Primeiro crie um conjunto de funções com o mesmo nome, mas parâmetros diferentes:

#include <string>

int score(int x) {
    return x;
}

int score(const std::string& s) {
    return static_cast<int>(s.size());
}

Agora use std::visit para chamar o overload correto automaticamente:

#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
}

O truque psicológico: dentro da lambda não há "ramificação por tipo". Você escreve score(x), e o overload é escolhido pelo compilador com base no tipo real de x. Ou seja, visit entrega o tipo correto, e o overload faz o comportamento adequado.

Visitor como struct com overloads de operator()

Às vezes é mais conveniente manter o handler como um objeto com várias overloads de operator(). Assim o visitor vira uma "tabela de reações": para um tipo — isto, para outro — aquilo.

Voltando ao nosso mini-projeto "TaskTracker", definimos comandos:

#include <string>

struct CmdAdd { std::string text; };
struct CmdList {};
struct ParseError { std::string message; };

Fazemos o variant:

#include <variant>

using Parsed = std::variant<CmdAdd, CmdList, ParseError>;

E agora o visitor-handler:

#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';
    }
};

Uso:

#include <variant>

void debug_print(const Parsed& p) {
    std::visit(PrintVisitor{}, p);
}

O bom é que você não pode "esquecer" um tipo. Se amanhã você adicionar CmdDone e não atualizar o visitor — o compilador vai reclamar. E não no ambiente de produção para o usuário que só queria marcar a tarefa como feita.

Retornando valor de std::visit: tipo único de resultado

Situação comum: você quer não só "fazer algo", mas calcular um resultado. Por exemplo, transformar qualquer alternativa do variant em string (formato unificado de logs/output), ou obter um código de retorno, ou calcular o "peso" do comando.

std::visit é uma expressão com um único tipo de resultado. Portanto, todos os overloads do visitor devem retornar tipos compatíveis (normalmente idênticos).

Vamos formatar o resultado do parsing:

#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;
    }
};

Aplicando:

#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
}

Se por acidente um overload retornar std::string e outro retornar int, visit não "adivinhará" sua intenção. Ele dirá honestamente: "não há um tipo único de resultado". E isso é bom — caso contrário metade da linguagem seria telepatia.

3. Exemplo: mini-CLI de tracker de tarefas

Parser que retorna std::variant

Agora vamos montar o cenário para ver por que std::visit é útil não "no vácuo", mas em código real. Imagine uma aplicação de console que lê uma linha e tenta reconhecer um comando. Não estamos fazendo o parser perfeito — o importante é a arquitetura do resultado.

Que os comandos sejam assim:
add Texto da tarefa, list, e em caso de erro — ParseError.

Parser bem simples (muito didático, sem heroísmos):

#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>'"};
}

Agora o "motor" da aplicação guarda tarefas:

#include <string>
#include <vector>

struct Task {
    std::string text;
};

using Tasks = std::vector<Task>;

Executando comandos via visitor com contexto

E é aqui que std::visit brilha: queremos executar o comando, e cada tipo tem sua ação.

#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';
    }
};

Processamento de uma linha:

void process_line(Tasks& tasks, const std::string& line) {
    Parsed p = parse_line(line);
    std::visit(ExecVisitor{tasks}, p);
}

Note um detalhe pequeno, mas importante: o visitor guarda uma referência Tasks& tasks. Essa é uma maneira normal e clara de passar contexto. Você não precisa de variáveis globais nem "singletons mágicos". O visitor é um objeto comum.

Mini-demo:

#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>'
}

Assim fica o processamento "saudável" do variant: o parser retorna um resultado tipado, e o executor lida com ele centralmente via visit.

Por que isso é mais conveniente do que cadeias de if/else com get_if

Ao programar com get_if, você costuma pensar: "eu verifico com cuidado, então é seguro". Sim, é seguro — mas frágil em termos de design. Quando aparece um novo tipo de resultado (novo comando, novo erro), você precisa atualizar manualmente todos os lugares onde fez essas verificações.

Com std::visit o estilo fica mais "contratual". O variant diz: "estas são minhas alternativas". O visitor responde: "ok, eu sei processar cada uma". E o compilador faz o papel de notário: assinaturas batem — tudo legal.

Útil ter em mente esta ideia (não formal, só para organizar o pensamento):

flowchart TD
    A["Parsed (variant)"] -->|std::visit| B["Visitor"]
    B --> C["operator()(CmdAdd)"]
    B --> D["operator()(CmdList)"]
    B --> E["operator()(ParseError)"]

visit literalmente roteia a chamada para o operator() correto.

4. Erros típicos ao usar std::visit

Erro #1: o visitor não trata uma das alternativas do variant.
Acontece quando você adiciona um novo tipo ao std::variant, por exemplo CmdDone, mas esquece de criar o overload operator()(const CmdDone&). Manualmente, usando get_if, o programa poderia compilar e apenas não executar a ação desejada. Com std::visit o compilador reclamará — e isso é bom: melhor erro em compilação do que comportamento silencioso em produção.

Erro #2: dentro do visitor tentar novamente extrair valor do variant.
Às vezes alunos escrevem um visitor que aceita const auto& x, e dentro fazem std::get_if no variant original. Isso é redundante: visit já entregou o tipo concreto. Se você volta a acessar o variant, é como receber a pizza e sair para comprar ingredientes. Tecnicamente possível, mas desnecessário.

Erro #3: tipos de retorno diferentes entre overloads.
Se um overload retorna std::string e outro retorna int, o compilador não consegue deduzir um tipo único de resultado para std::visit. Isso ocorre quando "em uma ramificação quero texto de erro, em outra quero número". Escolha um tipo comum antecipadamente — geralmente std::string ou int (códigos), ou faça o visitor retornar void e trate/registre os efeitos nas ramificações.

Erro #4: armazenar no visitor referências para objetos já destruídos.
O visitor é um objeto; se ele guarda uma referência a Tasks&, essa Tasks deve viver mais que a chamada a std::visit. Nos exemplos isso vale, porque Tasks vive em main e o visitor é criado imediatamente antes do visit. Mas ao retornar o visitor de uma função ou armazená-lo para uso futuro, você pode enfrentar problemas de lifetime. Regra prática: crie o visitor perto do std::visit e não o transforme em um "serviço de longa vida".

Erro #5: transformar o visitor em "uma enorme função de 500 linhas".
É tentador: já que visit é "o ponto único de processamento", vamos colocar tudo ali. Na prática isso vira um monólito. Prefira manter overloads curtos e mover lógica pesada para funções separadas como execute_add(tasks, cmd) ou print_error(err). Assim o visit permanece um dispatcher, não um depósito de responsabilidades.

1
Tarefa
C++ SELF, nível 23, lição 1
Bloqueado
Rótulo do valor
Rótulo do valor
1
Tarefa
C++ SELF, nível 23, lição 1
Bloqueado
Resposta do comando
Resposta do comando
1
Tarefa
C++ SELF, nível 23, lição 1
Bloqueado
Perímetro do mundo
Perímetro do mundo
1
Tarefa
C++ SELF, nível 23, lição 1
Bloqueado
Comandos da pilha
Comandos da pilha
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION