27.4 Variadic template, pack és fold expression

A variadikus sablonok (variadic templates) teszik lehetővé, hogy egy függvény- vagy osztálysablon tetszőleges számú típusparamétert és argumentumot fogadjon el úgy, hogy a típusinformáció fordítási időben megmaradjon. A C++11-ben bevezetett parameter pack és pack expansion, a C++17-es fold expression, valamint a C++20-as constraint-ek együtt teszik lehetővé a típusos, mégis rugalmas interfészeket. Ez a rész a pack kibontásának mechanizmusát, a fold kiértékelését és az argumentumok élettartamára vonatkozó következményeket tárgyalja. A forwarding reference és a perfect forwarding mechanizmusát a Forwarding reference és perfect forwarding rész tárgyalta; a constraint-ekre a Concepts, requires és constrained overload rész épít. Itt ezekre csak a szükséges mértékben hivatkozunk.

A szabványban a variadikus sablonok és a pack expansion a [temp.variadic], a fold expression a [expr.prim.fold] alatt található. Ezek a paragrafushivatkozások tájékoztatók; a konkrét C++20 szabvány szövegében ellenőrizendők. A példák C++20 nyelvi módot és a standard library C++17/20 elemeit használják; a buildparancsok feltételezik a C++20 támogatását. A bemutatott kimenetek illusztrációk, nem mért eredmények.

27.4.1 Parameter pack és pack expansion

A parameter pack a sablon típusparamétereinek vagy függvényparamétereinek olyan csoportja, amelyet a ... jelöl. Két alapvető alakja van:

template <class... Ts>          // típusparameter-pack
void f(Ts... args);             // függvényparameter-pack

template <class... Ts>
void g(const Ts&... args);      // a minta: const Ts&

A Ts... nem egyetlen típus, hanem nulla vagy több típus szintaktikai csoportja. A args... hasonlóképpen nulla vagy több függvényparaméter. A pack mérete a sizeof...(Ts) vagy sizeof...(args) kifejezéssel kérdezhető le; ez fordítási időben konstans.

A pack expansion olyan szintaktikai minta, amelyben egy pack neve szerepel, és amelyet a compiler a pack minden elemére egyszer kifejt. A minta a ... előtti egység. Például a const Ts&... mintában a minta const Ts&, így minden Ts elemre egy const T& paraméter jön létre. A std::forward<Args>(args)... mintában a minta std::forward<Args>(args), így minden Args/args párra egy-egy std::forward hívás keletkezik.

A pack expansion érvényességének feltételei:

A pack nem futásidejű tömb. Nem lehet Ts típusú változót deklarálni, és nem lehet indexelni. A pack elemei csak kifejtve, például fold expressionnel, rekurzióval vagy std::index_sequence-szel érhetők el.

27.4.2 Fold expression és üres pack

A fold expression (C++17) a pack expansion egy olyan formája, amely a pack elemeit egy bináris operátorral kombinálja. Négy szintaktikai alak létezik:

(Eop…)unary right fold,(…opE)unary left fold,(Eop…opI)binary right fold,(Iop…opE)binary left fold. \begin{aligned} (E \mathbin{op} \dots) &\quad\text{unary right fold},\\ (\dots \mathbin{op} E) &\quad\text{unary left fold},\\ (E \mathbin{op} \dots \mathbin{op} I) &\quad\text{binary right fold},\\ (I \mathbin{op} \dots \mathbin{op} E) &\quad\text{binary left fold}. \end{aligned}

Itt E a packot tartalmazó minta, I pedig egy inicializáló kifejezés. A zárójelezés levezetése (vázlat):

A zárójelezés determinisztikus, de ez nem jelenti azt, hogy az operandusok kiértékelési sorrendje is mindig rögzített; erre a következő alfejezetekben visszatérünk.

A fold expression csak a megengedett operátorokkal használható. Ide tartoznak többek között a +, -, *, /, %, &, |, ^, <<, >>, &&, ||, ,, az összehasonlító operátorok és a hozzárendelő operátorok. A <=> nem szerepel a fold operátorai között, ezért spaceship operátorral nem foldolhatunk.

Az üres pack kezelése operátorfüggő. Unary fold üres packra csak három operátornál érvényes ([C++20] [temp.variadic]/9):

Minden más operátornál — a + és a * esetén is — az üres pack unary folddal fordítási hiba; a szabvány nem ad 0-t vagy 1-et. Binary foldnál az I inicializáló érték kötelező, és üres pack esetén az eredmény maga az I; ezért ha üres packot is támogatni kell, a kezdőértékes (0 + ... + args) és (1 * ... * args) a helyes alak.

// fold_empty.cpp — C++20: üres pack és fold
template <class... Ts> constexpr auto sum(Ts... xs)     { return (0 + ... + xs); }
template <class... Ts> constexpr auto product(Ts... xs) { return (1 * ... * xs); }
template <class... Ts> constexpr bool all(Ts... xs)     { return (xs && ...); }
template <class... Ts> constexpr auto sum_unary(Ts... xs) { return (xs + ...); }

static_assert(sum() == 0 && sum(1, 2, 3) == 6);
static_assert(product() == 1 && product(2, 3, 4) == 24);
static_assert(all() && all(true, true) && !all(true, false));
static_assert(sum_unary(1, 2) == 3);
#ifdef EMPTY_UNARY
static_assert(sum_unary() == 0);       // hiba: üres pack unary + folddal
#endif
int main() {}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic fold_empty.cpp figyelmeztetés nélkül fordul; -DEMPTY_UNARY mellett a sum_unary() fold of empty expansion over operator+ hibát ad.

Fontos példa a rövidzárlatra és az asszociativitásra:

template <class... Ts>
bool all_positive(Ts... xs) {
    return (true && ... && (xs > 0));
}

Ez bal oldali binary fold: ((true && (x1 > 0)) && (x2 > 0)) && .... Az üres pack eredménye true. A && rövidzárlatos, így ha egy korábbi elem hamis, a későbbi xs > 0 kifejezések nem értékelődnek ki. Ugyanez || esetén is igaz. A + operátor nem rövidzárlatos, és az operandusok kiértékelési sorrendje sem garantált.

27.4.3 Index sequence és tuple indexelés

A std::index_sequence<Is...> (C++14) egy olyan típus, amelynek template argumentumlistája 0, 1, ..., N-1. A std::make_index_sequence<N> és a std::index_sequence_for<Ts...> segítségével generálhatunk indexsorozatot. A tuple elemeihez compile-time indexkel, a std::get<Is>(t) hívással férünk hozzá.

template <class Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& t, std::index_sequence<Is...>) {
    ((std::cout << (Is == 0 ? "" : ", ") << std::get<Is>(t)), ...);
}

template <class... Ts>
void print_tuple(const std::tuple<Ts...>& t) {
    print_tuple_impl(t, std::index_sequence_for<Ts...>{});
}

A print_tuple_impl második paramétere névtelen, mert csak a típusából nyerjük ki az Is... packot. A std::get<Is>(t) a t const lvalue referenciáján keresztül const referenciát ad az adott elemre. A std::get indexe compile-time ellenőrzött: ha Is >= sizeof...(Ts), a program nem fordul. A std::apply (C++17) ugyanezt a mintát használja, de std::invoke-on keresztül hívja a callable-t, így tagfüggvénypointereket is kezel.

A tuple indexelésnél a value category számít. Ha t lvalue, a std::get<Is>(t) lvalue referenciát ad; ha t rvalue, akkor rvalue referenciát. A std::apply(f, t) a t value category-ját továbbítja, így a callable megkaphatja a tuple elemeit a megfelelő value category-vel.

27.4.4 Forwarding pack és argumentum lifetime

A template <class... Args> void f(Args&&... args) egy forwarding reference pack. Minden Args a hívás argumentumából dedukálódik, és a value category megőrzéséhez a std::forward<Args>(args)... szükséges:

template <class... Args>
void forward_to_sink(Args&&... args) {
    sink(std::forward<Args>(args)...);
}

A std::forward<Args>(args)... mintája minden argumentumra a saját Args-át használja, így az lvalue argumentum lvalue marad, az rvalue argumentum rvalue marad. A std::move(args)... ezzel szemben feltétel nélkül rvalue-vé tesz, és elveszíti az lvalue/rvalue különbséget; forwarding packnél ez hibás.

Az argumentum élettartama szempontjából kulcsfontosságú, hogy a forwarding reference nem hosszabbítja meg a temporary élettartamát a függvényhíváson túl. A temporary a teljes kifejezés végéig él, így a forward_to_sink hívása alatt a sink számára is érvényes. Ha azonban a packot eltároljuk, már nem hivatkozhatunk a temporary-re:

template <class... Args>
auto make_refs(Args&&... args) {
    return std::tuple<Args&&...>(std::forward<Args>(args)...);
}

auto t = make_refs(std::string("x")); // a temporary a teljes kifejezés végén megszűnik
// std::get<0>(t) használata érvénytelen élettartamra hivatkozik

Itt a std::string("x") temporary a teljes kifejezés végén megszűnik, a t azonban rvalue referenciát tárol rá. A t létrehozása önmagában nem UB, de a std::get<0>(t) használata dangling referencián keresztül történik, ami nem definiált viselkedés. A tulajdonos ebben az esetben nem a t, hanem a temporary volt; a temporary megszűnése invalidálja a referenciát.

A biztonságos tároláshoz a referenciákat értékekké kell alakítani:

template <class... Args>
auto make_owned(Args&&... args) {
    return std::make_tuple(std::forward<Args>(args)...);
}

A std::make_tuple a std::decay_t<Args>... típusokat tárolja, így a temporary értéke bemásolódik vagy mozgatódik, és a visszatérő tuple független a bemeneti temporary élettartamától. Ugyanezt teszi a std::tuple<std::decay_t<Args>...> is. A const Ts&... pack olvasásra biztonságos a híváson belül, de ha a referenciákat eltároljuk, ugyanígy dangling veszély áll fenn.

27.4.5 Kiértékelési sorrend és operátorfüggés

A fold zárójelezése determinisztikus, de az operandusok kiértékelési sorrendje az operátor szabályaitól függ. A , operátor balról jobbra értékel, és a bal operandus értéke után a jobb operandus értékelődik; a && és || rövidzárlatos és sorrendezett. A +, *, << és hasonló operátorok esetén az operandusok kiértékelési sorrendje nem specifikált, még akkor sem, ha a fold zárójelezése rögzített.

Ezért a logger példában a , foldot használjuk, ha a kiírás sorrendje számít:

template <class... Args>
void log_line(std::string_view level, const Args&... args) {
    std::cout << '[' << level << "] ";
    ((std::cout << args << ' '), ...);
    std::cout << '\n';
}

A ((std::cout << args << ' '), ...) unary jobbra zárójelező fold a , operátorral. A , balról jobbra értékel, így a std::cout kiírások sorrendje garantált. Egy (f(args) + ...) fold esetén az f hívások sorrendje nem garantált, még akkor sem, ha a + zárójelezése balról jobbra történik. A && és || foldoknál a rövidzárlat miatt a későbbi elemek ki sem értékelődhetnek; ez akkor fontos, ha a minta mellékhatással jár.

27.4.6 Rekurzió vagy fold: compile-time költség

A C++11-es variadikus sablonok tipikus feldolgozása rekurzióval történt:

template <class T>
void print(const T& x) { std::cout << x; }

template <class T, class... Rest>
void print(const T& x, const Rest&... rest) {
    std::cout << x << ' ';
    print(rest...);
}

Ez lineáris példányosítási mélységet okoz: minden argumentumhoz egy új print példány jön létre, és a rekurzió mélysége az argumentumok számával nő. Nagy pack esetén a compiler template-rekurziós limitjébe ütközhetünk (GCC/Clang esetén például a -ftemplate-depth értéke korlátozhat).

A fold expression ugyanezt egyetlen kifejezéssel fejezi ki:

template <class... Ts>
void print(const Ts&... xs) {
    ((std::cout << xs << ' '), ...);
}

A fold elkerüli a rekurziós mélységet, de a kifejezés mérete továbbra is lineáris a pack elemszámában. A compile-time költség nem feltétlenül kisebb; a példányosított kifejezés mérete és a compiler optimalizálási munkája compiler- és kódfüggő. A std::index_sequence használata szintén lineáris példányosítást eredményez, de gyakran áttekinthetőbb, mint a rekurzió. Mérést nem állítunk; a választás a konkrét fordító és kódbázis tapasztalata alapján történik.

27.4.7 Kidolgozott példa: logger és tuple-adapter

Az alábbi példa a log_line függvényt const Args&... packkal, a tuple kiíratását pedig std::index_sequence-szel valósítja meg. A kód C++20-ban fordul, GCC-vel vagy Clang-gel.

// variadic_logger.cpp
#include <cstddef>
#include <iostream>
#include <string>
#include <string_view>
#include <tuple>
#include <utility>

template <class... Args>
void log_line(std::string_view level, const Args&... args) {
    std::cout << '[' << level << "] ";
    ((std::cout << args << ' '), ...);
    std::cout << '\n';
}

template <class Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& t, std::index_sequence<Is...>) {
    ((std::cout << (Is == 0 ? "" : ", ") << std::get<Is>(t)), ...);
}

template <class... Ts>
void print_tuple(const std::tuple<Ts...>& t) {
    print_tuple_impl(t, std::index_sequence_for<Ts...>{});
}

int main() {
    log_line("INFO", "start", 42, 3.5);

    auto t = std::make_tuple(std::string("db"), 5432, true);
    print_tuple(t);
    std::cout << '\n';
}

A buildparancs:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 variadic_logger.cpp -o variadic_logger
./variadic_logger

Clang esetén ugyanez clang++-szal. A várható kimenet illusztráció:

[INFO] start 42 3.5 
db, 5432, 1

A log_line a const Args&... packot használja, mert csak olvas; a temporary argumentumok élettartama a hívás teljes kifejezéséig tart, így a kiírás alatt érvényesek. A függvény nem tárolja el a referenciákat, ezért nincs dangling. A print_tuple a tuple típusparamétereiből generál 0..N-1 indexeket, és a std::get<Is> hívással éri el az elemeket. A std::make_tuple a main-ben értékeket tárol, így a tuple független a temporary-ktől.

A példa nem használ C++20-as constraint-et. Ha a log_line-nak nem streamelhető típust adunk, a hiba a << kifejezésnél jelentkezik, nem az interfésznél. C++20 concept-tel a hiba a hívás helyére hozható; ennek részletei a Concepts, requires és constrained overload részben találhatók.

27.4.8 Gyakori hibák és javításuk

27.4.9 Feladatok

  1. Magyarázd el, mi a különbség a const Ts&... és a Ts&&... pack között a value category és az élettartam szempontjából. Miért nem használható a std::move(args)... a forwarding pack továbbítására?

  2. Írj sum és all_positive függvényt fold expressionnel, üres pack kezeléssel, és adj static_assert-eket. A sum üres packra 0-t, az all_positive üres packra true-t adjon.

  3. Az alábbi make_refs példa dangling referenciát eredményez. Magyarázd meg, hol szűnik meg a temporary, ki a tulajdonos, és javítsd std::make_tuple-lel.

template <class... Args>
auto make_refs(Args&&... args) {
    return std::tuple<Args&&...>(std::forward<Args>(args)...);
}
auto t = make_refs(std::string("x"));
  1. Írj apply_to_tuple függvényt std::index_sequence-szel, amely egy callable-t és egy tuple-t kap, és meghívja a callable-t a tuple elemeivel. Hasonlítsd össze a std::apply-jal.

  2. A log_line példában miért garantált a kiírás sorrendje a , folddal, és miért nem garantált egy + foldnál? Adj példát && folddal történő rövidzárlatra.

27.4.10 Megoldások

27.4.10.1 Első feladat

A const Ts&... pack minden argumentumot const lvalue referenciaként köt, és a temporary élettartamát a hívás teljes kifejezéséig meghosszabbítja. Ez olvasásra biztonságos, de a referenciák eltárolása danglinghez vezet. A Ts&&... forwarding reference pack minden argumentumnál megőrzi a dedukált típust és a value category-t; a továbbításhoz std::forward<Args>(args)... kell. A std::move(args)... feltétel nélkül rvalue-vé tesz, így az lvalue argumentumok is rvalue-ként továbbítódnak, és elveszik a value category. A helyes minta:

template <class... Args>
void forward_to_sink(Args&&... args) {
    sink(std::forward<Args>(args)...);
}

27.4.10.2 Második feladat

#include <concepts>

template <std::integral... Ts>
constexpr auto sum(Ts... xs) {
    return (0 + ... + xs);
}

template <std::integral... Ts>
constexpr bool all_positive(Ts... xs) {
    return (true && ... && (xs > 0));
}

static_assert(sum() == 0);
static_assert(sum(1, 2, 3) == 6);
static_assert(all_positive());
static_assert(all_positive(1, 2, 3));
static_assert(!all_positive(1, -2, 3));

A sum bal oldali binary fold: ((0 + x1) + x2) + ...; üres packra az iniciáló 0 marad. Az all_positive bal oldali binary fold: ((true && (x1 > 0)) && ...); üres packra true. A std::integral constraint C++20-as; a static_assert-ek a fordítási időben ellenőrzik az eredményt.

27.4.10.3 Harmadik feladat

A std::string("x") temporary a make_refs hívását tartalmazó teljes kifejezés végén megszűnik. A make_refs visszatérő std::tuple<std::string&&> rvalue referenciát tárol a temporary-re. A t létrehozása után a temporary már nem él, így a t dangling referenciát tartalmaz. A tulajdonos a temporary volt; a t nem tulajdonos, csak nem tulajdonló hivatkozást tárol. A temporary megszűnése invalidálja a referenciát; a std::get<0>(t) használata nem definiált viselkedés.

A javított változat:

#include <tuple>
#include <string>
#include <utility>
#include <iostream>

template <class... Args>
auto make_owned(Args&&... args) {
    return std::make_tuple(std::forward<Args>(args)...);
}

int main() {
    auto u = make_owned(std::string("x")); // biztonságos: értéket tárol
    std::cout << std::get<0>(u) << '\n';
}

A std::make_tuple a std::decay_t<Args>... típusokat tárolja, így a std::string bemásolódik vagy mozgatódik, és a u független a temporary élettartamától.

27.4.10.4 Negyedik feladat

#include <tuple>
#include <utility>
#include <type_traits>
#include <cstddef>
#include <iostream>

template <class F, class Tuple, std::size_t... Is>
decltype(auto) apply_impl(F&& f, Tuple&& t, std::index_sequence<Is...>) {
    return std::forward<F>(f)(std::get<Is>(std::forward<Tuple>(t))...);
}

template <class F, class Tuple>
decltype(auto) apply_to_tuple(F&& f, Tuple&& t) {
    return apply_impl(
        std::forward<F>(f), std::forward<Tuple>(t),
        std::make_index_sequence<
            std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
}

int main() {
    auto t = std::make_tuple(1, 2, 3);
    auto add = [](int a, int b, int c) { return a + b + c; };
    std::cout << apply_to_tuple(add, t) << '\n';
}

Az apply_impl a std::get<Is>(std::forward<Tuple>(t))... pack expansionnal adja át a tuple elemeit a callable-nak. A std::forward<Tuple>(t) megőrzi a tuple value category-ját, így rvalue tuple esetén a std::get rvalue referenciát ad. A std::apply ehhez képest std::invoke-ot használ, így tagfüggvénypointereket és std::reference_wrapper-t is kezel; a saját változat közvetlen hívást végez, ezért egyszerűbb, de szűkebb.

27.4.10.5 Ötödik feladat

A , operátor balról jobbra értékel, ezért a ((std::cout << args << ' '), ...) foldban a kiírások sorrendje garantált. A + operátor operandusai között nincs sorrendezés, ezért egy (f(args) + ...) foldban az f hívások sorrendje nem garantált, még akkor sem, ha a + zárójelezése balról jobbra történik. A && és || rövidzárlatos; például:

template <class... Ts>
bool all_true(Ts... xs) {
    return (true && ... && xs);
}

Ha az első xs hamis, a későbbi elemek kiértékelése a && szabályai szerint elmarad. Mellékhatásos mintánál ez megfigyelhető:

template <class... Fs>
bool any_true(Fs... fs) {
    return (false || ... || fs());
}

A false || ... || fs() bal oldali binary fold; az fs() hívások balról jobbra, rövidzárlattal értékelődnek. Az első true után a többi fs() nem hívódik meg.

27.4.10.6 Ellenőrzési feltétel

A megoldások akkor helyesek, ha a kód C++20-ban fordul, a static_assert-ek teljesülnek, a make_refs javított változata nem tárol referenciát temporary-re, és az apply_to_tuple a tuple elemeit a megfelelő value category-vel adja át. A log_line és a tuple-adapter példája nem tartalmaz eltárolt referenciát a hívás után.

27.4.11 Összefoglaló