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í que template e lambda deixam de ser exercício e viram ferramenta. Além da interface encadeada, o artigo trata da comparação entre valores poliformes e do custo da avaliação ansiosa, que copia linhas a cada etapa.
Linguagem C++

13 min de leitura

No artigo A Segunda Pedra — Linhas e Tabelas com RAII e STL nosso banco ganhou tabelas que armazenam linhas com integridade de esquema. Mas armazenar e listar é o mínimo — o que torna um banco de dados útil é consultar: filtrar registros por condições, ordená-los, transformá-los. Hoje construímos o motor de consultas, e esta é a pedra onde a Fase 5 (templates e lambdas) e a Fase 4 (algoritmos da STL) se encontram no auge. Vamos implementar filtrar (que recebe uma condição como lambda), ordenar_por (usando std::sort), e — a cereja — uma interface fluente que permite encadear operações como .filtrar(...).ordenar_por(...), aplicando o *this da Fase 3. Ao fim, o banco responderá perguntas sobre seus dados, e você verá o poder combinado de tudo que aprendeu numa única e elegante API.

O conceito: consultas que produzem resultados

A ideia central do design é separar a tabela (que possui os dados) do resultado de consulta (uma visão sobre um subconjunto das linhas). Uma consulta não modifica a tabela; ela produz um novo conjunto de linhas selecionadas. Modelamos isso com uma classe Query que carrega as linhas resultantes e oferece operações que devolvem novas Query — permitindo o encadeamento. Eis a interface:

// include/minidb/query.h
#pragma once
#include <vector>
#include <functional>
#include <algorithm>
#include "minidb/row.h"

namespace minidb {

class Query {
public:
    // Construída a partir de uma coleção de linhas (cópia das selecionadas).
    explicit Query(std::vector<Row> linhas) : linhas_(std::move(linhas)) {}

    // FILTRAR: mantém só as linhas que satisfazem o predicado (uma lambda).
    // Recebe qualquer coisa chamável que receba const Row& e devolva bool.
    template <typename Predicado>
    Query filtrar(Predicado cond) const {
        std::vector<Row> selecionadas;
        std::copy_if(linhas_.begin(), linhas_.end(),
                     std::back_inserter(selecionadas), cond);   // algoritmo STL (Fase 4)
        return Query(std::move(selecionadas));   // devolve NOVA Query (encadeável)
    }

    // ORDENAR_POR: ordena as linhas por uma coluna. Devolve nova Query.
    Query ordenar_por(const std::string& coluna, bool crescente = true) const {
        std::vector<Row> ordenadas = linhas_;   // cópia para não mexer na original
        std::sort(ordenadas.begin(), ordenadas.end(),
            [&coluna, crescente](const Row& a, const Row& b) {
                auto va = a.get(coluna);
                auto vb = b.get(coluna);
                bool menor = comparar_menor(*va, *vb);
                return crescente ? menor : !menor;
            });
        return Query(std::move(ordenadas));
    }

    // Acesso aos resultados.
    const std::vector<Row>& linhas() const { return linhas_; }
    std::size_t tamanho() const { return linhas_.size(); }

private:
    static bool comparar_menor(const Value& a, const Value& b);   // no .cpp
    std::vector<Row> linhas_;
};

}  // namespace minidb

O filtrar é um template (Fase 5): aceita qualquer predicado — tipicamente uma lambda — que receba const Row& e devolva bool. Ele usa std::copy_if (Fase 4) com std::back_inserter para coletar as linhas que passam. Cada operação devolve uma nova Query, e é isso que permite o encadeamento: o resultado de filtrar é uma Query sobre a qual você chama ordenar_por. Repare que Table ganhará um método consultar() que devolve uma Query inicial com todas as suas linhas, abrindo a cadeia.

A comparação de valores: ordenar tipos poliformes

O ordenar_por precisa comparar dois Value — que podem ser inteiros, reais ou textos. A comparação depende do tipo, e usamos std::visit (Fase 6) para tratar cada caso. É um trecho denso mas instrutivo, reunindo variant, visita e o if constexpr da célula:

// src/query.cpp
#include "minidb/query.h"
#include <stdexcept>

namespace minidb {

bool Query::comparar_menor(const Value& a, const Value& b) {
    if (a.tipo() != b.tipo())
        throw std::runtime_error("ordenar: tipos incompatíveis na coluna");

    switch (a.tipo()) {
        case Tipo::Inteiro: return a.como_inteiro() < b.como_inteiro();
        case Tipo::Real:    return a.como_real()    < b.como_real();
        case Tipo::Texto:   return a.como_texto()   < b.como_texto();
        default:            return false;
    }
}

}  // namespace minidb

Aqui a const-correctness (Fase 1) e o acesso tipado seguro do Value (Fase 2 do capstone) se pagam: comparar_menor lê os valores com os acessores que garantem o tipo, e lança se tentarem comparar tipos incompatíveis — protegendo contra ordenar uma coluna com dados heterogêneos. Cada tipo é comparado com seu próprio < natural (numérico ou lexicográfico para strings).

A interface fluente em ação

Agora a recompensa. Adicionando um método consultar() à Table que devolve uma Query com todas as linhas, a API que projetamos lá no artigo O Projeto Final Começa — Arquitetando um Mini Banco de Dados em Memória ganha vida:

// src/main.cpp (trecho de demonstração)
#include <iostream>
#include "minidb/table.h"
#include "minidb/query.h"
using namespace minidb;

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

    auto add = [&](const std::string& n, int64_t i) {
        Row r; r.set("nome", n); r.set("idade", i); usuarios.inserir(r);
    };
    add("Ana", 30); add("Bruno", 25); add("Carla", 40); add("Diego", 28);

    // A CONSULTA ENCADEADA: filtrar idade >= 28, ordenar por nome.
    auto resultado = usuarios.consultar()
        .filtrar([](const Row& r) { return r.get_inteiro("idade") >= 28; })
        .ordenar_por("nome");

    std::cout << "Usuários com 28+ anos, por nome:\n";
    for (const auto& r : resultado.linhas())
        std::cout << "  " << r.get_texto("nome")
                  << " (" << r.get_inteiro("idade") << ")\n";
    return 0;
}

Saída:

Usuários com 28+ anos, por nome:
Ana (30)
Carla (40)
Diego (28)

Contemple o que esta única expressão reúne: consultar() abre a cadeia; filtrar recebe uma lambda (Fase 5) e usa copy_if (Fase 4); ordenar_por usa sort (Fase 4) com comparação de variant (Fase 6); o encadeamento .filtrar(...).ordenar_por(...) é a interface fluente (Fase 3, o *this retornando novas Query); e tudo opera sobre containers com gestão automática (Fase 2). Esta linha de código é o curso — todas as fases colaborando para expressar uma intenção clara de forma concisa. É exatamente a promessa de "cada artigo é o curso em miniatura", agora cumprida em escala de aplicação real.

A honestidade sobre o design do motor

Prometo a franqueza de sempre. Fiz uma escolha de design que merece transparência: cada operação de consulta (filtrar, ordenar_por) copia as linhas selecionadas para uma nova Query. Isso torna as consultas imutáveis e encadeáveis com segurança (nenhuma operação altera a tabela original ou uma consulta anterior), o que é limpo e fácil de raciocinar. Mas tem um custo: para tabelas grandes, copiar linhas a cada etapa da cadeia é ineficiente. Bancos de dados reais resolvem isso com avaliação preguiçosa (lazy evaluation) — a consulta descreve as operações mas só as executa uma vez, sobre os dados originais, sem cópias intermediárias — ou trabalham com referências/índices para as linhas em vez de cópias. Implementar isso corretamente é significativamente mais complexo (envolve compor as operações em vez de executá-las de imediato), e para o nosso mini banco didático a simplicidade da cópia vale mais que a eficiência. Reconhecer esse trade-off — simplicidade e clareza agora versus eficiência que o escopo não exige — é a maturidade de engenharia do curso. A versão simples é a certa para o nosso propósito; saber o que mudaria em escala é o que distingue o profissional.

O motor de consultas é onde template e lambda se encontram com um propósito concreto: o filtro chega como tipo próprio, o compilador pode expandi-lo em linha, e a interface encadeada fica legível. A implementação aqui é ansiosa — cada etapa produz um resultado novo, copiando linhas —, o que é simples de entender e caro em volume. A alternativa preguiçosa compõe as operações e percorre uma vez só; a diferença aparece quando o número de linhas cresce.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Adicione ao motor um método Query selecionar(const std::vector<std::string>& colunas) const que produza linhas contendo apenas as colunas nomeadas (como o SELECT de SQL). Escreva um teste.

Ver resposta

✓ Resposta: Método selecionar e teste:

// em query.h, na parte public:
Query selecionar(const std::vector<std::string>& colunas) const {
    std::vector<Row> projetadas;
    for (const auto& linha : linhas_) {
        Row nova;
        for (const auto& col : colunas) {
            auto v = linha.get(col);
            if (v) nova.set(col, *v);   // copia só as colunas pedidas
        }
        projetadas.push_back(std::move(nova));
    }
    return Query(std::move(projetadas));
}
// teste:
TEST_CASE("selecionar projeta apenas as colunas pedidas") {
    Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
    Row r; r.set("nome", "Ana"); r.set("idade", int64_t{30}); t.inserir(r);
    auto res = t.consultar().selecionar({"nome"});
    REQUIRE(res.linhas()[0].tem("nome"));
    REQUIRE_FALSE(res.linhas()[0].tem("idade"));   // idade foi projetada fora
}

Exercício 2

Explique por que o filtrar é um template (template <typename Predicado>) em vez de receber um std::function<bool(const Row&)>. Qual é a vantagem de desempenho do template, e quando o std::function seria preferível?

Ver resposta

✓ Resposta: filtrar é um template porque isso permite ao compilador instanciar a função para o tipo exato do predicado passado (uma lambda específica), inlinando frequentemente a chamada — a condição da lambda é incorporada diretamente no laço de copy_if, sem overhead de chamada indireta. Um std::function<bool(const Row&)>, ao contrário, é um apagador de tipo (type erasure): ele guarda qualquer chamável atrás de uma interface uniforme, ao custo de uma indireção (uma chamada virtual-like) a cada invocação e, possivelmente, uma alocação de heap. Para um filtro aplicado a milhões de linhas, essa indireção soma. A vantagem do template é, portanto, desempenho: código especializado e inlinável. O std::function seria preferível quando você precisa armazenar predicados de tipos diferentes numa mesma variável ou container (por exemplo, uma lista de filtros a aplicar dinamicamente), ou expor a função numa fronteira de biblioteca compilada separadamente, onde o tipo concreto da lambda não pode ser conhecido — casos em que a flexibilidade do tipo apagado vale seu custo.

Exercício 3

No ordenar_por, o comparador é uma lambda que captura coluna e crescente. Explique por que coluna é capturada por referência (&coluna) com segurança neste caso, ligando à regra de tempo de vida das lambdas do artigo Funções Anônimas e Capturas — Lambdas de Verdade.

Ver resposta

✓ Resposta: No ordenar_por, a lambda comparadora é passada diretamente a std::sort e usada imediatamente, dentro da própria função ordenar_por, durante a ordenação. A variável coluna (parâmetro de ordenar_por) permanece viva durante toda a execução de std::sort, pois a função não retorna até a ordenação terminar. Como a lambda é consumida na hora, no mesmo escopo onde coluna existe, capturá-la por referência (&coluna) é seguro — não há risco de a referência sobreviver à variável, porque a lambda não escapa da função. É exatamente o caso que a regra do artigo Funções Anônimas e Capturas — Lambdas de Verdade permite: captura por referência é segura quando a lambda é usada imediatamente e localmente, sem ser armazenada ou retornada. Capturar por valor também funcionaria, mas por referência evita copiar o nome da coluna e é seguro aqui.

Exercício 4

Demonstre uma consulta encadeada de três operações: filtrar por uma condição, ordenar por uma coluna decrescente, e (usando o selecionar do exercício 1) projetar apenas uma coluna. Mostre o resultado esperado.

Ver resposta

✓ Resposta: Consulta de três operações:

// Tabela usuarios com nome, idade (do exemplo da aula), mais Elena(35), Bruno(25) etc.
auto res = usuarios.consultar()
    .filtrar([](const Row& r){ return r.get_inteiro("idade") >= 28; })  // 28+
    .ordenar_por("idade", /*crescente=*/false)                          // idade decrescente
    .selecionar({"nome"});                                              // só o nome

for (const auto& r : res.linhas())
    std::cout << r.get_texto("nome") << '\n';

Com os dados Ana(30), Bruno(25), Carla(40), Diego(28), o resultado esperado (28+, por idade decrescente, só nomes):

Carla
Ana
Diego

(Carla 40, Ana 30, Diego 28 — Bruno com 25 foi filtrado fora.) A cadeia demonstra filtrar, ordenar decrescente e projetar, tudo encadeado fluentemente.

Exercício 5

Discuta o trade-off entre a avaliação "ansiosa" (que copia linhas a cada etapa, como implementamos) e a "preguiçosa" (que compõe operações e executa uma vez). Descreva um cenário concreto em que a diferença de desempenho seria significativa e esboce, conceitualmente, como a versão preguiçosa evitaria as cópias.

Ver resposta

✓ Resposta: Na avaliação ansiosa (a nossa), cada etapa da cadeia (filtrar, ordenar_por, selecionar) executa de imediato e produz um novo vector<Row> copiado — então uma cadeia de três operações sobre N linhas faz múltiplas cópias intermediárias das linhas selecionadas. Na avaliação preguiçosa, a cadeia apenas registra as operações a realizar (as lambdas de filtro, a chave de ordenação, as colunas a projetar) sem executá-las; só quando os resultados são efetivamente pedidos (ao iterar) é que tudo roda numa única passagem sobre os dados originais, sem materializar cópias intermediárias. Cenário em que a diferença seria significativa: uma tabela com milhões de linhas onde você filtra para manter poucas — a versão ansiosa copiaria potencialmente muitas linhas em cada etapa antes do filtro reduzir o conjunto, enquanto a preguiçosa aplicaria filtro, ordenação e projeção de forma integrada, tocando cada linha uma vez e copiando só o resultado final mínimo. Conceitualmente, a versão preguiçosa evitaria as cópias representando a consulta como uma composição de operações (por exemplo, uma cadeia de iteradores transformadores, ou uma árvore de operações a ser "compilada" numa única travessia), avaliada só no consumo — o mesmo princípio dos ranges preguiçosos do C++20 (std::views), que aplicam filter e transform sem criar containers intermediários. A versão ansiosa troca eficiência por simplicidade de implementação e raciocínio; a preguiçosa troca simplicidade por eficiência em escala. Para nosso mini banco, a ansiosa é a escolha certa; para um banco de produção, a preguiçosa (ou índices) seria necessária.

Comentários

Mais em Linguagem C++

Extensão — Páginas Dinâmicas: HTML Gerado com Templates
Extensão — Páginas Dinâmicas: HTML Gerado com Templates

Montar HTML concatenando string quebra por dois motivos ao mesmo tempo: fica…

O Capstone dos Jogos — Breakout e o Fim da Jornada
O Capstone dos Jogos — Breakout e o Fim da Jornada

O último jogo reúne tudo o que a trilha construiu: recursos em RAII, entidades…

Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr
Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr

Quando várias partes do programa precisam legitimamente compartilhar um…