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
- cppreference.com/w/cpp/algorithm/copy e /w/cpp/iterator/back_inserter: os componentes usados no
filtrar. - cppreference.com/w/cpp/algorithm/sort: o algoritmo de ordenação e o uso de comparadores customizados, base do
ordenar_por. - Bjarne Stroustrup, A Tour of C++ (3ª ed.), seções sobre algoritmos e programação funcional: contexto para o design do motor de consultas.
- "Fluent interfaces" (Martin Fowler, martinfowler.com/bliki/FluentInterface.html): o conceito de interface fluente que o encadeamento aplica.
- ISO C++ Core Guidelines, regras sobre uso de algoritmos (SL.con) e templates (T.1): as diretrizes que embasam o motor.
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.