A Segunda Pedra — Linhas e Tabelas com RAII e STL

A Segunda Pedra — Linhas e Tabelas com RAII e STL

Com a célula pronta, entram a linha e a tabela — e com elas a pergunta de quem guarda o quê. A tabela mantém o esquema e defende o invariante: linha incompleta ou com tipo divergente não entra. O texto discute também por que o vector serve bem aqui e onde ele deixaria de servir.
Linguagem C++

13 min de leitura

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

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.

Comentários

Mais em Linguagem C++

A Filosofia por Trás do C++ e a Saída Elegante do printf
A Filosofia por Trás do C++ e a Saída Elegante do printf

Três detalhes aparecem na primeira linha de qualquer programa C++: o iostream…

Operações Indivisíveis — std::atomic
Operações Indivisíveis — std::atomic

Quando basta um contador ou uma flag, o mutex é peso desnecessário: o atomic…

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong
Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong

Detectar que dois retângulos se tocam são quatro comparações; responder à…