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
- cppreference.com/w/cpp/utility/variant: a referência completa de
std::variant, comget,holds_alternative,visitebad_variant_access. - cppreference.com/w/cpp/utility/expected: a referência de
std::expected(C++23), comerror,valueeunexpected. - Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre
variant,optionale tratamento alternativo de erros: a visão integrada dos tipos-soma. - cppreference.com/w/cpp/utility/variant/visit: os detalhes de
std::visite o padrão de visitação, incluindo lambdas genéricas. - ISO C++ Core Guidelines, discussões sobre error handling com tipos de retorno: quando preferir
expected/optionala exceções.
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.