Um Valor que Pode Não Existir — std::optional

Um Valor que Pode Não Existir — std::optional

Combinar que -1 significa ausência funciona até alguém esquecer de verificar. O optional move a ausência para dentro do tipo, e a assinatura passa a anunciar que pode não haver resultado. Aqui estão value_or, value() e o perigo de desreferenciar com asterisco sem confirmar que há valor.
Linguagem C++

11 min de leitura

No artigo Quando o Contrato se Quebra — Exceções e a Aliança com o RAII vimos as exceções e a diretriz que as governa: elas servem para erros excepcionais, não para condições esperadas e corriqueiras. Mas isso deixou uma pergunta prática no ar. Como uma função comunica, de forma limpa, que às vezes simplesmente não tem um valor a devolver — uma busca que não encontrou nada, uma conversão que não deu certo, um campo opcional que estava vazio? Em C, os truques eram todos frágeis: devolver um ponteiro que pode ser nulo, reservar um valor "mágico" como -1 para significar "nada", ou usar um parâmetro de saída com um bool de sucesso. Cada um tem armadilhas. O C++17 trouxe uma resposta elegante e segura: o std::optional, um tipo que representa explicitamente "um valor que pode ou não estar presente". Hoje você aprende a usá-lo, e ele vai mudar como você projeta funções que às vezes não têm resposta.

O problema dos valores mágicos e ponteiros nulos

Pense numa função que procura a posição de um caractere numa string e devolve o índice. E se o caractere não existir? A tradição do C oferece saídas ruins:

// Abordagem C, problemática: -1 significa "não achou".
int acha(const std::string& s, char c) {
    for (int i = 0; i < (int)s.size(); ++i)
        if (s[i] == c) return i;
    return -1;   // valor "mágico" — e se -1 fosse um índice válido em outro contexto?
}
// O chamador PRECISA saber que -1 é especial, e lembrar de checá-lo.
// Se esquecer, usa -1 como índice e causa comportamento indefinido.

O problema é que -1 não carrega a informação "isto é ausência" — é só um número que, por convenção, significa erro. Nada obriga o chamador a checá-lo, e nada impede que ele use o -1 como se fosse um índice real. A std::string::npos que você viu na Fase 1 é uma versão dessa mesma ideia — um valor sentinela. Funciona, mas depende da disciplina de quem chama. O optional torna a ausência parte do tipo.

optional: a ausência vira parte do tipo

Um std::optional<T> é uma caixa que ou contém um T, ou está vazia. O tipo em si anuncia "pode não haver valor aqui", e o compilador ajuda a garantir que você lide com os dois casos:

#include <iostream>
#include <optional>   // std::optional, std::nullopt (C++17)
#include <string>

// O tipo de retorno JÁ DIZ: "pode ou não haver um índice".
std::optional<int> acha(const std::string& s, char c) {
    for (int i = 0; i < (int)s.size(); ++i)
        if (s[i] == c)
            return i;         // devolve um valor presente
    return std::nullopt;      // devolve "vazio" explicitamente
}

int main() {
    std::string texto = "programacao";

    auto pos = acha(texto, 'g');
    if (pos) {                          // um optional converte para bool: tem valor?
        std::cout << "achei em " << *pos << '\n';   // *pos acessa o valor: achei em 3
    } else {
        std::cout << "não achei\n";
    }

    auto pos2 = acha(texto, 'z');
    if (!pos2)
        std::cout << "'z' não está no texto\n";
    return 0;
}

O retorno std::optional<int> comunica na assinatura que a função pode não ter resposta. Dentro, return i; devolve um valor presente e return std::nullopt; devolve o vazio explícito. No uso, um optional converte para bool (verdadeiro se tem valor), e *pos acessa o valor guardado — a mesma sintaxe de desreferência dos ponteiros, mas sem os perigos deles. A diferença crucial em relação ao -1: o tipo obriga o leitor a reconhecer que pode não haver valor, e acessar o valor exige um passo deliberado.

Acessando com segurança: value(), value_or() e o perigo do *

Há três formas de pegar o valor de dentro, e a distinção importa. *opt e opt-> acessam diretamente, mas são comportamento indefinido se o optional estiver vazio — use-os só depois de checar. opt.value() acessa com verificação: lança std::bad_optional_access se estiver vazio (a rede de segurança das exceções da aula passada). E opt.value_or(padrao) devolve o valor se presente, ou um padrão que você fornece se vazio — muitas vezes a forma mais limpa:

#include <iostream>
#include <optional>
#include <string>

std::optional<std::string> nome_do_usuario(int id) {
    if (id == 1) return "Ana";
    return std::nullopt;   // usuário desconhecido
}

int main() {
    // value_or: dá um padrão elegante quando não há valor.
    std::cout << nome_do_usuario(1).value_or("desconhecido") << '\n';   // Ana
    std::cout << nome_do_usuario(99).value_or("desconhecido") << '\n';  // desconhecido

    // value(): lança se vazio (útil quando ausência seria realmente um erro).
    auto n = nome_do_usuario(1);
    std::cout << n.value() << '\n';   // Ana

    // *  : rápido, mas SÓ após checar. Desreferenciar vazio = comportamento indefinido.
    if (n) std::cout << *n << '\n';   // seguro aqui, porque checamos
    return 0;
}

value_or é especialmente útil: nome_do_usuario(99).value_or("desconhecido") expressa "o nome, ou 'desconhecido' se não houver" em uma linha legível. A regra de bolso: use value_or quando há um padrão sensato, value() quando a ausência deveria ser um erro tratável, e */-> apenas depois de confirmar a presença com um if.

Quando usar optional (e quando não)

Prometo sempre a medida. O optional brilha para representar ausência esperada e legítima de um valor único: uma busca que pode não achar, um campo de configuração que pode não estar preenchido, uma conversão que pode falhar sem que isso seja catastrófico. Ele deixa a assinatura honesta e força o tratamento dos dois casos. Mas ele não é para tudo. Se a "ausência" na verdade carrega o motivo da falha (não só "não deu", mas "não deu porque o arquivo não existe" versus "porque não há permissão"), o optional é insuficiente — ele diz que não há valor, mas não por quê. Para isso existe o std::expected (C++23), que veremos no próximo artigo. E se a condição for um erro verdadeiramente excepcional, uma exceção (artigo Quando o Contrato se Quebra — Exceções e a Aliança com o RAII) ainda é a escolha. O optional ocupa o meio-termo valioso: "pode não haver valor, e isso é normal, e não preciso explicar por quê".

O optional move a ausência para dentro do tipo: em vez de combinar que -1 significa "não achei", a assinatura passa a dizer que pode não haver resultado, e o compilador cobra que isso seja tratado. O acesso é onde mora o risco — value_or quando existe um padrão razoável, value() quando a ausência é excepcional o bastante para justificar exceção, e * apenas depois de confirmar que há valor, porque desreferenciar um optional vazio é comportamento indefinido.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma função std::optional<double> divide_segura(double a, double b) que devolva o resultado da divisão, ou std::nullopt se b for zero. Demonstre os dois casos no main.

Ver resposta

✓ Resposta: Divisão segura:

#include <iostream>
#include <optional>
std::optional<double> divide_segura(double a, double b) {
    if (b == 0.0) return std::nullopt;
    return a / b;
}
int main() {
    if (auto r = divide_segura(10, 2))
        std::cout << "resultado: " << *r << '\n';   // resultado: 5
    if (!divide_segura(1, 0))
        std::cout << "divisão por zero evitada\n";
    return 0;
}

O if (auto r = divide_segura(10, 2)) declara e testa o optional numa linha — idioma comum.

Exercício 2

Explique por que devolver std::optional<int> é mais seguro que devolver -1 para sinalizar "não encontrado". Cite pelo menos dois problemas do -1 que o optional resolve.

Ver resposta

✓ Resposta: Devolver std::optional<int> é mais seguro que -1 por, ao menos, dois motivos. Primeiro, o tipo carrega a informação de ausência: optional<int> anuncia na assinatura que pode não haver valor, obrigando o leitor e o compilador a reconhecerem os dois casos; -1 é só um int comum, e nada no tipo avisa que ele é especial. Segundo, elimina a colisão com valores válidos: -1 pode ser um resultado legítimo em muitos contextos (um saldo, uma diferença, um deslocamento), então usá-lo como "erro" é ambíguo; o estado vazio do optional é distinto de qualquer valor de int, sem sobreposição. Um terceiro: acessar o valor de um optional exige um passo deliberado (*, value()), enquanto usar -1 acidentalmente como índice é fácil e causa comportamento indefinido silencioso.

Exercício 3

Dada a função nome_do_usuario da aula, escreva três acessos ao resultado: um com value_or, um com value() dentro de um try/catch, e um com * protegido por if. Comente quando cada forma é apropriada.

Ver resposta

✓ Resposta: Os três acessos:

#include <iostream>
#include <optional>
#include <string>
// (nome_do_usuario da aula)
int main() {
    // value_or: apropriado quando há um padrão sensato para "ausente".
    std::cout << nome_do_usuario(99).value_or("visitante") << '\n';

    // value() em try/catch: apropriado quando a ausência é um erro a tratar.
    try {
        std::cout << nome_do_usuario(99).value() << '\n';
    } catch (const std::bad_optional_access&) {
        std::cout << "usuário inexistente\n";
    }

    // * com if: apropriado quando você já vai ramificar no fluxo mesmo.
    auto n = nome_do_usuario(1);
    if (n) std::cout << *n << '\n';
    return 0;
}

value_or é ideal quando um valor padrão faz sentido; value() com try/catch quando a ausência é excepcional o bastante para justificar tratamento explícito; * com if quando o fluxo já se divide naturalmente entre "tem" e "não tem".

Exercício 4

O código abaixo tem um bug perigoso. Aponte-o e corrija.

#include <optional>
#include <iostream>
std::optional<int> busca(int chave);   // pode devolver vazio
int main() {
    auto r = busca(42);
    std::cout << *r << '\n';   // e se busca não achou?
    return 0;
}
Ver resposta

✓ Resposta: O bug: *r desreferencia o optional sem checar se ele tem valor. Se busca(42) devolveu std::nullopt, *r é comportamento indefinido — acesso a um valor inexistente. Correções possíveis:

#include <optional>
#include <iostream>
std::optional<int> busca(int chave);
int main() {
    auto r = busca(42);
    if (r)                                  // checa antes de desreferenciar
        std::cout << *r << '\n';
    else
        std::cout << "não encontrado\n";
    // ou, em uma linha, com padrão:
    std::cout << busca(42).value_or(-1) << '\n';
    return 0;
}

A primeira forma trata os dois casos; a segunda fornece um padrão. Ambas eliminam o acesso desprotegido.

Exercício 5

Discuta a fronteira entre std::optional e exceções. Dê um exemplo de situação em que optional é a escolha certa e outra em que uma exceção seria mais apropriada, justificando com a diretriz do artigo.

Ver resposta

✓ Resposta: A fronteira: std::optional serve para ausência esperada e normal de um valor, onde "não há resultado" é um desfecho legítimo e comum; exceções servem para erros excepcionais que impedem a função de cumprir seu contrato. Exemplo em que optional é a escolha certa: procurar um usuário por ID num cache — não achar é rotineiro (o usuário pode simplesmente não estar no cache), faz parte do funcionamento normal, e o chamador naturalmente lida com "achou/não achou"; um optional<Usuario> expressa isso com clareza e sem o custo de uma exceção. Exemplo em que exceção é mais apropriada: abrir o banco de dados na inicialização do programa — falhar aqui é raro e catastrófico, impede o sistema de operar, e não há "caminho normal" que siga sem o banco; lançar uma exceção interrompe limpamente e sinaliza a gravidade. A diretriz: se a ausência é um resultado comum e sem drama, optional; se é uma falha rara que quebra o contrato, exceção.

Comentários

Mais em Linguagem C++

Extensão — A Arte de Integrar: Dependências e Bibliotecas de Terceiros
Extensão — A Arte de Integrar: Dependências e Bibliotecas de Terceiros

A ausência de um gerenciador de pacotes universal explica por que integrar…

O Conforto do C++ Moderno — Structured Bindings e Dedução de Tipos
O Conforto do C++ Moderno — Structured Bindings e Dedução de Tipos

Dar nome ao que vinha como .first e .second muda mais a leitura do que parece…

Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores
Ensinando Símbolos ao seu Tipo — Sobrecarga de Operadores

Somar com mais, comparar com igual, imprimir direto no cout: operadores são…