A Quarta Pedra — Tratamento de Erros Coerente com std::expected

A Quarta Pedra — Tratamento de Erros Coerente com std::expected

Três ferramentas de erro convivendo sem critério viram bagunça. Aqui elas ganham uma política única para o banco inteiro: ausência prevista fica com optional, falha esperada com motivo vai para expected, e quebra de contrato continua sendo exceção. Com um tipo de erro próprio e a inserção migrada.
Linguagem C++

14 min de leitura

No artigo A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas nosso banco ganhou um motor de consultas, e ele já é funcional: armazena, valida e responde perguntas sobre os dados. Mas ao longo da construção, tomei decisões de tratamento de erros localmente — aqui um std::optional, ali uma exceção —, cada uma justificada no momento, sem uma visão do conjunto. Hoje damos coerência a isso, revisitando o tratamento de erros do banco como um todo à luz da Fase 6. Vamos estabelecer uma política clara — quando usar optional, quando expected, quando exceção — e refinar a API para que suas falhas sejam previsíveis e bem-comunicadas. É a pedra que transforma um banco que funciona num banco robusto, fechando a construção antes da retrospectiva final. E é a demonstração viva de que tratamento de erros não é um detalhe adicionado no fim, mas uma dimensão de projeto tão importante quanto a funcionalidade.

Uma política de erros para o banco inteiro

Antes de mexer no código, decidimos a política — o princípio que guiará cada escolha, aplicando a diretriz da Fase 6. Classificamos as falhas do banco em três categorias, cada uma com sua ferramenta:

Primeira categoria: ausências consultáveis e legítimas. "Esta linha tem a coluna X?", "existe um usuário com este id?". São perguntas cuja resposta "não" é normal e esperada. Ferramenta: std::optional. Já usamos isso no Row::get.

Segunda categoria: operações que podem falhar por razões que o chamador precisa conhecer e tratar. "Inserir esta linha" pode falhar por coluna faltando ou por tipo errado — e quem chama pode querer reagir diferente a cada motivo, ou reportá-lo. Ferramenta: std::expected<T, Erro>, que carrega o motivo. Vamos migrar a inserção para cá.

Terceira categoria: erros de programação — violações de expectativas que o código assume verdadeiras, como pedir como_inteiro() de um texto num ponto onde o tipo já deveria ser conhecido. Ferramenta: exceção, que falha ruidosamente para o bug ser corrigido. Mantemos isso nos acessores tipados.

Essa política é uma decisão de projeto, não uma regra universal — ela reflete que, no nosso banco, inserção é uma operação cujo fracasso é esperado e informativo (dados externos podem ser inválidos), enquanto acessar o tipo errado internamente é bug. Ter a política explícita torna cada escolha de código consistente e justificável.

Um tipo de erro rico

Para o expected, definimos um tipo de erro que carrega mais que uma string — um código e uma mensagem, permitindo ao chamador reagir programaticamente:

// include/minidb/erro.h
#pragma once
#include <string>

namespace minidb {

// Categorias de erro que o banco pode reportar.
enum class CodigoErro {
    ColunaFaltando,
    TipoIncorreto,
    ColunaDesconhecida
};

// Um erro rico: código (para o programa reagir) + mensagem (para humanos).
struct Erro {
    CodigoErro codigo;
    std::string mensagem;
};

}  // namespace minidb

O CodigoErro (um enum class, Fase 1) permite ao chamador ramificar por tipo de falha; a mensagem dá o detalhe humano. É o casamento que o expected viabiliza: falha com informação estruturada, sem o custo de exceções e sem a pobreza de um simples bool.

Migrando a inserção para expected

Agora refinamos o Table::inserir para devolver std::expected<void, Erro> em vez de lançar. O expected<void, E> representa "ou sucesso (sem valor a devolver), ou um erro E":

// include/minidb/table.h (assinatura refinada)
#include <expected>
#include "minidb/erro.h"

// Agora devolve sucesso ou um Erro descritivo, em vez de lançar.
std::expected<void, Erro> inserir(Row linha);
// src/table.cpp (implementação refinada)
#include "minidb/table.h"

namespace minidb {

std::expected<void, Erro> Table::inserir(Row linha) {
    // Valida contra o esquema, devolvendo um Erro específico em cada falha.
    for (const auto& col : esquema_) {
        auto v = linha.get(col.nome);
        if (!v)
            return std::unexpected(Erro{
                CodigoErro::ColunaFaltando,
                "falta a coluna '" + col.nome + "'"});
        if (v->tipo() != col.tipo)
            return std::unexpected(Erro{
                CodigoErro::TipoIncorreto,
                "tipo errado na coluna '" + col.nome + "'"});
    }
    linhas_.push_back(std::move(linha));   // sucesso: guarda a linha
    return {};   // expected<void> de sucesso
}

}  // namespace minidb

Cada falha retorna um Erro específico via std::unexpected; o sucesso retorna {} (o expected<void> bem-sucedido). O chamador agora não pode ignorar a falha silenciosamente — o tipo o obriga a lidar com ela, e pode reagir ao código específico. É a Fase 6 tornando as falhas parte visível da assinatura.

Usando a API robusta

O código cliente agora trata os erros de forma explícita e informada, escolhendo como reagir a cada categoria:

// src/main.cpp (trecho)
#include <iostream>
#include "minidb/table.h"
using namespace minidb;

void tenta_inserir(Table& t, Row linha) {
    auto resultado = t.inserir(std::move(linha));
    if (resultado) {
        std::cout << "inserido com sucesso\n";
    } else {
        // Reage ao ERRO com base no código e na mensagem.
        const Erro& e = resultado.error();
        std::cout << "falha ao inserir: " << e.mensagem;
        if (e.codigo == CodigoErro::TipoIncorreto)
            std::cout << " (verifique os tipos dos dados)";
        std::cout << '\n';
    }
}

int main() {
    Table usuarios({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});

    Row valida; valida.set("nome", "Ana"); valida.set("idade", int64_t{30});
    tenta_inserir(usuarios, valida);       // inserido com sucesso

    Row invalida; invalida.set("nome", "Bruno");   // falta idade
    tenta_inserir(usuarios, invalida);     // falha ao inserir: falta a coluna 'idade'
    return 0;
}

Saída:

inserido com sucesso
falha ao inserir: falta a coluna 'idade'

O chamador decide o que fazer com a falha — imprimir, tentar corrigir, propagar — e tem a informação (código + mensagem) para decidir bem. Compare com a versão anterior que lançava: agora a falha é um valor de retorno tratável, o fluxo normal do código lida com ela, e nada explode inesperadamente. Para uma operação cujo fracasso é esperado (dados podem ser inválidos), isso é mais robusto e mais claro que exceções.

A honestidade sobre a escolha entre expected e exceções

Prometo a franqueza de sempre, e ela é o cerne desta pedra. Não há uma resposta universalmente "certa" entre expected e exceções — há trade-offs reais, e vender uma como sempre superior seria desonesto. O expected torna as falhas visíveis e obrigatórias no tipo de retorno, o que é excelente para erros esperados e frequentes, mas polui as assinaturas e exige que cada nível da cadeia de chamadas propague o erro manualmente (if (!resultado) return resultado.error(); repetido) — verboso quando o erro precisa subir muitos níveis. As exceções, ao contrário, saltam automaticamente através dos níveis até um catch distante, mantendo o caminho feliz limpo, mas tornam o fluxo de controle menos visível e têm custo quando lançadas. A escolha depende de quão esperado é o erro e quão longe ele precisa viajar: erros esperados e tratados localmente favorecem expected; erros raros que sobem muitos níveis favorecem exceções. No nosso banco, inserção inválida é esperada e tratada pelo chamador imediato — expected encaixa. Um erro catastrófico de inicialização, que deveria abortar tudo, seria melhor como exceção. Reconhecer que a decisão depende do contexto — e ter uma política que a torna consistente — é a maturidade de engenharia que o curso inteiro cultivou. Erro é projeto.

Uma política de erros coerente vale mais que a escolha isolada de cada ferramenta: aqui, ausência prevista vira optional, falha esperada com motivo vira expected, e quebra de contrato continua sendo exceção. O expected<void, Erro> cobre o caso da operação que só precisa dizer se deu certo. O ganho concreto é que o erro deixa de ser um detalhe do fluxo e passa a constar da assinatura, obrigando quem chama a decidir o que fazer com ele.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/utility/expected: a referência de std::expected (C++23), com unexpected, error, value e a especialização para void.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre tratamento de erros: a discussão sobre quando preferir valores de erro a exceções.
  • ISO C++ Core Guidelines, seção "E" (Error handling), especialmente E.3 e a discussão sobre exceções versus códigos de erro: as diretrizes que embasam a política.
  • Herb Sutter, "Zero-overhead deterministic exceptions" (proposta P0709): contexto sobre o debate exceções versus retornos de erro no C++ moderno.
  • cppreference.com/w/cpp/error/error_code: uma alternativa da biblioteca padrão para códigos de erro, útil para comparar com nossa abordagem.

Exercícios

Exercício 1

Migre também o Row::exigir (os atalhos get_inteiro/get_texto) para uma versão que devolve std::expected<Value, Erro> em vez de lançar, e discuta se essa mudança é uma melhoria ou não, à luz da política de erros do banco.

Ver resposta

✓ Resposta: Row com expected (esboço) e discussão:

std::expected<Value, Erro> get_valor(const std::string& coluna) const {
    auto v = get(coluna);
    if (!v) return std::unexpected(Erro{CodigoErro::ColunaDesconhecida,
                                        "coluna inexistente: " + coluna});
    return *v;
}

Discussão: se get_inteiro/get_texto são usados em pontos onde o esquema garante a coluna (código interno que conhece a tabela), a versão que lança é adequada — pedir uma coluna inexistente ali é bug, e a exceção o expõe (categoria 3 da política). Migrar para expected só é melhoria se esses acessores forem chamados com nomes de coluna não confiáveis (vindos de consultas dinâmicas externas), caso em que a ausência vira condição esperada (categoria 2), e o expected força o tratamento. Ou seja: a mudança é melhoria se o contexto de uso for entrada não confiável, e é ruído verboso se o uso for interno com colunas conhecidas. A política do banco resolve isso pelo contexto — não há resposta única, e é por isso que ter a política explícita importa.

Exercício 2

Explique a diferença entre std::expected<void, Erro> e std::expected<int, Erro>. Por que a inserção usa a versão void?

Ver resposta

✓ Resposta: std::expected<void, Erro> representa uma operação que, em caso de sucesso, não produz nenhum valor a devolver — só importa se deu certo ou não —, carregando um Erro em caso de falha. std::expected<int, Erro> representa uma operação que, em sucesso, devolve um int, ou um Erro em falha. A inserção usa a versão void porque inserir uma linha não tem um "resultado" natural a retornar — a operação ou funciona (e a linha está guardada) ou falha (com o motivo); não há um valor de sucesso significativo a entregar. O expected<void, Erro> expressa exatamente "isto pode falhar com um erro, mas se der certo não há nada a devolver" — mais honesto que devolver um bool (que perderia o motivo) ou um valor artificial.

Exercício 3

Escreva um teste Catch2 para a nova inserir que retorna expected: um caso de sucesso (verifica que resultado é verdadeiro) e um de falha (verifica que resultado.error().codigo == CodigoErro::ColunaFaltando).

Ver resposta

✓ Resposta: Testes da inserção com expected:

#include <catch2/catch_test_macros.hpp>
#include "minidb/table.h"
using namespace minidb;

TEST_CASE("inserir válido retorna sucesso") {
    Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
    Row r; r.set("nome", "Ana"); r.set("idade", int64_t{30});
    auto resultado = t.inserir(std::move(r));
    REQUIRE(resultado.has_value());   // ou simplesmente REQUIRE(resultado)
}
TEST_CASE("inserir com coluna faltando retorna erro específico") {
    Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
    Row r; r.set("nome", "Bruno");   // falta idade
    auto resultado = t.inserir(std::move(r));
    REQUIRE_FALSE(resultado.has_value());
    REQUIRE(resultado.error().codigo == CodigoErro::ColunaFaltando);
}

O teste de falha verifica não só que houve erro, mas que foi o erro certo — o código específico —, garantindo que a API reporta a causa correta.

Exercício 4

Discuta a política de erros de três categorias do banco. Para cada operação a seguir, diga qual ferramenta (optional, expected ou exceção) você usaria e por quê: (a) buscar uma linha por índice que pode estar fora dos limites; (b) conectar o banco a um arquivo de persistência que não existe; (c) somar dois valores que deveriam ser numéricos mas um é texto.

Ver resposta

✓ Resposta: Aplicando a política de três categorias: - (a) Buscar linha por índice fora dos limites: depende do contrato. Se o índice vem de código que deveria garanti-lo válido, é bug → exceção (ou .at() que lança). Se o índice vem de entrada do usuário e "fora dos limites" é esperado, → optional<Row> (devolve "nada" para índice inválido), pois é uma ausência consultável e legítima. Para uma API pública de banco, optional é mais defensável. - (b) Conectar a um arquivo de persistência inexistente: exceção — se a persistência é essencial para o banco funcionar, não encontrar o arquivo na inicialização é uma falha grave e relativamente rara que impede a operação; lançar interrompe limpamente. (Alternativamente expected se o chamador puder razoavelmente recuperar-se criando o arquivo — mas para falha de infraestrutura essencial, exceção é comum.) - (c) Somar dois valores em que um deveria ser numérico mas é texto: exceção — no contexto interno do banco, isso indica dados inconsistentes ou um bug de esquema (a coluna deveria ser numérica); é uma violação de expectativa (categoria 3), que merece falhar ruidosamente. Se, porém, a soma processasse entrada dinâmica não validada, expected com TipoIncorreto seria melhor.

Exercício 5

Discuta o trade-off central entre std::expected e exceções levantado na aula. Descreva um cenário no nosso banco em que expected é claramente superior, e outro (hipotético, se estendêssemos o banco) em que exceções seriam a melhor escolha, justificando com os critérios de "quão esperado" e "quão longe viaja" o erro.

Ver resposta

✓ Resposta: O trade-off central: std::expected torna a falha visível e obrigatória na assinatura, ótimo para erros esperados tratados perto de onde ocorrem, mas exige propagação manual verbosa se o erro precisa subir muitos níveis; exceções saltam automaticamente para um tratador distante, mantendo o caminho feliz limpo, mas tornam o fluxo menos explícito e custam quando lançadas. Cenário no banco em que expected é claramente superior: a inserção de uma linha (que implementamos) — a falha (dados inválidos) é esperada (entrada pode estar errada) e tratada localmente pelo chamador imediato, que decide se corrige, reporta ou pula; o erro não viaja longe, e torná-lo visível no tipo força o tratamento consciente. expected encaixa perfeitamente. Cenário hipotético em que exceções seriam melhores: se estendêssemos o banco com carregamento de um arquivo de dados na inicialização, e uma corrupção grave do arquivo fosse detectada bem no fundo de uma cadeia de parsing (muitos níveis abaixo do main) — aí o erro é raro (arquivo corrompido é excepcional) e precisa viajar muito longe (do parser profundo até o main, que aborta a inicialização); propagá-lo com expected exigiria if (!r) return r.error(); em cada um dos muitos níveis intermediários, poluindo todo o caminho, enquanto uma exceção saltaria direto ao tratador no main, deixando os níveis intermediários limpos. Os critérios decidem: inserção — erro esperado, viaja pouco → expected; corrupção de arquivo — erro raro, viaja longe → exceção. A política do banco codifica esse julgamento, tornando-o consistente em vez de ad hoc.

Comentários

Mais em Linguagem C++

A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas
A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas

Consultar sem escrever laço exige que o filtro chegue como parâmetro, e é aí…

A Vida Começa Aqui — Inicialização de Membros a Fundo
A Vida Começa Aqui — Inicialização de Membros a Fundo

Inicializar membros parece assunto resolvido até aparecerem os casos em que…

Funções Anônimas e Capturas — Lambdas de Verdade
Funções Anônimas e Capturas — Lambdas de Verdade

O que distingue uma lambda de uma função comum é a captura: ela leva consigo o…