cpp

Wstęp

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.

Paczka parametrów

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

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>();
}

Rozwinięcie paczki

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

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

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!");
}

Przetwarzanie rekurencyjne

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

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

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:

Zatem:

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);
}

Wersja dwuargumentowa

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:

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:

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);
}

Z dwuargumentowego do jednoargumentowego

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');
}

Krótki a trudny przykład: lista oddzielona przecinkami

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;  
}

Podsumowanie

Quiz