C++11 wprowadził szablon wariadyczny (ang. a variadic template), który potrafi przyjąć dowolną liczbę argumentów szablonu, także zerową. Szablon wariadyczny jest mechanizmem czasu kompilacji, który jest konkretyzowany, kiedy jest użyty.
Szablon wariadyczny poznamy po trójkropku ... w liście parametrów
szablonu:
#include <iostream>
using namespace std;
template <typename... P>
void foo()
{
cout << __PRETTY_FUNCTION__ << endl;
cout << sizeof...(P) << endl;
}
int
main()
{
foo<>();
foo<int>();
foo<bool, double>();
}
W powyższym przykładzie trójkropek został użyty w definicji paczki parametrów, a potem w rozwinięciu tej paczki.
Paczkę parametrów (ang. a parameter pack) definiujemy z użyciem
trójkropka, po którym następuje nazwa paczki, np. p. Paczka p
składa się z parametrów p1, p2, …,
p(n-1), pn. Paczka parametrów szablonu może być
szablonu albo funkcji. Paczka parametrów jest używana w rozwinięciu
paczki albo wyrażeniu złożenia.
Paczka parametrów szablonu definiuje parametry szablonu tego samego rodzaju: w przykładzie wyżej są typowe, a w przykładzie niżej wartościowe.
#include <iostream>
using namespace std;
template <unsigned... N>
void foo()
{
cout << __PRETTY_FUNCTION__ << endl;
cout << sizeof...(N) << endl;
}
int
main()
{
foo<>();
foo<1>();
foo<1, 2>();
// foo<1, -1>();
}
Nazwę paczki z następującym trójkropkiem nazywa się rozwinięciem paczki (ang. pack expansion). Paczka parametrów szablonu jest rozwijana do listy parametrów szablonu oddzielonych przecinkami:
#include <iostream>
#include <string>
struct A
{
};
template <typename... P>
struct B: P...
{
B()
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
};
int
main()
{
// Expands to a structure type that does not have a base class.
B<>();
// Expands to a structure type that has the base class A.
B<A>();
// Expands to a structure type derived from A and string.
B<A, std::string>();
}
W przykładach wyżej argumenty szablonu musiały być jawnie podane, bo funkcje i konstruktor nie miały zdefiniowanych parametrów i przekazanych argumentów wywołania, na podstawie których argumenty szablonu mogły być wywnioskowane. Argumenty dla parametrów szablonu w paczce są wnioskowane na podstawie definicji paczki parametrów funkcji i argumentów wywołania funkcji.
Paczka parametrów funkcji jest definiowana w liście parametrów funkcji także z użyciem trójkropka: pierwsze podajemy nazwę paczki parametrów szablonu, trójkropek, a następnie nazwę paczki parametrów funkcji. Co ciekawe, definicja paczki parametrów funkcji jest jednocześnie rozwinięciem paczki parametrów szablonu.
#include <iostream>
using namespace std;
template <typename... P>
void foo(P... p)
{
cout << __PRETTY_FUNCTION__ << endl;
}
int
main()
{
foo();
foo(1);
foo(1, 2.0);
}
W przykładzie wyżej funkcja przyjmuje argumenty przez wartość. Żeby
funkcja przyjmowała argumenty przez referencję stałą, to należy
zdefiniować paczkę parametrów jako const Args &... args.
Rozwinięcie paczki parametrów funkcji zapisujemy jako nazwa paczki z następującym trójkropkiem.
#include <iostream>
#include <string>
#include <vector>
template <typename T, typename... P>
auto
factory(P... p)
{
return T{p...};
}
int
main()
{
std::cout << factory<std::string>("Hello!") << std::endl;
auto p = factory<std::vector<int>>(1, 2, 3);
}
Paczkę parametrów szablonu można rozwijać w zgraniu (ang. lockstep) z paczką parametrów funkcji, np. kiedy inicjalizujemy obiekty bazowe parametrami konstruktora klasy wyprowadzonej:
#include <iostream>
#include <string>
struct A
{
A() = default;
A(int) {}
};
template <typename... P>
struct B: P...
{
B(const P &... p): P(p)...
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
};
int
main()
{
B<>();
B<A>(1);
B<A, std::string>({}, "Hello!");
}
Argumenty funkcji możemy przetwarzać rekurencyjnie z użyciem paczek parametrów. Sztuczka polega na zdefiniowaniu dwóch parametrów funkcji: pierwszy jest zwykły, do przetworzenia przez bieżące wywołanie, a drugi jest paczką do przetworzenia przez wywołanie rekurencyjnego. Paczka jest rozwijana w listę argumentów wywołania rekurencyjnego. W ten sposób paczka parametrów wywołania rekrencyjnego nie ma już pierszego parametru z paczki bieżącego wywołania.
#include <iostream>
#include <string>
template <typename P1, typename... P>
void
print(P1 p1, P... p)
{
std::cout << p1;
if constexpr(sizeof...(P))
print(p...);
}
int
main()
{
print("Hello", ' ', std::string("World"), " x ", 100, '\n');
}
Wyrażenie złożenia (ang. a fold expression, od C++17) jest przepisem
na wygenerowanie docelowego (zamierzonego) wyrażenia z użyciem
dowolnego dwuargumentowego operatora op i paczki parametrów.
Wyrażenie tak się nazywa, bo składa (jak obrus) docelowe wyrażenie
(to, które sami napisalibyśmy “ręcznie”) do skompresowanego zapisu.
Wyrażenie złożenia jest rozwijane, konkretyzowane dla danej paczki
parametrów, co może zastąpić przetwarzanie rekurencyjne. Wyrażenie
złożenia poznajemy po trójkropku i nawiasach. Są cztery wersje: dwie
jednoargumentowe i dwie dwuargumentowe, które używają tego samego
binarnego (wymagającego dwóch operandów) operatora op.
Częścią wyrażenia złożenia jest wyrażenie E, które używa paczki p.
Wyrażenie złożenia jest rozwijane przez konkretyzowanie wyrażenia E
dla kolejnych parametrów paczki p. Wyrażenie E skonkretyzowane
dla parametru pi zapisujemy jako Ei.
Wersje jednoargumentowe wymagają jednego wyrażenia E i operatora
op. Są one rozwijane mniej więcej w ten sposób:
E1 op E2 op … op E(n-1) op En
Wynik powyższego wyrażenia zależy od wiązania operatora op, bo
kierunek opracowania podwyrażeń z operatorem op (np., E1
op E2) zależy od wiązania operatora op: albo od lewej do
prawej strony jeżeli ma wiązanie lewe (ang. left-to-right
associativity), albo od prawej do lewej strony jeżeli ma wiązanie
prawe (ang. right-to-left associativity).
Dla działania łącznego (np. dodawania) kierunek nie ma znaczenia, bo
wynik będzie ten sam. Jeżeli jednak działanie nie jest łączne, to
kolejność ma znaczenie. Proszę sprawdzić: 3 - 2 - 1 opracowujemy od
lewej do prawej strony: (3 - 2) - 1 = 0, a nie od prawej do lewej: 3 -
(2 - 1) = 2. Wniosek: operator - musi mieć wiązanie lewe.
Nie ma wyrażenia złożenia, które jest rozwijane w powyższy sposób,
żeby kompilator opracował podwyrażenia w kierunku zgodnym z wiązaniem
operatora op. Wprowadzono natomiast dwie wersje (jednoargumentowego
wyrażenia złożenia), które narzucają ten kierunek:
wersja lewa: (... op E) rozwijana do ((E1 op
E2) op …)
wersja prawa: (E op ...) rozwijana do (… op (E(n-1)
op En))
Zatem:
wersja lewa opracowuje podwyrażenia od lewej (do prawej) strony,
jakby operator op miał wiązanie lewe,
wersja prawa opracowuje podwyrażenia od prawej (do lewej) strony,
jakby operator op miał wiązanie prawe.
W przypadku działania łącznego obie wersje zwrócą ten sam wynik.
Jeżeli jednak działanie nie jest łączne, to musimy wybrać właściwą
wersję, w zależności od wiązania operatora op. W przykładzie niżej
odejmowanie nie jest łączne i ma wiązanie lewe, więc powinniśmy użyć
lewej wersji jednoargumentowego wyrażenia złożenia.
template <int... P>
constexpr int left()
{
return (... - P);
}
template <int... P>
constexpr int right()
{
return (P - ...);
}
int main()
{
// (3 - 2) - 1 = 0
static_assert(left<3, 2, 1>() == 0);
// 3 - (2 - 1) = 2
static_assert(right<3, 2, 1>() == 2);
}
Przykład poniżej pokazuje konieczność użycia prawego wyrażenia:
#include <cassert>
template <typename... T>
void left(T &... p)
{
(... += p);
}
template <typename... T>
void right(T &... p)
{
(p += ...);
}
int main()
{
int x = 1, y = 2, z = 3;
// We would like to inrement y by z, and then x by y:
//
// x += y += z;
//
// The above compiles and works as expected, because += has the
// right associativity, and so we need the right fold expression.
// left(x, y, z);
right(x, y, z);
assert(x == 6);
assert(y == 5);
assert(z == 3);
}
Wersje dwuargumentowe wymagają wyrażenia inicjalizującego A, które
jest drugim argumentem. Kompilator rozróżnia wyrażenia A i E po
paczce parametrów. Są dwie wersje:
wersja lewa: (A op ... op E) rozwijana do ((A op E1) op
…)
wersja prawa: (E op ... op A) rozwijana do (… op (En
op A))
Strumienie wejścia-wyjścia są często wyrażeniem inicjalizującym
dwuargumentowego wyrażenia złożenia z operatorem strumieniowym
(wyciągania >> albo wstawiania <<), dla których musimy użyć
wyrażenia lewego, bo:
lewym operandem operatora strumieniowego (>> albo <<) jest
strumień wejściowy albo wyjściowy,
operator strumieniowy zwraca strumień, który otrzymał jako lewy operand,
strumień zwracany przez operator strumieniowy staje się lewym operandem kolejnego operatora strumieniowego.
Oto przykład:
#include <iostream>
#include <sstream>
#include <string>
#include <utility>
using namespace std;
template <typename... P>
void
read(P &... p)
{
(cin >> ... >> p);
// (p >> ... >> cin);
}
template <typename... P>
void
write(P &&... p)
{
(cout << ... << p);
// (p << ... << cout);
}
int
main()
{
write("Hello", ' ', std::string("World"), " x ", 100, "!\n");
string txt;
int x;
bool b;
read(txt, x, b);
write(txt, ' ', x, ' ', b, '\n');
}
Oto przykład z wersją prawą:
#include <cassert>
template <typename R, typename... T>
void left(R &r, T &... p)
{
(r += ... += p);
}
template <typename R, typename... T>
void right(R &r, T &... p)
{
(p += ... += r);
}
int main()
{
int x = 1, y = 2, z = 3;
// We would like to inrement y by z, and then x by y:
//
// x += y += z;
//
// The above compiles and works as expected, because += has the
// right associativity, and so we need the right fold expression.
// left(x, y, z);
right(x, y, z);
assert(x == 6);
assert(y == 5);
assert(z == 3);
}
Wyrażenie dwuargumentowe jest wygodne i ekspresywne, ale moglibyśmy
się bez niego objeść: wyrażenie A możemy dodać do paczki i użyć
wyrażenia jednoargumentowego. Dodajemy albo na początek paczki, żeby
użyć wersji lewej, albo na koniec, żeby użyć wersji prawej. Przykład
niżej używa operatorów strumieniowych, więc musimy użyć wersji lewej.
#include <iostream>
#include <sstream>
#include <string>
#include <utility>
using namespace std;
template <typename... P>
void
read(P &&... p)
{
(... >> p);
}
template <typename... P>
void
write(P &&... p)
{
(... << p);
}
int
main()
{
write(cout, "Hello", ' ', std::string("World"), " x ", 100, "!\n");
istringstream in("Hi! 100 0");
string txt;
bool b;
// We don't care about the 100, so we read it into a temporary.
read(in, txt, int(), b);
write(cout, txt, ' ', 200, ' ', b, '\n');
}
Binarny operator przecinka jest nietypowy, ponieważ łączy dwa
podwyrażenia opracowywane niezależnie. Przecinek ma wiązanie lewe,
więc lewe podwyrażenie jest opracowane pierwsze, niezależnie od
nawiasów z prawego podwyrażenia. Co ciekawe, operator ten ma
najniższy priorytet, a jednak ustala porządek opracowania podwyrażeń
operatorów o wyższym priorytecie. W przykładzie niżej, przecinki (a
nie nawiasy) ustalają porządek opracowania podwyrażeń z operatorem
<<.
#include <iostream>
using namespace std;
int main()
{
(cout << '1', cout << '2'), cout << '3';
cout << '1', (cout << '2', cout << '3');
}
Przykład niżej używa wersji lewej, gdzie (std::cout << ", " << p) to
E, a przecinek to op. Jeżeli paczka p jest pusta, to wyrażenie
złożenia jest puste. Jeżeli p ma tylko jeden parametr, to
kompilator dokooptowuje dodatkowy pusty parametr, jeżeli taki istnieje
(dla operatora przecinka jest nim void()), bo op jest binary.
Żeby zadbać o prawidłowe wypisywanie przecinka, to pierwszy parametr
przetwarzamy osobno, poza paczką (tak, jak w przetwarzaniu
rekurencyjnym), a parametry z paczki przetwarzamy wyrażeniem złożenia.
#include <iostream>
#include <string>
template <typename T, typename... P>
void
print(const T &t, const P &... p)
{
std::cout << t;
(... , (std::cout << ", " << p));
}
int
main()
{
print(5, "10", std::string("15"));
std::cout << std::endl;
// What's this?
1, void();
// Well, it's needed here:
print(1);
std::cout << std::endl;
}
Szablon wariadyczny przyjmuje dowolną liczbę argumentów.
Paczka parametrów jest używana w rozwinięciach albo w wyrażeniach złożenia.
Wariadyczne mogą być listy:
dziedziczenia i inicjalizacji,
parametrów szablonu i argumentów szablonu,
parametrów funkcji i argumentów funkcji.
Parametry paczki mogą być przetwarzane rekurencyjnie lub z użyciem wyrażenia złożenia.
Czy paczka parametrów szablonu może przyjąć argumenty różnego rodzaju?
Czy paczka parametrów może być pusta?
Co to jest wyrażenie złożenia?