A Primeira Pedra — Uma Célula Poliforme com std::variant

A Primeira Pedra — Uma Célula Poliforme com std::variant

A primeira peça do banco precisa guardar inteiro, texto ou real na mesma célula, e o variant resolve isso sem herança nem ponteiro. O artigo trata do acesso tipado, do if constexpr na conversão para texto, e da armadilha em que bool e os tipos inteiros disputam a mesma alternativa.
Linguagem C++

12 min de leitura

No artigo O Projeto Final Começa — Arquitetando um Mini Banco de Dados em Memória arquitetamos o mini banco de dados e definimos suas cinco peças. Hoje colocamos a primeira pedra: o Value, a célula fundamental que pode conter um inteiro, um número real ou um texto. Tudo no banco se apoiará nele — linhas são coleções de valores, tabelas são coleções de linhas. Construir bem essa base é crítico, e ela é a oportunidade perfeita para ver o curso condensado num tijolo: vamos usar std::variant (Fase 6) para o dado poliforme, encapsulá-lo numa classe com acesso seguro (Fase 3), aplicar const-correctness (Fase 1), lançar exceções em acessos inválidos (Fase 6) e escrever os primeiros testes (Fase 8). Uma peça pequena, o curso inteiro dentro dela.

Modelando o valor com variant

Uma célula de banco de dados precisa guardar um de vários tipos possíveis. Na Fase 6 vimos que o std::variant é exatamente isso — a union segura. Encapsulamos o variant numa classe Value, que esconde a mecânica e oferece uma interface limpa. Eis a interface (o cabeçalho):

// include/minidb/value.h
#pragma once
#include <variant>
#include <string>
#include <cstdint>
#include "minidb/tipos.h"

namespace minidb {

class Value {
public:
    // Construtores para cada tipo suportado — sobrecarga (Fase 1/3).
    Value(int64_t i)      : dado_(i) {}
    Value(double d)       : dado_(d) {}
    Value(std::string s)  : dado_(std::move(s)) {}   // move: evita cópia (Fase 2)
    Value(const char* s)  : dado_(std::string(s)) {} // conveniência para literais

    // Qual tipo está guardado agora?
    Tipo tipo() const;

    // Acesso tipado e seguro: lança se o tipo não bater (implementação no .cpp).
    int64_t      como_inteiro() const;
    double       como_real() const;
    const std::string& como_texto() const;

    // Representação textual, para impressão/depuração.
    std::string para_texto() const;

private:
    // O coração: guarda UM de três tipos. A ordem casa com o enum Tipo.
    std::variant<int64_t, double, std::string> dado_;
};

}  // namespace minidb

Escolhas de projeto que já são reflexo seu: usamos int64_t (inteiro de 64 bits, de <cstdint>) para portabilidade; o construtor de std::string recebe por valor e usa std::move para eficiência (Fase 2); os acessores são const porque só leem (Fase 1/3); e o variant é private, escondendo a mecânica atrás de métodos seguros (encapsulamento, Fase 3). A classe é a guardiã do acesso correto ao dado.

Implementando o acesso seguro

Agora a implementação (o .cpp). O ponto central é o acesso tipado: pedir como_inteiro() de um valor que guarda texto deve falhar de forma clara, não causar comportamento indefinido. Usamos std::get (que lança std::bad_variant_access) ou verificamos e lançamos uma exceção nossa com mensagem melhor:

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

namespace minidb {

Tipo Value::tipo() const {
    // O índice do variant (0,1,2) casa com a ordem do enum Tipo.
    switch (dado_.index()) {
        case 0: return Tipo::Inteiro;
        case 1: return Tipo::Real;
        default: return Tipo::Texto;
    }
}

int64_t Value::como_inteiro() const {
    if (!std::holds_alternative<int64_t>(dado_))
        throw std::runtime_error("Value: esperava Inteiro, mas o tipo é outro");
    return std::get<int64_t>(dado_);   // seguro: acabamos de checar
}

double Value::como_real() const {
    if (!std::holds_alternative<double>(dado_))
        throw std::runtime_error("Value: esperava Real, mas o tipo é outro");
    return std::get<double>(dado_);
}

const std::string& Value::como_texto() const {
    if (!std::holds_alternative<std::string>(dado_))
        throw std::runtime_error("Value: esperava Texto, mas o tipo é outro");
    return std::get<std::string>(dado_);
}

std::string Value::para_texto() const {
    // std::visit (Fase 6) aplica a conversão certa a qualquer tipo guardado.
    return std::visit([](const auto& v) -> std::string {
        using T = std::decay_t<decltype(v)>;   // descobre o tipo real de v
        if constexpr (std::is_same_v<T, std::string>)
            return v;                            // texto: devolve como está
        else
            return std::to_string(v);            // int/double: converte para string
    }, dado_);
}

}  // namespace minidb

Repare no para_texto: usamos std::visit com uma lambda genérica (Fases 5 e 6), e o if constexpr — um if avaliado em tempo de compilação (C++17) — para tratar o std::string diferente dos numéricos. O std::decay_t<decltype(v)> descobre o tipo real do valor visitado. Não se assuste com a densidade: isto reúne variant, visita, lambda genérica e template numa função de seis linhas — exatamente o tipo de código idiomático que o curso preparou você para ler e escrever. E o acesso tipado protege o invariante: você nunca lê um valor como o tipo errado sem receber um erro claro.

Testando a primeira pedra

Fiel à Fase 8, uma peça só está pronta quando testada. Escrevemos testes que cobrem os casos normais e — crucialmente — os de erro (acesso ao tipo errado):

// tests/test_value.cpp
#include <catch2/catch_test_macros.hpp>
#include "minidb/value.h"
using namespace minidb;

TEST_CASE("Value guarda e devolve inteiro") {
    Value v(int64_t{42});
    REQUIRE(v.tipo() == Tipo::Inteiro);
    REQUIRE(v.como_inteiro() == 42);
}

TEST_CASE("Value guarda e devolve texto") {
    Value v("olá");
    REQUIRE(v.tipo() == Tipo::Texto);
    REQUIRE(v.como_texto() == "olá");
}

TEST_CASE("Value: acesso ao tipo errado lança") {
    Value v(int64_t{10});
    // Pedir texto de um inteiro deve lançar — testando o comportamento de erro.
    REQUIRE_THROWS_AS(v.como_texto(), std::runtime_error);
}

TEST_CASE("Value: para_texto converte qualquer tipo") {
    REQUIRE(Value(int64_t{7}).para_texto() == "7");
    REQUIRE(Value("abc").para_texto() == "abc");
}

Os testes documentam e garantem o comportamento: valores guardam e devolvem seu tipo, e acessos incorretos falham de forma controlada. O REQUIRE_THROWS_AS verifica o caminho de erro — tão importante quanto o de sucesso, como firmamos no artigo Provando que Funciona — Testes Automatizados com Catch2. Com isso, a primeira pedra está assentada e verificada.

A honestidade sobre decisões de design da célula

Prometo a franqueza de sempre. Fiz escolhas que merecem transparência. Optei por exceções no acesso ao tipo errado — mas, seguindo a discussão da Fase 6, um acesso tipado incorreto poderia também ser modelado com std::optional (devolver "nada" em vez de lançar), se considerássemos que pedir o tipo errado é um caso esperado e não um erro de programação. Escolhi exceção porque, no nosso design, pedir como_inteiro() de um texto é um erro do programador (ele deveria saber o tipo da coluna), e erros de programador merecem falhar ruidosamente. Se o banco recebesse consultas de fontes externas não confiáveis, optional/expected seria mais defensável. Essa é uma decisão de projeto legítima com trade-offs reais — e reconhecê-la, em vez de fingir que há só uma resposta certa, é parte da maturidade de engenharia. No artigo A Quarta Pedra — Tratamento de Erros Coerente com std::expected revisitaremos o tratamento de erros do banco como um todo, e você verá essa tensão resolvida conscientemente.

Modelar a célula com variant resolve o poliformismo sem herança nem ponteiro, e traz junto uma armadilha que morde cedo: bool e os tipos inteiros competem na resolução de sobrecarga, e um literal pode acabar na alternativa errada. A escolha de lançar no acesso tipado, em vez de devolver optional, parte do princípio de que pedir a coluna com o tipo errado é defeito de programação, não desfecho previsto — critério que vale conferir contra o uso real da API.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Adicione ao Value um construtor e suporte para o tipo bool (booleano), incluindo o valor no variant, um novo membro no enum Tipo, o método como_booleano() e o ajuste em tipo() e para_texto(). Escreva um teste para o novo tipo.

Ver resposta

✓ Resposta: Suporte a bool (mudanças principais):

// em tipos.h: enum class Tipo { Inteiro, Real, Texto, Booleano };
// em value.h:
Value(bool b) : dado_(b) {}
bool como_booleano() const;
// variant vira: std::variant<int64_t, double, std::string, bool> dado_;
// em value.cpp:
bool Value::como_booleano() const {
    if (!std::holds_alternative<bool>(dado_))
        throw std::runtime_error("Value: esperava Booleano");
    return std::get<bool>(dado_);
}
// tipo() e para_texto() ajustados para o novo alternativo.
// teste:
TEST_CASE("Value guarda booleano") {
    Value v(true);
    REQUIRE(v.tipo() == Tipo::Booleano);
    REQUIRE(v.como_booleano() == true);
}

Atenção: como bool e int64_t são ambos numéricos, é preciso cuidado com a resolução de sobrecarga dos construtores; colocar bool explicitamente e testar evita surpresas de conversão.

Exercício 2

Explique por que o construtor Value(std::string s) : dado_(std::move(s)) usa std::move. O que aconteceria em termos de cópia se ele fosse dado_(s) sem o move?

Ver resposta

✓ Resposta: std::move(s) transfere o conteúdo interno da string s (seu buffer) diretamente para dado_, em vez de copiá-lo. Como s é um parâmetro por valor (já é uma cópia local que vai morrer ao fim do construtor), movê-la é seguro e barato — apenas transfere um ponteiro. Sem o std::move, dado_(s) faria uma cópia de s: alocaria um novo buffer e duplicaria todos os caracteres, para logo em seguida s ser destruída — trabalho duplicado e desperdiçado. Para strings grandes, a diferença é mensurável. É a lição de move semantics (Fase 2) aplicada: quando a origem vai morrer, mova em vez de copiar.

Exercício 3

No método para_texto, explique o papel do if constexpr. Por que um if normal (em tempo de execução) não funcionaria para distinguir o tratamento do std::string dos tipos numéricos aqui?

Ver resposta

✓ Resposta: O if constexpr é avaliado em tempo de compilação: o compilador escolhe qual ramo compilar com base na condição, e descarta o outro ramo antes de gerar código. Isso é necessário aqui porque, dentro da lambda genérica visitada, o tipo T é diferente a cada instanciação (uma vez int64_t, uma vez double, uma vez std::string), e as operações válidas diferem: std::to_string(v) funciona para números mas não compila para std::string (não há std::to_string(std::string)), e return v; como string funciona para std::string mas não daria o resultado certo para números. Um if normal (de execução) não funcionaria porque ambos os ramos precisariam compilar para todos os tipos — e std::to_string(uma_string) causaria erro de compilação na instanciação para std::string. O if constexpr resolve compilando, para cada tipo, apenas o ramo apropriado, descartando o inválido. É o mecanismo que permite código genérico com tratamento divergente por tipo.

Exercício 4

Discuta a decisão de usar exceção (em vez de std::optional) no acesso tipado do Value. Descreva um cenário em que std::optional<int64_t> como_inteiro() seria a escolha mais apropriada, e justifique.

Ver resposta

✓ Resposta: Usar exceção assume que pedir o tipo errado é um erro de programação — o programador deveria conhecer o tipo da coluna que está acessando, então errar isso é um bug que merece falhar ruidosamente e ser corrigido, não uma condição a tratar rotineiramente. Cenário em que std::optional<int64_t> como_inteiro() seria mais apropriado: se o banco processasse consultas dinâmicas de fontes externas não confiáveis — por exemplo, um usuário digita uma consulta que tenta ler uma coluna como inteiro sem saber seu tipo real. Aí, "o tipo não bate" deixa de ser um bug do programador e vira uma condição esperada de entrada inválida, que o código de consulta deve tratar graciosamente (informar "tipo incompatível" ao usuário) em vez de lançar uma exceção que poderia derrubar o processamento. Nesse contexto, optional (ou expected com a razão) tornaria a possibilidade de incompatibilidade parte da assinatura, forçando o tratamento — mais robusto para entrada não confiável. A escolha depende de quem comete o erro: programador (exceção) ou entrada externa (optional/expected).

Exercício 5

Escreva um teste que verifique que para_texto() de um Value real (double) produz uma representação textual correta, e discuta uma sutileza (por exemplo, std::to_string(3.14) pode produzir casas decimais extras). Como você lidaria com a formatação de reais num banco de dados real?

Ver resposta

✓ Resposta: Teste e sutileza:

TEST_CASE("para_texto de real") {
    Value v(3.5);
    REQUIRE(v.para_texto() == "3.500000");   // std::to_string(3.5) → "3.500000"
}

A sutileza: std::to_string(double) usa uma formatação fixa que produz seis casas decimais por padrão, então 3.5 vira "3.500000" e 3.14 vira "3.140000" — casas extras indesejadas, e ainda com possíveis imprecisões de ponto flutuante em outros valores. Num banco de dados real, eu lidaria com a formatação de reais de forma controlada em vez de depender do std::to_string: usaria std::format (C++20), que permite especificar a precisão (std::format("{:.2f}", v) para duas casas), ou o <charconv> (std::to_chars), que dá a representação mais curta e exata que reconverte para o mesmo double — ideal para armazenamento fiel. A escolha dependeria do propósito: exibição ao usuário pede precisão configurável (std::format), enquanto serialização para persistência pede fidelidade exata (std::to_chars). O ponto pedagógico: a representação textual de números reais é uma decisão de projeto com armadilhas, não um detalhe trivial — e o C++ moderno oferece ferramentas melhores que o std::to_string para tratá-la.

Comentários

Mais em Linguagem C++

Referências e Ponteiros, Frente a Frente
Referências e Ponteiros, Frente a Frente

No scanf era obrigatório passar o endereço da variável; no cin a leitura…

Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática
Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática

Uma classe que adquire um recurso ao nascer e o devolve ao morrer, sem um…

Um Valor que Pode Não Existir — std::optional
Um Valor que Pode Não Existir — std::optional

Combinar que -1 significa ausência funciona até alguém esquecer de verificar…