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), comunexpected,error,valuee a especialização paravoid. - 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.