Templates variádicos em C++ – descomplicando o monstro pt.1

templates variádicos em C++

Se voce já conhece as funções template, você deve saber que elas fornecem flexibilidade ao código e evitam que a mesma função seja redefinida várias vezes apenas para que se suporte tipos diferentes de dados. Muito prático, não? Todavia, mesmo as funções template tem limitações no que diz respeito à quantidade de seus parâmetros: eles têm um número fixo determinado na assinatura da função. Se a coisa é assim, como podemos então criar uma função que possa receber quantos argumentos nós quisermos, como o printf do C, por exemplo? Aí é que entram os templates variádicos.

Os templates variádicos são tipos especiais de templates que aceitam um número varável de paramêtros de template através do uso de pacotes de parâmetros.

Veja a seguir um exemplo de classe que emprega templates variádicos.

template <typename... Types>
class variadicTemplateClass {
    //...
};

Pacote de parâmetros (parameter pack)

Um pacote de parâmetros (ou parameter pack, em inglês) é um tipo especial de parâmetro usado pelos templates variádicos que aceita um número variável de argumentos. A sintaxe para se criar um parameter pack é a seguinte: usa-se a palavra typename ou class, seguida de três pontos () e do nome do pacote de parâmetro desejado, como Types no exemplo acima.

Sintaxe – pacote de parâmetros

<typename… Types>
<class… Types>


Exemplo 1 – Instanciação de templates variádicos com diversos números de argumentos de template

variadicTemplateClass<double> instanceA;
variadicTemplateClass<double, int, string> instanceB;
variadicTemplateClass<vector<int>> instanceC;
// Também é permitido instanciar o template com 0 argumentos de template
variadicTemplateClass<> instanceD;

Como evitar que um template variádico seja instanciado com zero argumentos de template

Algumas vezes desejamos que o template variádico seja instanciado com pelo menos 1 argumento. Felizmente, é fácil obrigar o usuário a fornecer ao menos um argumento de template durante a instanciação do template: basta definí-lo com um primeiro parâmetro template simples, e um segundo parâmetro template que é um pacote de parâmetros, como mostrado no bloco de código abaixo. Nele, o parâmetro template T1 obrigado o usuário a fornecer ao menos um argumento durante a instanciação do template.

template <typename T1, typename... Types>
class VariadicTemplateClass {
    //...
};

Uso dos templates variádicos

Apesar da versatilidade dos templates variádicos, eles também possuem um incoveniente com o qual se é preciso lidar: não é possível iterar (como em um loop) pelos valores contidos em um pacote de parâmetros, isto é, pelos argumentos do template. Para acessar os diferentes valores presentes nos argumentos de um template variádico é preciso recorrer à recursão de template ou às fold expressions (ou expressões de dobra, em tradução livre).

Templates variádicos com recursão de template

O uso dos templates variádicos com recursão de template segue um princípio bastante simples, vejamos como isso se dá. Antes de tudo é necessário definir uma função como a classe mostrada no bloco de código acima, com um parâmetro template normal T1, por exemplo, e um pacote de parâmetros, Types. Em seguida, é preciso criar uma função que execute algum trabalho tomando como argumento de entrada o primeiro argumento passado para a função de base (aquela que possui um parâmetro template normal e outro um pacote de parâmetros).

Tendo em mãos essas duas funções, basta que invoquemos a segunda, que realiza o verdadeiro trabalho, dentro da primeira no início de cada recursão, e em seguida chamemos a primeira passando-lhe o pacote de parâmetros expandido como novos argumentos (linha 25 do exemplo 2).


Exemplo 2 – Função template variádica com recursão e expansão de pacote de parâmetros

#include <iostream>
#include <string_view>

using namespace std;

void printValueType(int value) {
    cout << "Valor inteiro: " << value << endl;
}

void printValueType(double value) {
    cout << "Valor double: " << value << endl;
}

void printValueType(string_view value) {
    cout << "Valor string: " << value << endl;
}

void processValues() { // Caso de base para encerrar a recursão
    cout << "Não há mais elementos para processar. Finalizando o programa..." << endl;
}

template <typename T1, typename... Tn>
void processValues(T1 arg1, Tn... args) {
    printValueType(arg1);
    processValues(args...);
}

int main()
{
    processValues("Ola!"sv, 10, 30.5);

    return 0;
}

Valor string: Ola!
Valor inteiro: 10
Valor double: 30.5
Não há mais elementos para processar. Finalizando o programa…


No exemplo acima, na main, a função processValues é invocada com 3 argumentos: um do tipo string_view (que é uma espécie de primo mais leve do string), um int e um double. Assim, processValues é chamada com a seguinte assinatura: void processValues(string_view arg1, Tn... args) – note que o primeiro parâmetro de template da função tomou o tipo de um string_view, visto ser esse o tipo do primeiro argumento que lhe fora passado (para relembrar como funcionam os templates, leia nosso artigo sobre o assunto).

Dentro da função processValues (linha 24) chamamos printValueType passando-lhe o argumento de tipo string_view, arg1. O compilador então resolve a nossa chamada executando a sobrecarga da função printValueType que aceita um parâmetro de tipo string_view; vemos na saída, portanto, a mensagem: “Valor string: Ola!”, como esperado pela execução da linha 15 do código. Na sequência, a função processValues é invocada novamente, mas dessa vez com a seguinte expressão: processValues(args...). O que isso significa?

A expressão args... significa mais ou menos o seguinte: todos os argumentos restantes que estão compactados no pacote chamado args serão expandidos e separados entre si por vírgulas, resultando, no nosso caso, no código que vemos a seguir: processValues(10, 30.5) – isso é efeito dos três pontos que vêm após args, isto é, é efeito de uma expressão de dobra (falaremos em detalhes sobre isso na parte 2 deste artigo). O processo então se repete: a função printValueType é então chamada com o valor 10 como argumento, e processValues(30.5) é executada.

Na última iteração, quando printValueType(30.5) tiver sido chamada, a função processValues será executada sem nenhum argumento, e sua versão que não recebe parâmetros (exemplo 2, linha 18) será invocada, resultando no print que vemos na última linha da saída do programa “Não há mais elementos para processar. Finalizando o programa…”. Essa sobrecarga da função processValues é portanto crucial para que a recursão possa se encerrar; sem ela, o programa teria um erro, pois na última iteração a função processValues seria chamada sem nenhum argumento, mas a sua definição com parâmetros template exige ao menos um argumento (T1) em entrada.

O problema com a recursão de templates: cópias custosas

Em cada nível de recursão na chamada da função processValues do exemplo 2 os argumentos que lhe são passados em entrada são copiados. Em nosso exemplo, isso não é um problema tão grande, visto que os tipos dos dados são simples (int, double e string_view), isto é, são leves de se copiar. Todavia, esse nem sempre é o caso, e algumas vezes tais funções templates são invocadas com argumentos cujos tipos são classes definidas pelo usuário que possuem muitos atributos e métodos, sendo portanto mais custosas (ou pesadas) de se copiar. A situação fica ainda pior quando a lista de argumentos que passamos para a função é muito grande, como em processValues(arg1, arg2, arg3, arg4, arg5, arg6, arg7) – haja cópias!

Para contornar esse problema é possível utilizar a técnica do perfect forwarding (ou encaminhamento perfeito, ou transmissão perfeita, em tradução livre para o português), que permite conservar a natureza dos argumentos passados para a função durante cada uma de suas chamadas. Com o perfect forwading, se um valor de tipo lvalue ou referência para lvalue for fornecido como argumento do template, ele será tratado como uma referência lvalue; caso o argumento seja um rvalue, ele será tratado como uma referência rvalue. Desta forma, cópias desnecessárias são evitadas. Vejamos como utilizar essa técnica no exemplo 3.


Exemplo 3 – Função template variádica usando perfect forwarding para evitar cópias

#include <iostream>
#include <string_view>

using namespace std;

void printValueType(int value) {
    cout << "Valor inteiro: " << value << endl;
}

void printValueType(double value) {
    cout << "Valor double: " << value << endl;
}

void printValueType(string_view value) {
    cout << "Valor string: " << value << endl;
}

void processValues() { // Caso de base para encerrar a recursão
    cout << "Não há mais elementos para processar. Finalizando o programa..." << endl;
}

template <typename T1, typename... Tn>
void processValues(T1&& arg1, Tn&&... args) {
    printValueType(forward<T1>(arg1));
    processValues(forward<Tn>(args)...);
}

int main()
{
    processValues("Ola!"sv, 10, 30.5, 40.2f);

    return 0;
}

Valor string: Ola!
Valor inteiro: 10
Valor double: 30.5
Valor double: 40.2
Não há mais elementos para processar. Finalizando o programa…


No exemplo acima, como se pode ver na linha 23, os tipos dos parâmetros template na função processValues foram modificados de T1 e Tn... para T1&& e Tn&&..., duas referências rvalue não const. Esse tipo de parâmetros, quando usado em parâmetros template, é chamado de forwarding reference (ou referência de encaminhamento, em português) e permite que o tipo original dos dados fornecidos como argumentos da função template seja conservado, bastando passar os parâmetros de entrada como argumentos da função template da biblioteca padrão std::forward; como retorno da função std::forward<T1>(arg1), por exemplo, estará o valor de arg1 com o seu tipo original inalterado: se ele era uma referência lvalue, assim permanecerá e será transmitido; se era um rvalue (como quando passamos um literal tal qual "Ola!"sv como argumento da função), como rvalue será retornado. Desta forma, se passam referências entre as chamadas recursivas da função e cópias desnecessárias são evitadas.

Próximos passos

Vimos neste artigo como criar funções ou classes templates com número variável de parâmetros pelo uso de templates variádicos e seus pacotes de parâmetros. Descobrimos que é possível utilizar tais templates com funções recursivas, e que essa técnica possui um problema que lhe é inerente: a criação de cópias desnecessárias dos argumentos do template a cada vez que uma nova camada da recursão é iniciada. Contudo, aprendemos também que – felizmente – é possível contornar esse problema das cópias usando forwarding references e perfect forwarding.

Resta-nos falar sobre o uso de templates variádicos com as fold expressions (ou expressões de dobra, em português), mas isso será o assunto da parte 2 deste artigo, então fique esperto para não perder a sequência desse assunto. Até mais!

Gostou do artigo? Então inscreva-se na nossa newsletter para não perder nenhum dos nossos artigos 🙂

Foto de perfil de Emanoel

Sou apaixonado por tecnologia, literatura e também filosofia. O cultivo dessas paixões ao longo da minha trajetória me inspiraram a compartilhar aquilo que aprendo com os outros da maneira mais clara que eu possa. Sou formado em Engenharia Elétrica no Brasil, e também sou engenheiro formado na França. Trabalho atualmente como programador C++ em uma multinacional francesa, uma das maiores empresas de TI do mundo.

Leave a Reply

Your email address will not be published. Required fields are marked *