C++ Templates de Classe

Na aula Templates de Função, você escreveu uma função-molde que serve para qualquer tipo. A mesma ideia vale para classes: uma classe Caixa que guarda um int, ou um texto, ou um preço, escrita uma vez só. Você já usa isso desde o Nível 1 sem saber: std::vector<int> é um template de classe, e o <int> é você dizendo qual "massa" vai na forma.

Você vai aprender
  • Como escrever um template de classe: template <typename T> class Caixa.
  • Como criar objetos dele: Caixa<int>, Caixa<std::string>.
  • Como o C++17 descobre o tipo pelo construtor (Caixa c{5};), a "CTAD", e a armadilha do texto entre aspas.
  • Por que std::vector é um template, e o que o <int> significa.
  • Como escrever os métodos fora da classe.
  • Por que templates ficam no arquivo .h, e o erro real de ligação (referência não definida) quando você os separa.

Todos os programas desta aula foram compilados com o g++ 15.2 na máquina fictícia da Ana, na pasta ~/cpp, com g++ -std=c++23 -Wall -Wextra arquivo.cpp -o programa. Você precisa conhecer Classes e Objetos, Construtores e Métodos const.


A ideia: a caixa de presente

Uma loja vende caixas de presente do mesmo modelo: tampa, laço, etiqueta. Numa vai um livro, noutra um perfume, noutra um par de meias. O modelo da caixa é o mesmo; muda o que vai dentro.

Um template de classe é o projeto da caixa. Na hora de usar, você diz o que vai dentro: Caixa<int> é "uma caixa que guarda um int"; Caixa<std::string> é "uma caixa que guarda um texto". Para o compilador, essas são duas classes diferentes, geradas a partir do mesmo molde.


1. O primeiro template de classe

#include <iostream>
#include <string>

template <typename T>
class Caixa {
public:
    Caixa(T valor) : conteudo{valor} {}

    T pegar() const {
        return conteudo;
    }

    void trocar(T novo) {
        conteudo = novo;
    }

private:
    T conteudo;
};

int main() {
    Caixa<int> moedas{25};
    Caixa<std::string> bilhete{"Feliz aniversário!"};
    Caixa<double> peso{1.75};

    std::cout << moedas.pegar() << '\n';
    std::cout << bilhete.pegar() << '\n';
    std::cout << peso.pegar() << '\n';

    moedas.trocar(40);
    std::cout << moedas.pegar() << '\n';
}
template <typename T>
A mesma linha dos templates de função: "o que vem abaixo é um molde, e T é o tipo em branco".
class Caixa { … };
Uma classe comum, só que pode usar T em qualquer lugar onde usaria um tipo.
Caixa(T valor) : conteudo{valor} {}
O construtor recebe um T e guarda em conteudo (a lista depois dos : inicializa o atributo).
T pegar() const
Um método que devolve o que está na caixa. O const promete não mexer no objeto (aula Métodos const).
void trocar(T novo)
Troca o conteúdo por outro do mesmo tipo T.
T conteudo;
O atributo privado: um int numa Caixa<int>, um std::string numa Caixa<std::string>.
Caixa<int> moedas{25};
Cria um objeto. O <int> diz o que vai no lugar de T. O nome completo do tipo é Caixa<int>; Caixa sozinho é só o molde.
$ g++ -std=c++23 -Wall -Wextra caixa.cpp -o caixa
$ ./caixa
25
Feliz aniversário!
1.75
40

Com um molde, três classes: Caixa<int>, Caixa<std::string> e Caixa<double>. E cada uma é rígida com o seu tipo. Tente pôr um texto na caixa de moedas:

$ g++ -std=c++23 -Wall -Wextra caixa-errada.cpp -o caixa-errada     « moedas.trocar("quarenta");
caixa-errada.cpp: In function ‘int main()’:
caixa-errada.cpp:14:19: error: invalid conversion from ‘const char*’ to ‘int’ [-fpermissive]
   14 |     moedas.trocar("quarenta");
      |                   ^~~~~~~~~~
      |                   |
      |                   const char*
caixa-errada.cpp:7:19: note: initializing argument 1 of ‘void Caixa<T>::trocar(T) [with T = int]’
    7 |     void trocar(T novo) { conteudo = novo; }
      |                 ~~^~~~

"Conversão inválida de const char* (um texto entre aspas) para int." A nota explica onde: no argumento 1 de Caixa<T>::trocar(T) com T = int. Depois que você escolheu int, a moedas é uma caixa de inteiros para sempre. É a mesma proteção do std::vector<int>, que não aceita textos.

Experimente: crie uma Caixa<char> com a letra 'A' e mostre o conteúdo. Depois crie uma Caixa<bool> com true e mostre (o cout escreve 1). Por fim, tente moedas.trocar(2.9);: compila? O que aparece?

#include <iostream>
#include <string>

template <typename T>
class Caixa {
public:
    Caixa(T valor) : conteudo{valor} {}
    T pegar() const { return conteudo; }
    void trocar(T novo) { conteudo = novo; }
private:
    T conteudo;
};

int main() {
    Caixa<int> moedas{25};
    std::cout << moedas.pegar() << '\n';
}

2. CTAD: deixar o compilador descobrir o T C++17

Com templates de função, o compilador deduz o tipo pelos argumentos. Até o C++14, isso não valia para classes: era obrigatório escrever Caixa<int>. Desde o C++17, o compilador deduz pelo que você passa ao construtor. O nome técnico é CTAD (Class Template Argument Deduction, "dedução de argumentos de template de classe").

#include <iostream>
#include <string>
#include <vector>

template <typename T>
class Caixa {
public:
    Caixa(T valor) : conteudo{valor} {}
    T pegar() const { return conteudo; }
private:
    T conteudo;
};

int main() {
    Caixa a{5};
    Caixa b{2.5};
    Caixa c{std::string("Ana")};
    std::vector notas{7, 9, 10};

    std::cout << a.pegar() << ' ' << b.pegar() << ' ' << c.pegar() << '\n';
    std::cout << notas.size() << " notas\n";
}
$ g++ -std=c++23 -Wall -Wextra ctad.cpp -o ctad
$ ./ctad
5 2.5 Ana
3 notas
Você escreveuO compilador entendeuPor quê
Caixa a{5};Caixa<int> a{5};5 é int
Caixa b{2.5};Caixa<double> b{2.5};2.5 é double
Caixa c{std::string("Ana")};Caixa<std::string> c{…};o argumento é um std::string
std::vector notas{7, 9, 10};std::vector<int> notas{7, 9, 10};os elementos são int

Com um padrão mais antigo, o mesmo arquivo nem compila. O g++ reclama que faltam os argumentos do template:

$ g++ -std=c++14 -Wall -Wextra ctad.cpp -o ctad
ctad.cpp: In function ‘int main()’:
ctad.cpp:15:11: error: missing template arguments before ‘a’
   15 |     Caixa a{5};
      |           ^
ctad.cpp:16:11: error: missing template arguments before ‘b’
   16 |     Caixa b{2.5};
      |           ^
ctad.cpp:17:11: error: missing template arguments before ‘c’
   17 |     Caixa c{std::string("Ana")};
      |           ^
ctad.cpp:18:17: error: missing template arguments before ‘notas’
   18 |     std::vector notas{7, 9, 10};
      |                 ^~~~~
ctad.cpp:20:18: error: ‘a’ was not declared in this scope
   20 |     std::cout << a.pegar() << ' ' << b.pegar() << ' ' << c.pegar() << '\n';
      |                  ^
ctad.cpp:20:38: error: ‘b’ was not declared in this scope
   20 |     std::cout << a.pegar() << ' ' << b.pegar() << ' ' << c.pegar() << '\n';
      |                                      ^
ctad.cpp:20:58: error: ‘c’ was not declared in this scope
   20 |     std::cout << a.pegar() << ' ' << b.pegar() << ' ' << c.pegar() << '\n';
      |                                                          ^
ctad.cpp:21:18: error: ‘notas’ was not declared in this scope
   21 |     std::cout << notas.size() << " notas\n";
      |                  ^~~~~

Oito erros, mas só quatro de verdade: os quatro missing template arguments ("faltam argumentos de template antes de…"). Os outros (‘a’ was not declared) são consequência: como a linha 15 falhou, a nunca existiu. Esse é o motivo de o guia compilar sempre com -std=c++23.

A armadilha do texto entre aspas

Por que o exemplo escreveu std::string("Ana") e não só "Ana"? Porque um texto entre aspas não é um std::string; é um ponteiro para caracteres, herança do C. Veja o que a CTAD deduz:

$ g++ -std=c++23 -Wall -Wextra ctad-texto.cpp -o ctad-texto     « Caixa nome{"Ana"};  e depois  nome.pegar().size()
ctad-texto.cpp: In function ‘int main()’:
ctad-texto.cpp:15:31: error: request for member ‘size’ in ‘nome.Caixa<const char*>::pegar()’, which is of non-class type ‘const char*’
   15 |     std::cout << nome.pegar().size() << '\n';
      |                               ^~~~

A mensagem entrega o que aconteceu: Caixa<const char*>. O T virou const char*, que não tem .size() ("pedido do membro size em algo que não é classe"). Quando quiser texto, diga Caixa<std::string> nome{"Ana"}; ou passe um std::string.

Quando escrever o tipo, quando deixar a CTAD trabalhar

Use a CTAD quando o tipo é óbvio pelo valor (std::vector notas{7, 9, 10}). Escreva o tipo quando ele não é óbvio, quando o valor engana (textos entre aspas) ou quando o objeto começa vazio: std::vector<int> notas; não tem valor nenhum para o compilador olhar.

Por falar em "começar vazio": a CTAD precisa de um argumento para olhar. Sem argumento, ela falha:

$ g++ -std=c++23 -Wall -Wextra sem-args.cpp -o sem-args     « Caixa vazia;
sem-args.cpp: In function ‘int main()’:
sem-args.cpp:10:11: error: class template argument deduction failed:
   10 |     Caixa vazia;
      |           ^~~~~
sem-args.cpp:10:11: error: no matching function for call to ‘Caixa()’
sem-args.cpp:10:11: note: there are 2 candidates
sem-args.cpp:4:5: note: candidate 1: ‘template<class T> Caixa(T) -> Caixa<T>’
    4 |     Caixa(T valor) : conteudo{valor} {}
      |     ^~~~~
sem-args.cpp:4:5: note: candidate expects 1 argument, 0 provided
sem-args.cpp:2:7: note: candidate 2: ‘template<class T> Caixa(Caixa<T>) -> Caixa<T>’
    2 | class Caixa {
      |       ^~~~~
sem-args.cpp:2:7: note: candidate expects 1 argument, 0 provided

"A dedução de argumentos de template de classe falhou." As notas mostram as "regras de dedução" que o compilador criou a partir dos construtores (Caixa(T) -> Caixa<T>: "se receber um T, é uma Caixa<T>") e dizem que todas esperam 1 argumento, e você deu 0.


3. std::vector é um template

Agora o std::vector<int> da aula std::vector faz sentido por inteiro: vector é um template de classe da biblioteca padrão, e <int> é o argumento do template. Dá para ver a primeira linha dele no cabeçalho que vem com o g++:

$ sed -n 457,458p /usr/include/c++/15/bits/stl_vector.h
  template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
    class vector : protected _Vector_base<_Tp, _Alloc>
template<typename _Tp, …>
O mesmo template <typename T> que você escreveu. A biblioteca chama o tipo de _Tp (nomes com _ no começo são reservados para ela).
typename _Alloc = std::allocator<_Tp>
Um segundo parâmetro, com valor padrão (o =), como os valores padrão de funções. Ele diz como o vetor pede memória. Você quase nunca mexe nele; por isso escreve só std::vector<int>.
class vector
A classe em si. O resto (protected _Vector_base…) é herança, um detalhe interno.

Quase tudo na biblioteca padrão é template: std::vector<T>, std::map<Chave, Valor> (aula std::map), std::optional<T>, std::unique_ptr<T> (aula Ponteiros Inteligentes). Até std::string é um apelido para std::basic_string<char>. Por isso os nomes nas mensagens de erro ficam tão compridos.

Parâmetros que não são tipos

Um template também pode receber um número. O std::array é assim: em std::array<int, 5>, o int é o tipo e o 5 é o tamanho, fixo, conhecido na compilação. Você escreveria algo como template <typename T, int N> class Prateleira. É menos comum no dia a dia; basta reconhecer.


4. Métodos escritos fora da classe

Numa classe comum, você pode declarar o método dentro e escrever o corpo fora, com Classe::metodo (aula Métodos). Num template, a linha template <typename T> precisa ser repetida antes de cada corpo, e o nome da classe vira Caixa<T>:

#pragma once

template <typename T>
class Caixa {
public:
    Caixa(T valor);
    T pegar() const;
private:
    T conteudo;
};

template <typename T>
Caixa<T>::Caixa(T valor) : conteudo{valor} {}

template <typename T>
T Caixa<T>::pegar() const {
    return conteudo;
}
Caixa(T valor);
Dentro da classe, só a declaração (sem corpo, com ;).
template <typename T>
Repetido antes de cada corpo escrito fora: cada corpo também é um molde.
Caixa<T>::pegar()
"O método pegar da Caixa<T>". Esquecer o <T> aqui é um erro comum.
#pragma once
Impede que o cabeçalho seja incluído duas vezes (aula Arquivos .h e .cpp).

Repare que, aqui, os corpos estão no mesmo arquivo .h, logo abaixo da classe. Isso não é descuido. É o assunto da próxima seção.


5. Por que templates ficam no .h

Na aula Arquivos .h e .cpp, você aprendeu a regra: declarações no .h, corpos no .cpp. A Ana seguiu a regra com a Caixa:

#pragma once

template <typename T>
class Caixa {
public:
    Caixa(T valor);
    T pegar() const;
private:
    T conteudo;
};
#include "caixa.h"

template <typename T>
Caixa<T>::Caixa(T valor) : conteudo{valor} {}

template <typename T>
T Caixa<T>::pegar() const {
    return conteudo;
}
#include <iostream>
#include "caixa.h"

int main() {
    Caixa<int> moedas{25};
    std::cout << moedas.pegar() << '\n';
}

Cada arquivo compila sem erro. Mas na hora de juntar:

$ g++ -std=c++23 -Wall -Wextra caixa.cpp main.cpp -o caixa
/usr/bin/x86_64-linux-gnu-ld.bfd: /tmp/ccMfHt4T.o: na função "main":
main.cpp:(.text+0x28): referência não definida para "Caixa<int>::Caixa(int)"
/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:(.text+0x34): referência não definida para "Caixa<int>::pegar() const"
collect2: error: ld returned 1 exit status
/usr/bin/x86_64-linux-gnu-ld.bfd
Quem reclama não é o compilador: é o ligador (ld, de linker), o programa que junta os pedaços compilados num executável (aula Compilar Vários Arquivos). O ligador do Ubuntu fala português, por isso essa parte saiu traduzida.
/tmp/ccMfHt4T.o: na função "main"
O pedaço compilado do main.cpp (um arquivo temporário, com nome sorteado), dentro da função main.
referência não definida para "Caixa<int>::Caixa(int)"
"O main chama o construtor de Caixa<int>, e eu não achei o corpo dele em lugar nenhum." O mesmo para pegar().
collect2: error: ld returned 1 exit status
O resumo: a ligação falhou, nenhum executável foi criado.

O que aconteceu, passo a passo

Lembre: um template é um molde, e o compilador só "assa" uma função quando vê alguém usando o molde com um tipo. E o g++ compila cada .cpp sozinho, sem olhar os outros. Dá para ver o que saiu de cada um com -c (só compilar, sem ligar) e o nm (lista as funções de um arquivo compilado):

$ g++ -std=c++23 -Wall -Wextra -c caixa.cpp
$ nm -C caixa.o                                  « vazio: nenhuma função foi gerada!
$ g++ -std=c++23 -Wall -Wextra -c main.cpp
$ nm -C main.o | grep Caixa
                 U Caixa<int>::Caixa(int)       « U = undefined: "preciso disto, alguém tem?"
                 U Caixa<int>::pegar() const
  1. Ao compilar caixa.cpp, o compilador vê os moldes, mas ninguém ali usa Caixa<int>. Ele não sabe que tipos você vai querer, então não gera nada. O caixa.o sai vazio.
  2. Ao compilar main.cpp, ele vê o uso de Caixa<int>, mas o caixa.h só tem as declarações. Sem o corpo, não dá para assar. Ele anota "preciso de Caixa<int>::pegar()" e confia que algum outro arquivo vai ter.
  3. O ligador procura Caixa<int>::pegar() nos dois arquivos e não acha: referência não definida.

A solução: o corpo do template precisa estar visível onde ele é usado. Na prática, o template inteiro vai no .h (como no caixa.h da seção 4), e não existe caixa.cpp:

$ g++ -std=c++23 -Wall -Wextra main.cpp -o caixa
$ ./caixa
25

É por isso que os cabeçalhos da biblioteca padrão (<vector>, <map>) são enormes e cheios de código: são templates, e templates moram no cabeçalho.

Existe outro jeito (raro)

Se você sabe de antemão todos os tipos que vai usar, pode manter o caixa.cpp e pedir a geração explicitamente, no fim dele: template class Caixa<int>; ("instanciação explícita"). A Ana testou: com essa linha, o mesmo comando liga e o programa mostra 25. Mas aí uma Caixa<double> volta a dar referência não definida. Para começar, fique com "template inteiro no .h".


6. Formas diferentes, e qual usar

FormaExemploQuando usar
Tipo escritoCaixa<int> moedas{25};Sempre funciona. Use quando o tipo não é óbvio, quando o objeto começa vazio ou com textos entre aspas.
CTAD C++17Caixa moedas{25};Quando o valor deixa o tipo óbvio.
Métodos dentro da classeT pegar() const { return conteudo; }Métodos curtos. O jeito mais simples.
Métodos fora da classe, no mesmo .htemplate <typename T> T Caixa<T>::pegar() const {…}Classes grandes, para a declaração da classe ficar curta de ler.
Corpos num .cpp separadocaixa.cppNão use com templates (dá referência não definida), salvo com instanciação explícita.

Missão: transforme a classe Dupla (que hoje só guarda int) num template, para que o main funcione com notas inteiras e com preços. A saída deve ser 9 e 4.99.

#include <iostream>

class Dupla {
public:
    Dupla(int a, int b) : primeiro{a}, segundo{b} {}

    int maior() const {
        return primeiro > segundo ? primeiro : segundo;
    }

private:
    int primeiro;
    int segundo;
};

int main() {
    Dupla<int> notas{7, 9};
    Dupla precos{4.99, 3.50};
    std::cout << notas.maior() << '\n';
    std::cout << precos.maior() << '\n';
}
#include <iostream>

template <typename T>
class Dupla {
public:
    Dupla(T a, T b) : primeiro{a}, segundo{b} {}

    T maior() const {
        return primeiro > segundo ? primeiro : segundo;
    }

private:
    T primeiro;
    T segundo;
};

int main() {
    Dupla<int> notas{7, 9};
    Dupla precos{4.99, 3.50};
    std::cout << notas.maior() << '\n';
    std::cout << precos.maior() << '\n';
}

Quando dá errado

referência não definida para "Caixa<int>::pegar() const" (undefined reference)

O que significa: o corpo do template está num .cpp separado, e o arquivo que usa o template só viu a declaração. (Num sistema em inglês, a mensagem é undefined reference to.)

Como consertar: mova os corpos para o .h, logo abaixo da classe, e apague o .cpp do template.

error: missing template arguments before ‘a’

O que significa: você escreveu Caixa a{5}; num padrão anterior ao C++17, que não tem CTAD.

Como consertar: compile com -std=c++23, ou escreva o tipo: Caixa<int> a{5};.

error: class template argument deduction failed

O que significa: você pediu a CTAD (sem <tipo>) e não deu nenhum valor do qual o tipo possa ser deduzido, como em Caixa vazia; ou std::vector lista;.

Como consertar: escreva o tipo: std::vector<int> lista;.

request for member ‘size’ in … which is of non-class type ‘const char*’

O que significa: a CTAD deduziu const char* de um texto entre aspas, e esse tipo não tem métodos.

Como consertar: Caixa<std::string> nome{"Ana"};.

error: invalid conversion from ‘const char*’ to ‘int’ com [with T = int]

O que significa: o objeto foi criado com um tipo (Caixa<int>) e você tentou guardar outro.

Como consertar: crie outro objeto, do tipo certo (Caixa<std::string>). Um objeto nunca muda de tipo.


Referência rápida

EscritaO que faz
template <typename T> class Caixa { … };Declara um template de classe
Caixa<int> c{5};Objeto de Caixa com T = int
Caixa c{5}; C++17CTAD: T deduzido pelo construtor
template <typename T> T Caixa<T>::pegar() const { … }Corpo de método escrito fora da classe
template <typename T, typename U = int>Dois parâmetros, o segundo com valor padrão
template <typename T, int N>Parâmetro que é um número (como em std::array<int, 5>)
template class Caixa<int>;Instanciação explícita (gera Caixa<int> naquele .cpp)

Exercícios

1. Usar o template

Complete para criar uma caixa de double com o valor 9.90, escrevendo o tipo:

Caixa preco{9.90};

2. Corpo fora da classe

Complete a primeira linha do corpo do método trocar, escrito fora da classe:

template <typename T>
void (T novo) { conteudo = novo; }

3. Com C++17 ou mais novo, qual é o tipo de c em Caixa c{7.5};?

4. A Ana pôs a classe template em caixa.h e os corpos dos métodos em caixa.cpp. O que acontece ao compilar g++ caixa.cpp main.cpp?

5. Em std::vector<std::string> nomes;, o que é std::string?

Missão final: o template no lugar certo

Missão: este projeto tem três arquivos (veja as abas). Rode: a ligação falha, porque os corpos do template estão em caixa.cpp. Mova os dois corpos para o fim do caixa.h e deixe o caixa.cpp vazio (só um comentário). O programa deve mostrar Ana e 42.

#include <iostream>
#include <string>
#include "caixa.h"

int main() {
    Caixa<std::string> nome{"Ana"};
    Caixa<int> idade{42};
    std::cout << nome.pegar() << '\n';
    std::cout << idade.pegar() << '\n';
}
#pragma once

template <typename T>
class Caixa {
public:
    Caixa(T valor);
    T pegar() const;
private:
    T conteudo;
};
#include "caixa.h"

template <typename T>
Caixa<T>::Caixa(T valor) : conteudo{valor} {}

template <typename T>
T Caixa<T>::pegar() const {
    return conteudo;
}
#pragma once

template <typename T>
class Caixa {
public:
    Caixa(T valor);
    T pegar() const;
private:
    T conteudo;
};

template <typename T>
Caixa<T>::Caixa(T valor) : conteudo{valor} {}

template <typename T>
T Caixa<T>::pegar() const {
    return conteudo;
}
// vazio: o template inteiro mora no caixa.h

Resumo

  • Um template de classe é o molde de uma classe: template <typename T> class Caixa { … };.
  • Cada tipo gera uma classe diferente: Caixa<int> e Caixa<std::string> não se misturam.
  • Desde o C++17, a CTAD deduz o tipo pelo construtor: Caixa c{5};. Cuidado com textos entre aspas (viram const char*) e objetos vazios (escreva o tipo).
  • std::vector, std::map, std::string… são templates. O <int> de std::vector<int> é o argumento do template.
  • Corpos fora da classe repetem template <typename T> e usam Caixa<T>::.
  • Templates ficam inteiros no .h. Separados num .cpp, dão referência não definida na ligação, porque o compilador só gera o código onde vê o corpo e o uso juntos.
  • Próximo passo: dizer quais tipos um template aceita, em Concepts.