Estruturas de Dados para Qualquer Tipo — Templates de Classe

Estruturas de Dados para Qualquer Tipo — Templates de Classe

Generalizar uma função é um passo; generalizar uma classe inteira é o que explica a STL por dentro. Construindo uma pilha genérica, o texto mostra como o std::vector é feito, por que uma classe apoiada num membro que já se gerencia dispensa destrutor e cópia, e o que fazer com parâmetros que não são tipos.
Linguagem C++

12 min de leitura

No artigo Escreva uma Vez, Use com Todo Tipo — Templates de Função aprendemos a generalizar funções sobre tipos e tivemos a revelação de que a STL é feita de templates. Mas ficou um fio à mostra: o std::vector não é uma função, é uma classe inteira que guarda qualquer tipo de elemento. Como se generaliza uma classe? Essa é a pergunta de hoje, e respondê-la fecha o entendimento de como a STL foi construída. Vamos conhecer os templates de classe e, para não ficar no abstrato, construir com as próprias mãos uma pequena estrutura genérica — uma pilha que funciona com qualquer tipo. Ao terminar, você terá visto por dentro a mesma técnica que dá vida ao std::vector<T> que você usa desde a Fase 4, e a genericidade deixará de ser mágica para virar ferramenta sua.

Generalizando uma classe inteira

A sintaxe é a mesma dos templates de função — template <typename T> — mas aplicada a uma classe. O T então pode ser usado em qualquer lugar dentro dela: como tipo de membros, de parâmetros, de retornos. Vamos construir uma Caixa genérica que guarda um valor de qualquer tipo:

#include <iostream>
#include <string>

// A classe inteira é parametrizada por T. Uma Caixa<int> guarda int,
// uma Caixa<std::string> guarda string, e assim por diante.
template <typename T>
class Caixa {
public:
    Caixa(T valor) : conteudo_(valor) {}

    T obter() const { return conteudo_; }        // devolve o T guardado
    void guardar(T novo) { conteudo_ = novo; }   // substitui o T guardado

private:
    T conteudo_;   // o membro é do tipo genérico T
};

int main() {
    Caixa<int> ci(42);                    // T = int
    Caixa<std::string> cs("olá");         // T = std::string

    std::cout << ci.obter() << '\n';      // 42
    std::cout << cs.obter() << '\n';      // olá

    cs.guardar("mundo");
    std::cout << cs.obter() << '\n';      // mundo
    return 0;
}

Diferente dos templates de função, onde o tipo era deduzido dos argumentos, num template de classe você especifica o tipo entre <> ao criar o objeto: Caixa<int>, Caixa<std::string>. Cada um desses é um tipo concreto e distinto, instanciado a partir do molde Caixa. É exatamente o que você fez em toda a Fase 4 ao escrever std::vector<int> — só que agora você sabe que estava fornecendo um argumento de template a uma classe genérica.

Construindo uma pilha genérica de verdade

Vamos a algo mais substancial, que reúne tudo do curso: uma pilha (estrutura LIFO — último a entrar, primeiro a sair) que funciona com qualquer tipo. Note como ela é construída sobre o std::vector da Fase 4, aplica a composição do artigo Uma Classe que Nasce de Outra — Herança e a Cadeia de Construção, e generaliza com o template de hoje:

#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>   // std::out_of_range

template <typename T>
class Pilha {
public:
    // Empilha um elemento no topo.
    void empurra(const T& valor) {   // const T& : não copia à toa (Fase 1)
        dados_.push_back(valor);
    }

    // Remove e devolve o elemento do topo.
    T desempilha() {
        if (vazia())
            throw std::out_of_range("pilha vazia");   // exceções: Fase 6
        T topo = dados_.back();
        dados_.pop_back();
        return topo;
    }

    const T& topo() const {          // espia o topo sem remover
        return dados_.back();
    }

    bool vazia() const { return dados_.empty(); }
    std::size_t tamanho() const { return dados_.size(); }

private:
    std::vector<T> dados_;   // composição: a Pilha TEM um vector<T> (Regra do Zero!)
};

int main() {
    Pilha<int> pi;
    pi.empurra(1);
    pi.empurra(2);
    pi.empurra(3);
    std::cout << "topo: " << pi.topo() << '\n';        // 3
    std::cout << "removido: " << pi.desempilha() << '\n';  // 3
    std::cout << "novo topo: " << pi.topo() << '\n';   // 2

    Pilha<std::string> ps;      // a MESMA Pilha, agora de strings
    ps.empurra("a");
    ps.empurra("b");
    std::cout << "topo string: " << ps.topo() << '\n'; // b
    return 0;
}

Repare em quanta coisa do curso converge nesta única classe. O membro é um std::vector<T> (Fase 4), o que dá à Pilha gestão de memória automática e a Regra do Zero de graça (Fase 2) — nenhum destrutor, nenhuma função de cópia a escrever. O empurra recebe const T& para não copiar à toa (Fase 1). O desempilha lança uma exceção em caso de erro (Fase 6, uma prévia). E tudo é genérico graças ao template de hoje. Esta é a demonstração viva de "cada artigo é o curso em miniatura": você já domina todas as peças, e agora as vê trabalhando juntas numa estrutura de dados real e reutilizável.

Como o std::vector é feito por dentro

Com isso, a última cortina cai. O std::vector<T> que você usa desde a Fase 4 é, em essência, um template de classe como a nossa Pilha, só que muito mais sofisticado: ele gerencia um bloco de memória bruta (em vez de delegar a outro container), implementa cuidadosamente as cinco funções especiais para cópia e movimento (artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco), controla capacidade e crescimento (artigo O Array que Cresce Sozinho — std::vector a Fundo), e oferece iteradores (artigo O Fio que Une Tudo — Iteradores como Conceito Central). Mas o esqueleto conceitual é o que você acabou de construir: uma classe parametrizada por T, com um recurso interno e métodos genéricos. Você não precisa reimplementar std::vector — ele já é excelente —, mas agora entende que não há mágica ali, apenas um template de classe bem-feito. Toda a Fase 4 se ilumina retroativamente.

A honestidade sobre parâmetros que não são tipos

Uma nota de completude, com o cuidado de sempre. Templates podem ser parametrizados não só por tipos, mas também por valores — o std::array<int, 5>, por exemplo, tem o tamanho 5 como parâmetro de template. E podem ter múltiplos parâmetros, como o std::map<Chave, Valor> que você usou, que é um template de classe com dois parâmetros de tipo. Não vamos aprofundar essas variações agora para não sobrecarregar, mas saiba que a técnica se estende: quando você viu std::map<std::string, int> na Fase 4, estava instanciando um template de classe com dois argumentos de tipo. Tudo se conecta.

Generalizar uma classe é o passo que explica a STL por dentro: um vector é um template de classe que guarda qualquer tipo e administra a própria memória. O detalhe que merece atenção é o que não foi preciso escrever — uma pilha construída sobre um std::vector<T> dispensa destrutor, cópia e atribuição, porque o membro já sabe cuidar de si. É a Regra do Zero em ação, e ela vale tanto para código genérico quanto para o concreto.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/language/class_template: a referência completa sobre templates de classe, instanciação e parâmetros.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre templates: a construção de tipos genéricos pelo criador, incluindo exemplos de containers.
  • cppreference.com/w/cpp/container/vector: revisitar a interface de std::vector agora entendendo-o como o template de classe que ele é.
  • Scott Meyers, Effective Modern C++ (2014): os itens sobre templates iluminam as sutilezas de instanciação de classes genéricas.
  • ISO C++ Core Guidelines, seção "T", regras T.5 e T.140: diretrizes sobre projetar tipos genéricos reutilizáveis.

Exercícios

Exercício 1

Crie um template de classe Par<T> que guarde dois valores do mesmo tipo T, com métodos primeiro() e segundo() e um método troca() que inverta os dois. Demonstre com Par<int> e Par<std::string>.

Ver resposta

✓ Resposta: O template Par:

#include <iostream>
#include <string>
template <typename T>
class Par {
public:
    Par(T a, T b) : primeiro_(a), segundo_(b) {}
    T primeiro() const { return primeiro_; }
    T segundo()  const { return segundo_; }
    void troca() { T tmp = primeiro_; primeiro_ = segundo_; segundo_ = tmp; }
private:
    T primeiro_, segundo_;
};
int main() {
    Par<int> pi(1, 2);
    pi.troca();
    std::cout << pi.primeiro() << ' ' << pi.segundo() << '\n';   // 2 1

    Par<std::string> ps("ana", "bia");
    std::cout << ps.primeiro() << ' ' << ps.segundo() << '\n';   // ana bia
    return 0;
}

Exercício 2

Explique a diferença, quanto à especificação do tipo, entre chamar um template de função (maior(3, 7)) e criar um objeto de um template de classe (Caixa<int> c(42)). Por que num você não escreve <int> e no outro sim?

Ver resposta

✓ Resposta: Num template de função, o compilador deduz o tipo T a partir dos argumentos da chamada: em maior(3, 7), vê dois int e conclui T = int, então você não precisa escrever <int>. Num template de classe, não há argumentos de função no momento da criação a partir dos quais deduzir o tipo — Caixa não é chamada, é instanciada —, então você precisa dizer explicitamente qual tipo quer: Caixa<int>. funções deduzem dos argumentos; classes precisam do tipo declarado entre <>. (Nota: o C++17 introduziu dedução de argumentos de template de classe em alguns casos, mas a regra geral e mais clara é especificar o tipo.)

Exercício 3

Adicione à classe Pilha<T> da aula um método bool contem(const T& valor) const que diga se um valor está na pilha. (Dica: use std::find da Fase 4 sobre o vector interno.)

Ver resposta

✓ Resposta: O método contem:

#include <algorithm>   // std::find
// dentro de Pilha<T>, na parte public:
bool contem(const T& valor) const {
    return std::find(dados_.begin(), dados_.end(), valor) != dados_.end();
}

std::find procura valor no vector interno e devolve end() se não achar; comparar com end() responde se está presente — o padrão da Fase 4, agora dentro de uma classe genérica. Funciona para qualquer T que suporte == (que é o que std::find usa).

Exercício 4

Explique por que a classe Pilha<T> da aula não precisou de destrutor, construtor de cópia nem operador de atribuição, apesar de "gerenciar" uma coleção de elementos. Ligue a resposta à Regra do Zero (artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco) e ao membro std::vector<T>.

Ver resposta

✓ Resposta: A Pilha<T> não precisou de nenhuma das funções especiais porque seu único membro é um std::vector<T>, que já implementa corretamente destrutor, cópia e movimento por si só. Pela Regra do Zero (artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco), quando todos os membros de uma classe se autogerenciam, o compilador gera automaticamente as operações certas para a classe que os contém: copiar uma Pilha copia seu vector (que faz cópia profunda), mover uma Pilha move seu vector (eficiente), e destruir uma Pilha destrói seu vector (liberando a memória). A Pilha "gerencia" elementos apenas delegando ao vector, então não há recurso cru para ela cuidar — logo, nada a escrever. É a Regra do Zero em sua forma mais pura: componha com tipos que se gerenciam e você não erra.

Exercício 5

Construa um template de classe Registrador<T> que guarde um histórico (std::vector<T>) de todos os valores que já lhe foram passados por um método registra(const T&), e ofereça um método ultimo() que devolva o valor mais recente e quantidade(). Demonstre com dois tipos diferentes e explique como esta classe reúne conceitos de várias fases do curso.

Ver resposta

✓ Resposta: O Registrador:

#include <iostream>
#include <vector>
#include <string>
#include <stdexcept>
template <typename T>
class Registrador {
public:
    void registra(const T& valor) { historico_.push_back(valor); }  // const T& (Fase 1)
    const T& ultimo() const {
        if (historico_.empty()) throw std::out_of_range("nada registrado");  // Fase 6
        return historico_.back();
    }
    std::size_t quantidade() const { return historico_.size(); }
private:
    std::vector<T> historico_;   // Fase 4 + Regra do Zero (Fase 2)
};
int main() {
    Registrador<int> ri;
    ri.registra(10); ri.registra(20);
    std::cout << ri.ultimo() << " (" << ri.quantidade() << " registros)\n";  // 20 (2)

    Registrador<std::string> rs;
    rs.registra("primeiro"); rs.registra("segundo");
    std::cout << rs.ultimo() << '\n';   // segundo
    return 0;
}

Esta classe reúne várias fases: é um template de classe (Fase 5) parametrizado por T; usa std::vector<T> como membro (Fase 4), o que lhe dá a Regra do Zero (Fase 2) — sem funções especiais a escrever; recebe argumentos por const T& para evitar cópias (Fase 1); os métodos que só leem são const (Fase 3); e lança uma exceção quando não há o que devolver (Fase 6, prévia). É o curso inteiro condensado numa estrutura genérica reutilizável de vinte linhas — a prova de que os conceitos não são ilhas, mas peças de um mesmo sistema.

Comentários

Mais em Linguagem C++

Um Novo Território — Por que Jogos, o Game Loop e as Três Bibliotecas
Um Novo Território — Por que Jogos, o Game Loop e as Três Bibliotecas

Um jogo não espera o usuário: o laço roda dezenas de vezes por segundo mesmo…

Texto como Coleção — std::string Revisitada e o std::string_view
Texto como Coleção — std::string Revisitada e o std::string_view

Enxergar std::string como container muda o que se espera dele: tamanho…

Provando que Funciona — Testes Automatizados com Catch2
Provando que Funciona — Testes Automatizados com Catch2

Rodar e olhar a saída não protege contra regressão, porque ninguém repete a…