No artigo A Primeira Pedra — Uma Célula Poliforme com std::variant assentamos a primeira pedra, o Value poliforme. Hoje empilhamos: valores viram linhas, e linhas viram uma tabela — o coração do nosso banco de dados. Esta é a peça onde a Fase 2 (RAII, Regra do Zero) e a Fase 4 (STL) brilham juntas: a tabela vai possuir suas linhas via containers da biblioteca padrão, sem um único new ou delete, e vai proteger invariantes de esquema (Fase 3), como "toda linha inserida respeita as colunas declaradas". Ao fim desta aula, você poderá criar uma tabela, definir suas colunas, inserir registros e listá-los — o banco começa a se comportar como um banco. E, mais uma vez, veremos o curso inteiro operando: containers, encapsulamento, const-correctness, move semantics e gestão automática de memória, tudo numa peça coesa.
A Linha: um registro como mapa de valores
Uma linha é um registro — um conjunto de valores indexados por nome de coluna. A escolha natural de container é um mapa (Fase 4). Encapsulamos num tipo Row que oferece acesso seguro:
// include/minidb/row.h
#pragma once
#include <map>
#include <string>
#include <optional>
#include "minidb/value.h"
namespace minidb {
class Row {
public:
// Define o valor de uma coluna (insere ou sobrescreve).
void set(const std::string& coluna, Value valor) {
campos_.insert_or_assign(coluna, std::move(valor)); // move: sem cópia (Fase 2)
}
// Acesso seguro: devolve o valor da coluna, ou nullopt se ela não existe (Fase 6).
std::optional<Value> get(const std::string& coluna) const {
auto it = campos_.find(coluna); // find: sem criar chave fantasma (Fase 4)
if (it == campos_.end())
return std::nullopt;
return it->second;
}
// Atalhos tipados que combinam get + acesso ao tipo.
int64_t get_inteiro(const std::string& coluna) const {
return exigir(coluna).como_inteiro();
}
const std::string get_texto(const std::string& coluna) const {
return exigir(coluna).como_texto();
}
bool tem(const std::string& coluna) const { return campos_.count(coluna) > 0; }
const std::map<std::string, Value>& campos() const { return campos_; }
private:
// Lança se a coluna não existe — usado pelos atalhos tipados.
Value exigir(const std::string& coluna) const {
auto v = get(coluna);
if (!v) throw std::runtime_error("Row: coluna inexistente: " + coluna);
return *v;
}
std::map<std::string, Value> campos_; // coluna → valor (ordenado por nome)
};
} // namespace minidb
Decisões idiomáticas: insert_or_assign insere ou atualiza sem o risco do operator[] (que criaria chaves fantasmas — a armadilha do artigo Buscas Instantâneas — map, set e suas Versões Hash); get devolve std::optional<Value> para o caso legítimo de coluna ausente (Fase 6); os atalhos tipados (get_inteiro, get_texto) combinam a busca com o acesso ao tipo, lançando via exigir quando a coluna falta. O membro é um std::map, então a Row tem a Regra do Zero de graça — nenhuma função especial a escrever (Fase 2).
A Tabela: o coração, com esquema e invariantes
Agora a tabela. Ela conhece seu esquema (nomes e tipos das colunas), possui suas linhas, e protege o invariante de que toda linha respeita esse esquema. Aqui a Fase 3 (invariantes) encontra a Fase 4 (containers):
// include/minidb/table.h
#pragma once
#include <vector>
#include <string>
#include <utility>
#include "minidb/row.h"
#include "minidb/tipos.h"
namespace minidb {
// Uma coluna do esquema: nome + tipo.
struct Coluna {
std::string nome;
Tipo tipo;
};
class Table {
public:
// Construída com seu esquema. O esquema é fixo após a criação (invariante).
explicit Table(std::vector<Coluna> esquema) : esquema_(std::move(esquema)) {}
// Insere uma linha, validando-a contra o esquema. Lança se inválida.
void inserir(Row linha);
// Acesso de leitura às linhas (const&: sem cópia — Fase 1).
const std::vector<Row>& linhas() const { return linhas_; }
const std::vector<Coluna>& esquema() const { return esquema_; }
std::size_t tamanho() const { return linhas_.size(); }
private:
// Verifica que a linha tem as colunas do esquema, com os tipos certos.
void validar(const Row& linha) const;
std::vector<Coluna> esquema_; // as colunas, fixas
std::vector<Row> linhas_; // as linhas — a Tabela as POSSUI (RAII, Fase 2)
};
} // namespace minidb
E a implementação, com a validação que protege o invariante:
// src/table.cpp
#include "minidb/table.h"
#include <stdexcept>
namespace minidb {
void Table::validar(const Row& linha) const {
// Cada coluna do esquema deve estar presente e com o tipo correto.
for (const auto& col : esquema_) {
auto v = linha.get(col.nome);
if (!v)
throw std::runtime_error("inserção: falta a coluna '" + col.nome + "'");
if (v->tipo() != col.tipo)
throw std::runtime_error("inserção: tipo errado na coluna '" + col.nome + "'");
}
}
void Table::inserir(Row linha) {
validar(linha); // protege o invariante ANTES de guardar
linhas_.push_back(std::move(linha)); // move a linha para dentro do vetor (Fase 2)
}
} // namespace minidb
Repare como as fases convergem. A tabela possui esquema_ e linhas_ como std::vector — quando a Table morre, tudo é liberado automaticamente, sem destrutor escrito (Regra do Zero, Fase 2). O inserir valida contra o esquema antes de guardar (invariante da Fase 3: nenhuma linha inconsistente entra), e usa std::move para transferir a linha sem copiar (Fase 2). Os acessores devolvem const& (Fase 1). É o banco de dados começando a ter integridade estrutural — ele recusa dados que violem seu esquema.
Colocando para funcionar
Uma pequena demonstração mostra as duas pedras trabalhando juntas — valores em linhas, linhas em tabela:
// src/main.cpp (trecho)
#include <iostream>
#include "minidb/table.h"
using namespace minidb;
int main() {
// Cria a tabela com esquema: nome (texto), idade (inteiro).
Table usuarios({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
// Monta e insere linhas.
Row ana;
ana.set("nome", "Ana");
ana.set("idade", int64_t{30});
usuarios.inserir(ana);
Row bruno;
bruno.set("nome", "Bruno");
bruno.set("idade", int64_t{25});
usuarios.inserir(bruno);
// Lista todas as linhas.
std::cout << "Total: " << usuarios.tamanho() << " usuários\n";
for (const auto& linha : usuarios.linhas()) // const auto&: sem cópia (Fase 4)
std::cout << linha.get_texto("nome") << ", "
<< linha.get_inteiro("idade") << " anos\n";
return 0;
}
Saída:
Total: 2 usuários
Ana, 30 anos
Bruno, 25 anos
E a integridade em ação: tentar inserir uma linha sem a coluna idade, ou com idade sendo texto, lançaria uma exceção clara — o banco protege sua consistência. Com testes (que você escreverá nos exercícios) cobrindo inserção válida e inválida, a segunda pedra está assentada.
A honestidade sobre as escolhas de container
Prometo a franqueza de sempre. Escolhi std::vector<Row> para as linhas e std::map<std::string, Value> para os campos, e essas escolhas têm trade-offs reais que valem a transparência. O vector de linhas dá inserção rápida no fim e travessia eficiente (ótimo para "listar todas" e "filtrar"), mas remoção no meio é O(n) e não há índice para busca rápida por valor — se o banco precisasse de "encontre o usuário com id X" em tabelas enormes, um índice (um map auxiliar de id→posição) seria necessário. O map nos campos mantém as colunas ordenadas por nome e dá acesso O(log n); um unordered_map seria O(1) mas sem ordem. Para um mini banco didático, essas escolhas são boas e simples; para um banco real com milhões de linhas, indexação e estruturas mais sofisticadas entrariam em cena. Reconhecer que as escolhas atuais servem ao escopo atual — e saber o que mudaria em escala — é a maturidade de engenharia que o curso cultivou. Não há container "certo" em abstrato; há o certo para os requisitos, medidos.
Linha e tabela mostram os containers da STL fazendo o trabalho pesado sem que se escreva uma linha de gestão de memória. A tabela guarda o esquema e é ela quem defende o invariante: linha sem coluna obrigatória ou com tipo divergente não entra, e a checagem mora num lugar só. Sobre o std::vector<Row>, vale ter presente que ele é ótimo para inserir no fim e percorrer, e péssimo para buscar por chave — o dia em que isso pesar, o que muda é a estrutura, não a interface.
Fontes e leituras recomendadas
- cppreference.com/w/cpp/container/map/insert_or_assign: o método que usamos para inserir/atualizar campos sem o risco do
operator[]. - cppreference.com/w/cpp/container/vector: revisão do container que possui as linhas, com suas garantias de complexidade.
- Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulos sobre containers e classes: contexto para as escolhas de armazenamento.
- ISO C++ Core Guidelines, regras C.2 (classe com invariante) e SL.con.2 (preferir vector): as diretrizes que embasam o design da Tabela.
- cppreference.com/w/cpp/utility/optional: o tipo usado no
Row::getpara representar coluna ausente.
Exercícios
Exercício 1
Escreva testes Catch2 para a Table: um que insere uma linha válida e confirma o tamanho, um que tenta inserir uma linha faltando uma coluna (deve lançar), e um que tenta inserir com o tipo errado numa coluna (deve lançar).
Ver resposta
✓ Resposta: Testes da Table:
#include <catch2/catch_test_macros.hpp>
#include "minidb/table.h"
using namespace minidb;
Row faz_usuario(const std::string& nome, int64_t idade) {
Row r; r.set("nome", nome); r.set("idade", idade); return r;
}
TEST_CASE("inserção válida aumenta o tamanho") {
Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
t.inserir(faz_usuario("Ana", 30));
REQUIRE(t.tamanho() == 1);
}
TEST_CASE("inserção com coluna faltando lança") {
Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
Row incompleta; incompleta.set("nome", "Ana"); // falta idade
REQUIRE_THROWS_AS(t.inserir(incompleta), std::runtime_error);
}
TEST_CASE("inserção com tipo errado lança") {
Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
Row errada; errada.set("nome", "Ana"); errada.set("idade", "trinta"); // idade como texto
REQUIRE_THROWS_AS(t.inserir(errada), std::runtime_error);
}
Exercício 2
Explique por que o Row::get devolve std::optional<Value> em vez de lançar quando a coluna não existe, enquanto os atalhos get_inteiro/get_texto lançam. Que filosofia de tratamento de erros (Fase 6) justifica cada escolha?
Ver resposta
✓ Resposta: Row::get devolve std::optional<Value> porque perguntar "esta linha tem tal coluna?" é uma consulta legítima e esperada — o chamador pode razoavelmente não saber se a coluna existe, e a ausência é uma resposta normal, não um erro. O optional expressa isso honestamente e força o chamador a tratar o caso "não tem". Já os atalhos get_inteiro/get_texto lançam porque eles são usados quando o chamador afirma conhecer a coluna e seu tipo — pedir get_inteiro("idade") numa tabela cujo esquema tem "idade" é uma operação que deveria sempre funcionar; se a coluna falta, é um erro de programação (esquema mal-entendido), que merece falhar ruidosamente. É exatamente a filosofia da Fase 6: optional para ausência esperada e consultável; exceção para violação de uma expectativa que o código assume verdadeira. As duas convivem, cada uma no seu papel.
Exercício 3
No Table::inserir, explique o papel do std::move(linha) no push_back. O que seria copiado desnecessariamente sem ele, considerando que Row contém um std::map de Values?
Ver resposta
✓ Resposta: O std::move(linha) transfere o conteúdo interno da Row (seu std::map<std::string, Value>, com todos os valores dentro) diretamente para dentro do vetor, em vez de copiá-lo. Sem o move, push_back(linha) faria uma cópia profunda da linha: duplicaria o mapa inteiro, alocando novos nós e copiando cada Value (incluindo cada std::string dentro dos valores de texto). Como linha é um parâmetro por valor que vai morrer ao fim de inserir, essa cópia seria puro desperdício — duplicar tudo só para descartar o original. Com std::move, o mapa é movido (transferência de ponteiros internos, tempo constante), e a linha original fica vazia e é descartada sem custo. É move semantics (Fase 2) evitando a cópia de uma estrutura potencialmente grande.
Exercício 4
Adicione à Table um método void remover_se(...) que remova todas as linhas satisfazendo uma condição dada por uma lambda. (Dica: use o idioma erase-remove com std::remove_if do artigo Pare de Escrever Laços — a Biblioteca <algorithm>.) Escreva um teste.
Ver resposta
✓ Resposta: Método remover_se com erase-remove:
// em table.h, na parte public:
template <typename Predicado>
void remover_se(Predicado cond) {
linhas_.erase(
std::remove_if(linhas_.begin(), linhas_.end(), cond),
linhas_.end());
}
// teste:
TEST_CASE("remover_se remove as linhas que casam") {
Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
t.inserir(faz_usuario("Ana", 30));
t.inserir(faz_usuario("Bruno", 25));
t.inserir(faz_usuario("Carla", 40));
// Remove quem tem menos de 35 anos:
t.remover_se([](const Row& r){ return r.get_inteiro("idade") < 35; });
REQUIRE(t.tamanho() == 1); // sobra só Carla
}
O remover_se é um template (Fase 5) que aceita qualquer predicado (lambda), e usa o idioma erase-remove (Fase 4) para remover com segurança, sem os bugs de invalidação de iterador.
Exercício 5
Discuta a escolha de std::vector<Row> para as linhas. Descreva um requisito realista (por exemplo, "buscar por id milhões de vezes por segundo") sob o qual essa escolha seria inadequada, e proponha como você adaptaria a estrutura de dados, justificando o trade-off.
Ver resposta
✓ Resposta: std::vector<Row> é uma boa escolha para o caso geral: inserção no fim e travessia completa (filtrar, listar) são eficientes, e a contiguidade favorece o cache. Mas ela seria inadequada sob um requisito como "buscar uma linha específica por id milhões de vezes por segundo": num vector, encontrar a linha com um dado id exige percorrer linearmente todas as linhas (O(n) por busca), o que em milhões de buscas sobre uma tabela grande seria proibitivamente lento. Adaptação: manter, ao lado do vector<Row>, um índice — um std::unordered_map<int64_t, std::size_t> que mapeia id → posição da linha no vetor, atualizado a cada inserção/remoção. Aí, a busca por id vira O(1) em média (consulta ao índice) em vez de O(n). O trade-off: o índice consome memória adicional e precisa ser mantido consistente com o vetor a cada modificação (mais código, mais chance de bug se malfeito), e só vale a pena se buscas por id forem de fato frequentes — para uma tabela raramente consultada por id, o índice seria custo sem benefício. A decisão, como sempre no C++, guia-se pelos requisitos medidos: adicione o índice quando o profiler mostrar que a busca linear é o gargalo, não antes. É a mesma disciplina de "meça antes de otimizar" da Fase 4, aplicada ao design do banco.