Um de Vários, ou o Valor e a Razão da Falha — std::variant e std::expected

Um de Vários, ou o Valor e a Razão da Falha — std::variant e std::expected

Quando o retorno pode ser de um entre vários tipos, ou precisa carregar a razão da falha junto, optional não basta. O variant guarda uma alternativa por vez e se percorre com std::visit; o expected, do C++23, devolve o valor ou o erro que explica o que houve. Com a fronteira entre eles e as exceções.
Linguagem C++

12 min de leitura

No artigo Um Valor que Pode Não Existir — std::optional o std::optional nos deu uma forma honesta de dizer "pode não haver valor". Mas encerrei apontando dois limites dele. Primeiro: e quando um valor pode ser de um entre vários tipos — ora um número, ora um texto, ora um erro? Segundo: e quando a ausência precisa vir acompanhada da razão da falha, não apenas do fato de que falhou? Hoje completamos o arsenal de tipos do C++ moderno para modelar dados com o std::variant (C++17), que guarda um valor que pode ser de um de vários tipos possíveis, e o std::expected (C++23), que representa "ou um valor, ou um erro que explica o que deu errado". Com estes três — optional, variant, expected —, você terá uma linguagem precisa para expressar ausência, alternativas e falhas, deixando para trás os truques frágeis do C.

variant: um valor de um entre vários tipos

Em C, quando um dado podia ser de tipos diferentes, você usava uma union — uma região de memória compartilhada por vários tipos, acompanhada de um enum para lembrar qual deles estava ativo. Era eficiente e extremamente perigoso: nada impedia você de gravar um int e ler como double, e o compilador não ajudava. O std::variant é a union segura: ele sabe qual tipo está guardado e impede acessos incorretos:

#include <iostream>
#include <variant>   // std::variant, std::get, std::holds_alternative (C++17)
#include <string>

int main() {
    // Um variant que pode conter OU um int, OU um double, OU uma string.
    std::variant<int, double, std::string> valor;

    valor = 42;          // agora contém um int
    std::cout << "é int? " << std::holds_alternative<int>(valor) << '\n';   // 1
    std::cout << std::get<int>(valor) << '\n';   // 42

    valor = "texto";     // agora contém uma string (o variant "troca" de tipo)
    std::cout << "é string? " << std::holds_alternative<std::string>(valor) << '\n'; // 1
    std::cout << std::get<std::string>(valor) << '\n';   // texto

    // Acessar como o tipo ERRADO lança exceção — segurança que a union não tinha.
    try {
        std::cout << std::get<int>(valor) << '\n';   // valor é string agora!
    } catch (const std::bad_variant_access& e) {
        std::cout << "acesso inválido: o variant não contém um int\n";
    }
    return 0;
}

O std::variant<int, double, std::string> pode conter um valor de qualquer um desses três tipos, um de cada vez. std::holds_alternative<T> pergunta se o tipo ativo é T; std::get<T> extrai o valor, mas lança std::bad_variant_access se você pedir o tipo errado — a segurança que a union crua jamais ofereceu. O variant sempre sabe qual tipo está guardando.

Visitando um variant: std::visit

Checar cada tipo à mão com uma cascata de if (holds_alternative...) funciona mas é deselegante. A forma idiomática de processar um variant é std::visit, que aplica uma função ao valor guardado, seja ele qual for — e o compilador garante que você tratou todos os tipos possíveis:

#include <iostream>
#include <variant>
#include <string>

int main() {
    std::variant<int, double, std::string> v = 3.14;

    // std::visit aplica a função certa conforme o tipo ativo.
    // Aqui, uma lambda genérica (auto) que serve a qualquer um dos tipos.
    std::visit([](const auto& x) {
        std::cout << "valor: " << x << '\n';
    }, v);   // imprime "valor: 3.14"

    return 0;
}

O [](const auto& x){ ... } é uma lambda genérica — o auto no parâmetro a faz aceitar qualquer tipo, uma ponte com os templates da Fase 5. std::visit a chama com o valor real guardado no variant. Isso é poderoso para modelar dados que têm "formas" alternativas: o resultado de um parser (número, string ou erro), o estado de uma máquina, uma mensagem que pode ter vários formatos.

expected: o valor ou a razão do erro

Agora o segundo limite do optional. Quando uma função falha, muitas vezes você quer saber por quê — "arquivo não encontrado" é diferente de "sem permissão". O optional só diz "não há valor". O std::expected<T, E> (C++23) resolve isso: ele contém ou um valor do tipo T (sucesso), ou um erro do tipo E (falha), carregando a informação da razão:

#include <iostream>
#include <expected>   // std::expected, std::unexpected (C++23)
#include <string>

// Retorna OU um int (sucesso) OU uma string de erro (falha).
std::expected<int, std::string> converte_para_int(const std::string& s) {
    if (s.empty())
        return std::unexpected("string vazia");
    for (char c : s)
        if (c < '0' || c > '9')
            return std::unexpected("caractere não-numérico: " + std::string(1, c));
    return std::stoi(s);   // sucesso: devolve o int
}

int main() {
    auto r1 = converte_para_int("123");
    if (r1)
        std::cout << "convertido: " << *r1 << '\n';        // convertido: 123
    else
        std::cout << "erro: " << r1.error() << '\n';

    auto r2 = converte_para_int("12a");
    if (r2)
        std::cout << "convertido: " << *r2 << '\n';
    else
        std::cout << "erro: " << r2.error() << '\n';       // erro: caractere não-numérico: a
    return 0;
}

O tipo std::expected<int, std::string> diz "ou um int, ou um erro descrito por uma string". Como no optional, ele converte para bool (verdadeiro em caso de sucesso) e *r acessa o valor; a novidade é r.error(), que acessa o erro quando houve falha. Você retorna sucesso com o valor direto e falha com std::unexpected(...). É o casamento perfeito entre a clareza do optional e a informação de um código de erro — sem o custo de exceções e sem valores mágicos. (Nota de versão importante: std::expected é C++23; se seu compilador ainda não o tem, o padrão é modelável com um variant ou com bibliotecas de terceiros. Sempre confirme.)

Escolhendo entre optional, variant, expected e exceções

Fecho com o mapa de decisão, porque agora você tem quatro ferramentas e precisa de critério. Use std::optional<T> quando o resultado é "um T ou nada", e o "nada" não precisa de explicação. Use std::expected<T, E> quando o resultado é "um T ou uma falha com motivo", e essa falha é esperada o suficiente para não merecer uma exceção. Use std::variant<A, B, C> quando um valor legitimamente pode ser de vários tipos diferentes como parte do design (não necessariamente erro — pode ser um dado poliforme). E use exceções (artigo Quando o Contrato se Quebra — Exceções e a Aliança com o RAII) quando o erro é verdadeiramente excepcional, raro, e atravessa muitas camadas até um ponto de tratamento distante. A regra unificadora: prefira tornar a possibilidade de falha ou variação visível no tipo de retorno (optional, expected, variant) para erros esperados, e reserve exceções para o inesperado. Tipos honestos comunicam ao chamador o que pode acontecer, e o compilador ajuda a não esquecer nenhum caso.

Com optional, variant e expected, o tipo de retorno passa a descrever o que a função realmente pode devolver, em vez de deixar a combinação por conta da documentação: ausência, alternativas ou falha com motivo. O expected é o que faltava para tratar erro sem exceção e sem perder a razão do erro pelo caminho — vale lembrar que ele é C++23, e nem todo compilador em uso já o oferece. Entre eles e as exceções, o critério continua o mesmo: o que é desfecho previsto vai no tipo, o que é quebra de contrato vai no throw.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie um std::variant<int, std::string> e escreva um código que, usando std::holds_alternative, imprima "número: X" se contiver um int ou "texto: Y" se contiver uma string. Teste com os dois casos.

Ver resposta

✓ Resposta: Com holds_alternative:

#include <iostream>
#include <variant>
#include <string>
void mostra(const std::variant<int, std::string>& v) {
    if (std::holds_alternative<int>(v))
        std::cout << "número: " << std::get<int>(v) << '\n';
    else
        std::cout << "texto: " << std::get<std::string>(v) << '\n';
}
int main() {
    mostra(42);              // número: 42
    mostra(std::string("oi")); // texto: oi
    return 0;
}

Exercício 2

Reescreva o código do exercício 1 usando std::visit com uma lambda genérica, e comente por que essa forma é preferível à cascata de holds_alternative.

Ver resposta

✓ Resposta: Com std::visit:

#include <iostream>
#include <variant>
#include <string>
void mostra(const std::variant<int, std::string>& v) {
    std::visit([](const auto& x) {
        std::cout << "valor: " << x << '\n';
    }, v);
}
int main() {
    mostra(42);
    mostra(std::string("oi"));
    return 0;
}

Essa forma é preferível porque std::visit trata todos os tipos do variant de uma vez, e o compilador garante que a lambda seja aplicável a cada alternativa — se você adicionar um novo tipo ao variant e a lambda não o cobrir, o código não compila, evitando o esquecimento silencioso. A cascata de holds_alternative, ao contrário, precisa ser editada manualmente a cada tipo novo, e esquecer um caso passa despercebido até o runtime. (A lambda genérica aqui funciona porque int e std::string ambos suportam <<; para tratamentos diferentes por tipo, usa-se um "overload set" de lambdas, que a documentação detalha.)

Exercício 3

Escreva uma função std::expected<double, std::string> raiz_quadrada(double x) que devolva a raiz se x >= 0, ou um erro descritivo se x for negativo. Demonstre o tratamento dos dois casos com .value()/* e .error().

Ver resposta

✓ Resposta: Raiz com expected:

#include <iostream>
#include <expected>
#include <string>
#include <cmath>
std::expected<double, std::string> raiz_quadrada(double x) {
    if (x < 0)
        return std::unexpected("raiz de número negativo");
    return std::sqrt(x);
}
int main() {
    auto r1 = raiz_quadrada(16);
    if (r1) std::cout << "raiz: " << *r1 << '\n';          // raiz: 4
    else    std::cout << "erro: " << r1.error() << '\n';

    auto r2 = raiz_quadrada(-4);
    if (r2) std::cout << "raiz: " << r2.value() << '\n';
    else    std::cout << "erro: " << r2.error() << '\n';   // erro: raiz de número negativo
    return 0;
}

Exercício 4

Explique a diferença essencial entre std::optional<int> e std::expected<int, std::string> como tipos de retorno. Em que situação a informação extra do expected justifica preferi-lo ao optional?

Ver resposta

✓ Resposta: std::optional<int> representa "um int ou nada" — quando vazio, ele não carrega nenhuma informação sobre por que está vazio. std::expected<int, std::string> representa "um int ou um erro descrito por uma string" — quando falha, ele carrega o motivo da falha, acessível por .error(). A informação extra do expected justifica preferi-lo quando o chamador precisa reagir diferente conforme a causa da falha, ou reportá-la ao usuário. Por exemplo, numa conversão de texto para número: com optional, você só sabe que não converteu; com expected, sabe se foi por a string estar vazia, ter um caractere inválido, ou estourar o limite — e pode dar mensagens específicas. Quando o "porquê" da ausência importa, expected; quando "não há valor" basta, optional.

Exercício 5

Você está projetando a função de um interpretador que avalia uma expressão e pode: (a) retornar um número, (b) retornar um texto, ou (c) falhar com uma mensagem de erro. Discuta como modelar o tipo de retorno usando as ferramentas desta e da aula anterior, e justifique sua escolha entre variant, expected e combinações.

Ver resposta

✓ Resposta: O interpretador tem três desfechos: número, texto, ou erro com mensagem. A modelagem mais expressiva combina as ferramentas: o sucesso já é polimorfo (número ou texto), o que sugere um std::variant<double, std::string> para o valor bem-sucedido; e a falha precisa de motivo, o que sugere std::expected. Unindo os dois:

std::expected<std::variant<double, std::string>, std::string> avalia(const std::string& expr);
// Sucesso: um variant que é OU número OU texto.
// Falha: uma string com a mensagem de erro.

Justificativa: o expected externo separa claramente "deu certo" de "deu errado, e aqui está o porquê" — ideal porque um interpretador quer reportar erros de sintaxe com mensagens úteis. O variant interno modela que um resultado bem-sucedido pode legitimamente ser de dois tipos (o interpretador pode avaliar tanto 2+2 → número quanto "a"+"b" → texto). Não se usaria um variant<double, string, Erro> de três vias porque isso misturaria o eixo "sucesso versus falha" com o eixo "qual tipo de sucesso" — separá-los em expected<variant<...>, Erro> deixa o código de tratamento mais claro: primeiro checa sucesso/falha, depois, no sucesso, visita o variant. É um exemplo de compor os tipos do C++ moderno para espelhar exatamente a estrutura do problema.

Comentários

Mais em Linguagem C++

Extensão — C++ no Mundo da Web e da Persistência
Extensão — C++ no Mundo da Web e da Persistência

Usar C++ para servir web é escolha de requisito, não de gosto: ganha onde…

Pare de Escrever Laços — a Biblioteca <algorithm>
Pare de Escrever Laços — a Biblioteca <algorithm>

Escrever o laço à mão conta como percorrer; chamar o algoritmo diz o que se…

Quando Copiar Dá Errado — a Regra dos Três e dos Cinco
Quando Copiar Dá Errado — a Regra dos Três e dos Cinco

Uma classe que possui um recurso e o libera no destrutor funciona bem — até…