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 w miejscu użycia.

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

Trójkropek jest częścią definicji paczki parametrów (ang. a parameter pack), po którym następuje nazwa paczki. Paczka parametrów szablonu może być szablonu albo funkcji. Paczka parametrów jest używana w rozwinięciu albo wyrażeniu złożenia.

Paczka parametrów szablonu

Parametry szablonu w paczce są 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 argumentó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 parametrów i 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ść. Może też przyjmować przez referencję stałą, jeżeli paczkę zdefiniujemy 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. podczas inicjalizacji obiektów bazowych 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

Parametry paczki funkcji możemy przetwarzać rekurencyjnie. Sztuczka polega na zdefiniowaniu dwóch parametrów funkcji: pierwszy do przetworzenia przez funkcję, a drugi jako paczka do przekazania jako argument wywołania rekurencyjnego. W ten sposób zmniejszamy liczbę parametrów paczki funkcji o jeden za każdym wywołaniem rekurencyjnym.

#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 wyrażenia z użyciem dowolnego dwuargumentowego operatora op i paczki parametrów. Wyrażenie tak się nazywa, bo składa docelowe wyrażenie (to, które sami napisalibyśmy “ręcznie”) do skompresowanego zapisu. Wyrażenie złożenia jest konkretyzowane w czasie kompilacji dla danej paczki parametrów, co eliminuje potrzebę przetwarzania rekurencyjnego. Wyrażenie złożenia poznajemy po ... i nawiasach. Są cztery wersje: dwie jednoargumentowe i dwie dwuargumentowe, ale ciągle z użyciem tego samego operatora op.

Paczka p składa się z parametrów p1, p2, …, p(n-1), pn. Wyrażenie złożenia wymaga wyrażenia E, które używa paczki p. Wyrażenie E opracowane dla parametru pi zapisujemy jako Ei.

Wersja jednoargumentowa

Wersje jednoargumentowe wyrażenia złożenia, gdzie argumentem jest E:

Wersja lewa przetwarza parametry paczki od lewej strony (do prawej, czyli od p1 do pn), a prawa od prawej (do lewej, czyli od pn do p1). Zatem wersja lewa przetwarza argumenty tak, jakby operator miał wiązanie lewe, a prawa tak, jakby miał wiązanie prawe.

Dla działania łącznego (np. dodawania) nie ma znaczenia, czy przetwarzamy od lewej czy od prawej strony, więc oba wyrażenia złożenia (lewe i prawe) zwrócą ten sam wynik. Jeżeli jednak działanie nie jest łączne, to trzeba wybrać właściwą wersję wyrażenia. W przykładzie niżej odejmowanie nie jest łączne i ma wiązanie lewe, więc powinniśmy użyć lewego 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ą drugiego argumentu, którym jest wyrażenie inicjalizujące A.

Strumienie wejścia-wyjścia są często wyrażeniem inicjalizującym dwuargumentowego wyrażenia złożenia, jak w przykładzie niżej. Wyrażenie musi być lewe, bo:

#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 prawym wyrażeniem:

#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

Jeżeli dodalibyśmy wyrażenie A na początek paczki, to moglibyśmy skorzystać z jednoargumentowego wyrażenia złożenia, ale byłoby to niewygodne i mniej ekspresywne, jak pokazano niżej:

#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

Przykład niżej używa operatora przecinka, który jest nietypowy, ponieważ łączy dwa niezależne wyrażenia. Łączone wyrażenia nie mają ze sobą nic wspólnego i są opracowywane niezależnie. Przecinek gwarantuje jedynie, że pierwsze będzie wykonane pierwsze wyrażenie, zatem nawiasy z drugiego wyrażenia nie mogą mieć wpływu na kolejność wykonania pierwszego wyrażenia. W poniższym przykładzie, nawiasowanie nie ma wpływu na wynik, ponieważ kolejność opracowania wyrażeń z operatorem << ustalają przecinki.

#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 E to (std::cout << ", " << p) a operatorem jest przecinek. Jeżeli paczka p jest pusta, to wyrażenie złożenia jest puste. Jeżeli p ma jeden parametr, to kompilator dokooptowuje dodatkowy pusty parametr, jeżeli taki istnieje (dla operatora , jest nim void()), bo op wymaga dwóch argumentów. Żeby na początku nie wypisać samego przecinka, to pierwszy parametr wypisujemy poza paczką (tak, jak w przetwarzaniu rekurencyjnym), a wypisanie każdego parametru z paczki jest poprzedzone wypisaniem przecinka.

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

Podsumowanie

Quiz