Paraméterlisták és -továbbítás

Czirkos Zoltán, Pohl László · 2022.11.20.

Változó hosszúságú sablonparaméterlista. Sablonparaméterek típushelyes továbbítása. Az std::move és az std::forward függvények.

1. C++11 változó hosszúságú sablonparaméterlista

A C nyelvben lehetett olyan függvényt írni, amely tetszőlegesen sok, tetszőleges típusú paramétert vett át: ... a függvény paraméterlistájában (variadic function). Ez a nyelvi eszköz C++-ban is létezik, azonban csak beépített típusokra használható, objektumokra nem. A fő probléma az vele, hogy a paraméterlistának ezen a részén C++-ban nem lehetne eldönteni, hogy a függvény érték- vagy referenciaparamétert vár. Meg persze az is, hogy ennek az eszköznek a szemlélete nem fér össze a nyelv általános szemléletével, az erősen típusossággal. A változó hosszúságú paraméterlistán már C-ben is ki volt kapcsolva mindenféle típusellenőrzés. Nem véletlen, hogy a printf()-nek is a formátumsztringben meg kellett adni a típusokat: %d, %f, %s.

Pedig ilyesmire gyakran lenne szükségünk. Képzeljünk el egy printf()-hez hasonló függvényt, amely a neki paraméterként adott objektumokat mind kiírja a szabványos kimenetre! Ezek az objektumok ismeretlen típusúak (ezért sablonra lesz szükség), és ismeretlen számúak. Első körben valahogy így közelíthetnénk meg a problémát:

template <typename ARG1>
void my_print(ARG1 arg1) {
    std::cout << arg1;
}


template <typename ARG1, typename ARG2>
void my_print(ARG1 arg1, ARG2 arg2) {
    std::cout << arg1 << arg2;
}


template <typename ARG1, typename ARG2, typename ARG3>
void my_print(ARG1 arg1, ARG2 arg2, ARG3 arg3) {
    std::cout << arg1 << arg2 << arg3;
}


/* ... */

Látszik, hogy ez így nem fog menni: nem csak a függvény paraméterei lehetnek akárhányan, hanem a sablonparaméterek is. Szerencsére C++11-ben bevezették a változó hosszúságú sablonparaméter-listát is. Így lehet olyan függvénysablont vagy osztálysablont írni, amelynek bármilyen számú és típusú sablonparamétere lehet (variadic template). Ezt a sablonparaméterek listájában egy ...-tal kell jelölni, amit a típus után írunk.

template <typename... ARGS>
void func(ARGS... args) {
    std::cout << sizeof...(args) << " paraméter." << std::endl;
}


int main() {
    func<int, double>(1, 2.3);
    func(1, 2.3, 'a');
}

A fenti func() függvénynek akárhány sablonparamétere, és ezen keresztül akárhány paramétere is lehet. A deklaráció typename... kulcsszava adja meg a tetszőlegesen sok típusból álló listát. A függvény első hívásánál ezek a sablonparaméterek explicite meg vannak adva. A második hívásnál pedig a szokásos módon a fordító vezeti le a típusokat a konkrét hívásból: ott a func<int, double, char>() függvény példányosodik.

A függvény formális paraméterlistájában a ARGS... args „képzeletbeli” paraméter reprezentálja a tetszőlegesen sok, érték szerint átvett paramétert. A függvény belsejében az args név ilyenkor nem egy konkrét változót jelöl, hanem az összes átvett paramétert jelképező ún. paramétercsomagot (argument pack). A paramétercsomag méretét a sizeof... operátorral tudjuk lekérdezni. (Vigyázat, ez a paraméterek számát adja, nem a bájtban mért méretüket!)

A paramétercsomag tagjait külön-külön nem is tudjuk kezelni, hanem csak egyben tudjuk kifejteni az egész csomagot. A kifejtést is a ... operátor végzi: az args... kifejezést írva a csomag kifejtődik, ami nagyjából azt jelenti, hogy a kifejezés helyére a fordító a paramétereket vesszővel elválasztva beírja. Ezt olyan kontextusban lehet megtenni, ahova amúgy is vesszővel elválasztott értékek kerülhetnének.

Az eredeti példához visszatérve, ha szeretnénk egyesével kiírni a képernyőre ezeket a paramétereket, akkor a vesszővel elválasztás nem megfelelő:

template <typename... ARGS>
void my_print(ARGS... args) {
    std::cout << args...;   // HIBÁS, ez lenne a jelentése: std::cout << 1, 'a', 2.3;
}


int main() {
    my_print(1, 'a', 2.3);
}

Ehelyett a rekurziónál szokásos módszert kell alkalmaznunk: szétválasztanunk az első paramétert és a többit. Akkor az első paraméternek külön neve is van, hivatkozni tudunk rá, utána pedig a függvényt meghívjuk a többi paraméterrel. Ebben az esetben már megfelelő szintaktikailag és szemantikailag is, ha a ... helyére egy vesszővel elválasztott listát képzelünk:

template <typename HEAD_ARG, typename... TAIL_ARGS>
void my_print(HEAD_ARG head, TAIL_ARGS... tail) {
    std::cout << head;
    my_print(tail...);
}

A rekurzió persze egyszer véget kell érjen, mégpedig akkor, amikor elfogynak a paraméterek. A mostani func() sablonnak legalább egy paramétere kell legyen, viszont a meghívott függvények között előbb-utóbb lesz egy olyan, amelynek már nincs paramétere. Ezért írunk egy olyan my_print() függvényt is, amely nem csinál semmit. Ez zárja majd le a rekurziót:

void my_print() {
    /* szándékosan üres */
}

Így végül egy my_print(1, 'a', 2.3) kifejezés az alábbi hívásokat és kiírásokat indítja majd:

my_print<int, char, double>(1, 'a', 2.3);   // 1
my_print<char, double>('a', 2.3);   // a
my_print<double>(2.3);  // 2.3
my_print();

Az üres my_print() függvény szándékosan nem sablon. Lényegében a my_print() függvénysablon teljes specializációjának szánjuk, arra az esetre, amikor a paraméterlista üres. A my_print()-nek viszont ilyen specializációja nem létezhetne, mert legalább egy sablonparamétere kell legyen, a HEAD_ARG. Csak a TAIL_ARGS az, ami üres lehet. A függvénynév túlterhelés (overload) azonban lehetséges, és ez most épp kapóra jön.

Egy furcsa nyelvi trükk: using swallow = int[];

A paramétercsomagot más módon is ki lehet fejteni. Az ilyen trükkök általában a nyelvi elemek furcsa kiforgatásai. Például ez is egy működő megoldás lehet:

C++11
template <typename... ARGS>
void print_stuff(ARGS... args) {
    using swallow = int[];
    swallow{(void(std::cout << args), 0)..., 0};
}

Itt definiálunk egy tömb típust: swallow. Aztán ennek a típusnak, mintha a konstruktorát hívnánk a tömbnek, létrehozzuk egy temporális példányát. Az első 0-ra azért van szükség, hogy ne definiáljunk üres tömböt, ha egyáltalán nem lenne paraméter. A tömb többi elemeit pedig a (void(std::cout << arg), 0) kifejezések értékei fogják meghatározni, amik mindig 0-k, mivel a bennük lévő vessző karakter egy vessző operátor, amelyik a jobb oldali értéket tekinti az egész kifejezés értékének. Közben a kiírások megtörténnek, a << operátorok miatt.

Ha mindez a trükközés nem lenne elég: a void-dá konvertálásra azért van szükség, hogy ne lehessen a függvény működését kívülről megzavarni egy operator,(std::ostream&, int) függvény definiálásával. A második 0-ra meg azért, hogy üres paraméterlista esetén ne jussunk szintaktikai hibához (üres tömb nem létezhet). Nem túl olvasható a kód, de működik...

Folding expressions

C++17-től a folding expressions nevű nyelvi elemmel ezt a konkrét feladatot még egyszerűbben megoldhatjuk:

C++17
template <typename... ARGS>
void print_stuff(ARGS... args) {
    (std::cout << ... << args);
}

A nyelvi elem hatására egy olyan kifejezést fejt ki a fordító, amelyben az std::cout-tal kezdődően, az összes paraméter << operátorokkal egymás után van leírva. A kifejezést körbezáró kerek zárójel kötelező, a nyelvi elem része.

C variadic function: akárhány paraméter, pl. printf, scanf.

C++-ben is működik, de objektumot nem tud átvenni. Nem lehetne eldönteni, hogy érték vagy referencia paramétert vár.

C++11: változó hosszúságú sablonparaméter-lista (variadic template):

template <typename... ARGS>// paramétercsomag (parameter pack)
void func(ARGS... args) {
 std::cout << sizeof...(args) << " paraméter." << std::endl;
} // hány darab?

int main() {
 func<int, double>(1, 2.3);
 func(1, 2.3, 'a');
}

2. Saját, típushelyes printf()

Az eddigiek alapján már egy saját, típushelyes printf()-et is összerakhatunk, amely nem csak a beépített típusokat, hanem a felhasználó által definiáltakat is ismeri, sőt mindegyiket a saját << operátorával írja ki. A függvény karakterenként halad a formátumsztringben, és ha % karaktert talál, akkor kiírja a formátumsztring utáni első paramétert, aztán meghívja magát a formátumsztring maradék és a paraméterlista maradék részével. A rekurzió leállási feltétele az, hogy elfogynak a paraméterek; a formátumsztring ilyenkor egy az egyben kiírható, mert elvileg már nem tartalmaz % jelet:

#include <iostream>


void my_printf(char const *format) {
    std::cout << format;
}

 
template <typename HEAD, typename... TAIL>
void my_printf(char const *format, HEAD head, TAIL... tail) {
    if (*format == '%') {
        std::cout << head;
        my_printf(format+2, tail...);
    } else {
        std::cout << *format;
        my_printf(format+1, head, tail...);
    }
}


int main() {
    std::string e = "Ernőke";
    my_printf("%_ %_ éves.", e, 5);
}

A % karakter megtalálása után két karaktert is lehet a formátumsztringben ugrani, így az nagyjából kompatibilis lehet a régi printf()-ével, ahol a típust még jelölni kellett. A típusok jelölésére igazából nincs is szükség, azokat a sablonok útján a fordító automatikusan kezeli, ezért a példában egyszerűen egy alulvonás van a konverziót megadó karakter helyén.

Az okosabb printf()-ek képesek arra, hogy átrendezzék a paraméterlistájukat. Ezt például akkor tudjuk kihasználni, amikor különböző nyelvekre fordítjuk le a programunkat: előfordulhat, hogy angolul, németül vagy franciául más sorrendben kell írni a mondatrészeket. Mivel a típust a sablonokat használó my_printf()-nek már nem kell megadnunk, a helyükön lévő karaktert használhatjuk sorszámnak is. Vegyünk egy magyar nyelvű példát:

my_printf("%1 %2 éves.\n",      "Ernőke", 5);    // Ernőke 5 éves.

my_printf("%2 éves lett %1.\n", "Ernőke", 5);    // 5 éves lett Ernőke.

Ezt a működést a legegyszerűbben úgy tudnánk megvalósítani, ha az összes paramétert betennénk egy tömbbe. A paraméterek azonban nem egyforma típusúak: az első egy sztring, a második pedig egy egész szám. De ez csak annyit jelent, hogy közvetlenül nem tudjuk őket tömbbe tenni. Mivel a kiírásnál amúgy is sztringgé alakul minden, az átalakítást elvégezhetjük előre is.

Kerüljenek ezért a tömbbe a sztringgé alakított változatok! Mindehhez szükségünk lesz az alábbi segédfüggvényre:

#include <string>
#include <sstream>

template <typename T>
std::string to_string(T const &valami) {
    std::ostringstream os;
    os << valami;
    return os.str();
}

Ez a neki paraméterként átadott, tetszőleges típusú objektum << operátorát használva sztringgé alakítja azt. Ha ez megvan, akkor már nincs más dolgunk, mint a my_printf() függvényünknek adott összes paramétert nem közvetlenül kifejteni, hanem az egyes paramétereket odaadni a to_string() függvénynek. Ez az alábbi módon tehető meg:

template <typename... ARGS>
void my_printf(char const *format, ARGS const & ... args) {
    std::string strings[] = { to_string(args)... };    // !
    
    for (int i = 0; format[i] != '\0'; ++i) {
        if (format[i] == '%') {
            std::cout << strings[format[i+1]-'1'];
            i++;
        } else {
            std::cout << format[i];
        }
    }
}

A trükk a jelölt helyen van. A ... operátort nem csak közvetlenül a paramétercsomag neve után használhatjuk, hanem egy kifejezés után is. Olyan ez, mint egy nagyon alacsony precedenciájú operátor. Ilyenkor azt jelenti, hogy a csomagban lévő paramétereket egyesével be kell helyettesíteni az előtte lévő kifejezésbe, és az így kapott kifejezéseket kell vesszővel elválasztva a kifejtés helyére írni. Az "Ernőke", 5 paraméterek esetén így a tömböt az alábbi módon inicializálja a fordító:

std::string strings[] = { to_string("Ernőke"), to_string(5) };

Ennek hatására pedig a to_string() függvénysablon példányosodik to_string<char const *> és to_string<int> formában, majd elvégzi a konverziókat. A kapott tömb már egyszerűen indexelhető, bármikor bármelyik sztring kivehető belőle, akár többször is.

A változó hosszúságú paraméterlista elemeinek kiértékelését gyakran bontjuk több függvényre, mint a fenti iteratív print() példában is. Így a paramétercsomag önálló paraméterekké fejthető ki.

Hova kerül a ...?

Tegyük fel, hogy az alábbi variadikus függvényeinket az 1, 'a', 2.3 paraméterekkel hívtuk meg. Mit ad meg ilyen esetben a ... helye?

template <typename... ARGS>
void f(ARGS... args) {
    using swallow = int[];
    
    swallow{ g1(args...) };        // g1(1, 'a', 2.3);

    swallow{ g2(args)... };        // g2(1), g2('a'), g2(2.3)
}

Vagyis ami után írjuk, az lesz ismételve vesszőkkel elválasztva. Ha a függvényhívás zárójelén belül, akkor az argumentumokat megkapja a függvény együtt. Ha a függvényhíváson kívül, akkor nem egy, hanem három függvényhívás lesz, azok viszont egyesével kapják a paramétereket.

Generikus lambdával

C++14 óta vannak generikus lambdák is, ahol a paraméter típusa auto. Egy ilyennel a sztringgé alakító függvényt könnyedén elrejthetjük a my_printf() belsejébe:

template <typename... ARGS>
void my_printf(char const *format, ARGS const & ... args) {
    auto to_string = [](auto const & valami) {
        std::ostringstream os;
        os << valami;
        return os.str();
    };

    std::string strings[] = { to_string(args)... };
    
    for (int i = 0; format[i] != '\0'; ++i) {
        if (format[i] == '%') {
            std::cout << strings[format[i+1]-'1'];
            i++;
        } else {
            std::cout << format[i];
        }
    }
}
void my_printf(char const *format) {
    std::cout << format;
} // ha elfogytak a template paraméterek
 
template <typename HEAD, typename... TAIL>
void my_printf(char const *format, HEAD head, TAIL... tail) {
    if (*format == '%') {
        std::cout << head;
        my_printf(format+2, tail...);
    } else {
        std::cout << *format;
        my_printf(format+1, head, tail...);
    }
}

int main() {
    std::string e = "Ernőke";
    my_printf("%_ %_ éves.", e, 5); // %_ jel van
}

3. Bináris literálisok sablon metaprogramozással

Mint azt láttuk régebben, a számokat a felhasználó által definiált literális utótag operátorai (literal suffix operator) több formában is átvehetik: nyersen (az egyes karaktereket) és előfeldolgozva (már számmá alakítva). Például ha szeretnénk egy 1101_binary módon használható utótag operátort, azt az alábbi módokon is megírhatjuk:

/* előfeldolgozva: ebben a példában nem célravezető,
 * mert tízes számrendszerben előfeldolgozva hamar túlcsordul */
unsigned long long int operator"" _binary (unsigned long long int p);

/* nyersen: ebben a példában jobb megoldás */
unsigned long long int operator"" _binary (char const *chars);

A szabvány ad egy harmadik, ezektől gyökeresen eltérő lehetőséget is: megengedi azt, hogy a függvényünk a feldolgozandó karaktereket sablonparaméterként vegye át. Ilyenkor egyéb paramétere nincs, mert a karaktereket a sablonparaméterek tartalmazzák:

template <char... ARGS>
unsigned long long operator"" _binary();

Például:

/* a leírt kifejezés: */
1101_binary;

/* a hívott függvény: */
operator"" _binary<'1', '1', '0', '1'>();

Ezeket a biteket ugyanúgy dolgozhatjuk fel, mint az előző printf-es rekurzív példa paramétereit: szétbontva a listát az első és a többi elemre. Ez most egy segédfüggvény lesz, hogy jobban látszódjon a működés. Az általános esetben a számítás könnyen elvégezhető: az első bitet, a lista fejét annyival kell balra léptetni, ahány bit jön még majd utána, vagyis ahány karakterből a lista farka áll:

1101    bemenet

1...    HEAD
 101    TAIL

Az argumentumok számát sizeof... operátor megmondja. Az első bit kiszámítását a specializált függvény fogja végezni, a karakterből a 0 ASCII kódját kivonva. Csakhogy...

template <char HEAD, char... TAIL>
unsigned long long chars_to_binary() {
    return chars_to_binary<HEAD>() << sizeof...(TAIL) | chars_to_binary<TAIL...>();
}


template <char HEAD>
unsigned long long chars_to_binary<HEAD>() {    // NEM MŰKÖDIK
    return HEAD - '0';
}


template <char... ARGS>
unsigned long long operator"" _binary() {
    return chars_to_binary<ARGS...>();
}

Ez nem fog működni, mivel a második függvénynél, a chars_to_binary<HEAD>()-nél a sablonparaméter változhat, pedig konkrét érték. Tehát ez egy részleges specializáció – és a szabvány szerint a függvényeknek csak teljes specializációja létezhet, részleges specializációja nem.

Két lehetőségünk van. Egyik, hogy teljes specializációt írunk az egy bitből álló esetekre. Ez itt vállalható, mert összesen kétféle bemenet van, a 0 és az 1. A megoldás előnye az egyszerűsége. Vegyük észre, hogy ebbe már a hibakezelés is be van építve: az 102_binary kifejezés fordítási hibát fog eredményezni, mivel a chars_to_binary<'2'> specializáció nem létezik, az alap sablon pedig nem működik erre.

template <>
unsigned long long chars_to_binary<'0'>() {
    return 0;
}

template <>
unsigned long long chars_to_binary<'1'>() {
    return 1;
}

A másik lehetőség, hogy egy segédosztályra bízzuk a probléma megoldását, mivel a szabvány az osztályok részleges specializációját megengedi. (Ezt mindig így kell csinálni: ha függvény részleges specializációjára lenne szükségünk, akkor ki kell szervezni a függvényt egy segédosztály statikus tagfüggvényévé. A segédosztály pedig már kaphat részleges specializációt.) A múltkori faktoriális függvényhez hasonló a megoldás, az osztályba tett statikus változóval:

template <char HEAD, char... TAIL>
struct Binary {
    static constexpr unsigned long long value =
        Binary<HEAD>::value << sizeof...(TAIL) | Binary<TAIL...>::value;
};


template <char ONEBIT>
struct Binary<ONEBIT> {
    static_assert(ONEBIT == '0' || ONEBIT == '1', "bits are either 0 or 1!");
    static constexpr unsigned long long value = ONEBIT-'0';
};


template <char... ARGS>
constexpr unsigned long operator"" _binary() {
    return Binary<ARGS...>::value;
}

Itt a bitet jelképező karakter 0/1 ellenőrzését egy fordítási idejű hibakezelőre, a static_assert-re bízhatjuk, amelyben egy saját magunk által írt üzenetben jelezhetjük a fordítási hibát.

Mindkét megoldás alkalmas arra, hogy a bennük szereplő összes függvényt és változót fordítási idejű constexpr konstanssá tegyük. A bitsorozat számmá alakítása még fordítási időben megtörténik.

Elődeklaráció: template <char... DIGITS> struct Binary;

A fenti Binary osztálysablonnak egy ilyen implementációja is elképzelhető:

template <char... DIGITS>
struct Binary;

template <>
struct Binary<> {
    static constexpr unsigned long long value = 0;
};

template <char HEAD, char... TAIL>
struct Binary<HEAD, TAIL...> {
    static_assert(HEAD == '0' || HEAD == '1', "bits are either 0 or 1!");
    static constexpr unsigned long long value =
        (HEAD-'0') << sizeof...(TAIL) | Binary<TAIL...>::value;
};

Itt az első, definiálatlan osztálysablonnak csak explicit specializációi léteznek. Az osztály deklarációjának a célja csak annyi, hogy a fordítóval tudassuk a sablon osztály paraméterezését. A középső, teljes specializáció adja a báziskritériumot (ha nincs bit, akkor az érték nulla), az alsó, részleges specializáció végzi a számítást.

Ebben a változatban kicsit szebb a kód, mert a teljes logika az alsó a kódrészletben van. Amikor csak egyetlen bit van, akkor is ez a specializáció lesz aktív, mert a TAIL... argumentumlista lehet üres is. A középső specializáció éppen a legfelül deklarált char... DIGITS megadása arra az esetre, amikor a DIGITS lista üres.

Eddig:

/* előfeldolgozva: ebben a példában nem célravezető, mert
 tízes számrendszerben előfeldolgozva hamar túlcsordul */
unsigned long long int operator"" _binary (unsigned long long int p);

/* nyersen: ebben a példában jobb megoldás */
unsigned long long int operator"" _binary (char const *chars);

Sablonként is lehet:

template <char... ARGS>
unsigned long long operator"" _binary();

1101_binary; /* Pl. a leírt kifejezés */
operator"" _binary<'1', '1', '0', '1'>(); /* a hívott fv */

4. A 2ⁿ darab függvény problémája

Idézzük fel az std::shared_ptr osztály működését! Ez egy referenciaszámlált okos pointert valósít meg. Az okos pointer objektumok nyilvántartják, hogy egy dinamikusan foglalt, ún. kezelt objektumra hány társukkal együtt mutatnak, hogy amikor az utolsó okos pointer is megszűnik, az felszabadíthassa a kezelt objektumot is. Így megkönnyítik a memóriakezelést abban az esetben, amikor a kezelt objektum felszabadításának felelőssége nem rendelhető egyértelműen semelyik objektumhoz vagy programmodulhoz.

Általános ajánlás, hogy ahogyan létrejött egy dinamikusan foglalt objektum, érdemes azt rögtön egy automatikus memóriakezelésű objektumra bízni (RAII – resource acquisition is initialization). Ha például létre szeretnénk hozni egy String típusú objektumot, és az azt kezelő std::shared_ptr<String> okos pointert, így írjuk:

String *ptr = new String();
std::shared_ptr<String> sptr(ptr);

Vagy még rövidebben:

std::shared_ptr<String> sptr(new String());

Tudjuk, hogy mindez kivételkezelés szempontjából is helyes. Ha a String konstruktorában kivétel dobódik, akkor az objektum létre sem jött, amint pedig a shared_ptr<String> konstruktorába kerül a végrehajtás, az objektum már kezeltnek számít. A két művelet, az objektum létrehozása és okos pointerre bízása két annyira összetartozó művelet, hogy érdemes egy függvényt is írni rá:

#include <memory>

template <typename T>
auto my_make_shared() {
    return std::shared_ptr<T>(new T());
}

int main() {
    auto p = my_make_shared<String>();
    *p = "hello";
}

Mindez kiválóan működik, egészen addig, amíg az objektum konstruktorának nincsenek paraméterei. Mivel a my_make_shared() a T típusról nem tud semmit, az utóbbi konstruktorparamétereiről sem, így annak sablon típusnak kell lennie:

template <typename T, typename ARG>
auto my_make_shared(ARG arg) {    // 1 paraméter
    return std::shared_ptr<T>(new T(arg));
}

Ez így kényelmes, mert bár az első sablonparamétert, a létrehozandó objektum típusát ki kell majd írni, de a másodikat, a konstruktorparaméter típusát már nem – levezeti a fordító. Azonban lehetnek kétparaméterű, háromparaméterű stb. konstruktorok... Ismerős problémához jutunk:

template <typename T, typename ARG1, typename ARG2>
auto my_make_shared(ARG1 arg1, ARG2 arg2) {   // 2 paraméter
    return std::shared_ptr<T>(new T(arg1, arg2));
}


template <typename T, typename ARG1, typename ARG2, typename ARG3>
auto my_make_shared(ARG1 arg1, ARG2 arg2, ARG3 arg3) {    // 3 paraméter
    return std::shared_ptr<T>(new T(arg1, arg2, arg3));
}

Ezt megoldhatnánk ...-tal, de előbb-utóbb egy másik problémába is bele fogunk ütközni. Ez nem a paraméterek számával, hanem a paraméterátadás módjával kapcsolatos. A fenti kód is, illetve az ARGS... args forma is a konstruktorparamétereket érték szerint veszi át. Előfordulhat, hogy a T konstruktora valamelyik paramétert referencia szerint venné át. Olyankor a my_make_shared()-en keresztül csak egy másolatot kapna, és helytelenül működne a program. Ha a my_make_shared() paraméterlistájába referenciát írunk: ARGS & ... args, az így megadott referenciák nem köthetők sem jobbértékhez, sem konstanshoz. Tehát ez sem járható út, és ugyanígy a konstans referencia sem tökéletes megoldás. Sőt, még az is előfordulhat, hogy az egyes paramétereket eltérő szemantikával vennénk át, némelyiket érték, másokat referencia szerint (az olvashatóság kedvéért template-ek kiírása nélkül):

auto my_make_shared(ARG1 & arg1, ARG2 & arg2);
auto my_make_shared(ARG1 & arg1, ARG2 const & arg2);
auto my_make_shared(ARG1 const & arg1, ARG2 & arg2);
auto my_make_shared(ARG1 const & arg1, ARG2 const & arg2);

Látjuk, hogy n darab paraméterhez így 2n változat tartozna, 0...n paraméterhez pedig összesen 2n+1-1 változatra lenne szükség. „Régen”, amikor delegáló függvénysablonokat írtak, programból generálták le ezeket a függvényeket.

A C++11 újabb csavart hozott a történetbe. Itt már nem is 2n esetről beszélünk, hanem 3n-ről: balértékről, konstans balértékről és jobbértékről. (Csak azért nem 4n-ről, mert a konstans jobbérték referencia létezhet, de haszontalan.) Ha a my_make_shared() függvénynek adott paraméter jobbérték, akkor a konstruktornak is jobbérték típusú paramétert kellene továbbadni, ezért ebben az esetben még az std::move függvényre is szükség van. Egy paraméter esetén ezekről a függvényekről lenne szó (az olvashatóság kedvéért megint a template-ek nélkül):

auto my_make_shared(ARG & arg) {
    return std::shared_ptr<T>(new T(arg));
}

auto my_make_shared(ARG const & arg) {
    return std::shared_ptr<T>(new T(arg));
}

auto my_make_shared(ARG && arg) {
    return std::shared_ptr<T>(new T(std::move(arg)));   // move!
}

Látjuk, hogy a helyzet tarthatatlan; kell egy olyan megoldás, amellyel az argumentumokat típushelyesen lehet a sablonfüggvényben továbbítani: ha balértékek voltak, balértékként, ha jobbértékek, akkor pedig jobbértékként. Ezt a szakirodalomban tökéletes argumentumtovábbításnak nevezik (perfect argument forwarding).

std::forward

Az std::move() függvénysablonhoz hasonlóan a C++11-be beépítettek egy std::forward() függvénysablont is, amelyik megoldja ezt a problémát. Ez a sablon arra képes, hogy bármilyen példányosító típus és típusmódosító (referencia, konstans stb.) esetén olyan típusúvá konvertálja a visszatérési értékét, mint ahogyan azt a paraméterátvevő függvény is látta. Ezzel úgy lehet egy hívott függvénynek továbbítani a hívó paramétereit, hogy azok a nem vesztik el balérték/jobbérték jellegüket. A használat módja az alábbi:

template <typename T, typename ARG>
auto my_make_shared(ARG && arg) {
    return std::shared_ptr<T>(new T(std::forward<ARG>(arg)));   // !
}

Vagyis majdnem ugyanúgy kell írni, mint az std::move() hívását, csak annyi a különbség, hogy meg kell adni sablonparaméterként a típust is. Ez működik változó argumentumszám esetén is. Ebben az esetben a függvény összes paraméterét típushelyesen továbbítja a fordító a T konstruktorának, mivel minden egyes paramétert egy std::forward() hívásba helyettesít a kifejezés:

template <typename T, typename... ARG>
auto my_make_shared(ARG && ... arg) {
    return std::shared_ptr<T>(new T(std::forward<ARG>(arg)...));
}

A fenti template ARG && paraméterről azt gondolhatnánk, hogy egy jobbérték típusú paramétert jelent, de ez nem igaz. Az így megadott referenciákat paramétertovábbító referenciának (forwarding reference) vagy univerzális referenciának (universal reference) nevezik. Nézzük meg, hogy mit jelent egy ilyen paraméter, mert ezen múlik az std::forward függvény működésének megértése!

Egy dinamikusan foglalt objektumot érdemes rögtön egy automatikus memóriakezelésű objektumra bízni (RAII):

std::shared_ptr<String> sptr(new String());

Érdemes rá függvényt írni:

#include <memory>

template <typename T>
auto my_make_shared() {
    return std::shared_ptr<T>(new T());
}

int main() {
    auto p = my_make_shared<String>();
    *p = "hello";
}

5. Template levezetési és összevonási szabályok

Tudjuk, hogy a fordító a sablonfüggvények esetén a hívásból kitalálja a sablonparaméterek típusát (template argument deduction). Azt szoktuk mondani, hogy a levezetett sablonparaméter típus megegyezik a hívásnál megadott típussal.

template <typename T>
void func(T what);

func(2);    // func<int>(int)

Ez a kijelentés azonban közel sem mindig igaz. A kép ennél árnyaltabb. Például ha a függvényt egy tömb típusú paraméterrel hívjuk meg, a fordító pointer típusúnak vezeti le a sablonparamétert. (Ennek neve angolul: decaying; the array decays to a pointer.) Ha pedig a híváskor átadott változó típusa konstans, akkor pedig a const minősítőt a fordító elhagyja. Ezt is látjuk a tárgykód fájlt visszafejtve:

template <typename T>
void func(T what) {}

int main() {
    int arr[10];
    func(arr);  // func<int*>(int*)
}
$ clang++ proba.cpp -c -o proba.o

$ nm -C proba.o
0000000000000000 T main
0000000000000000 W void func(int*)
template <typename T>
void func(T what) {}

int main() {
    int const j = 2;
    func(j);    // func<int>(int)
}
$ clang++ proba.cpp -c -o proba.o

$ nm -C proba.o
0000000000000000 T main
0000000000000000 W void func(int)

Látszik, hogy nem igaz az, hogy a hívás helyén adott érték típusa lesz a sablonfüggvény típusa. Ugyanezt láttuk az auto, a decltype() és a decltype(()) esetén is. Sőt az ezeknél alkalmazott levezetési szabályok még különböznek is a sablonfüggvényeknél alkalmazottaknál. De mindenhol úgy vannak kitalálva, hogy a legkisebb meglepetést okozzák, és hogy a kényelmes használatot segítsék elő.

A típusok megadásánál olyan szabályokat is érvényesítenek a fordítók, amelyek némileg módosítják a forráskód jelentését annak szó szerinti értelméhez képest. Például, bár az alábbi forráskódot elfogadják:

using MyType = int const;
MyType const j = 2;         // ok

A következő forráskódot viszont visszautasítják, hiába jelenti szó szerint ugyanazt, mint a fenti:

int const const j = 2;      // hibás

Ezt az összevonási szabályt is azért vezették be, hogy megkönnyítse a programozást. Egy felhasználói típusnév, mint itt a MyType, nem feltétlenül utal arra, hogy const minősítővel rendelkezik. Ugyanez a helyzet a sablonparaméterek mögötti, sablonkód írásakor még nem ismert típussal: nem kellene probléma legyen egy dupla const kulcsszó. Nem is az, mert a kettőt összevonja a fordító (const collapsing).

template <typename T> void func(T what);
func(2);    // func<int>(int)
template <typename T>
void func(T what) {}
int main() {
  int arr[10];
  func(arr);// func<int*>(int*)
}
$ clang++ proba.cpp -c -o proba.o

$ nm -C proba.o
0000000000000000 T main
0000000000000000 W void func(int*)
template <typename T>
void func(T what) {}
int main() {
  int const j = 2;
  func(j);// func<int>(int)
}
$ clang++ proba.cpp -c -o proba.o

$ nm -C proba.o
0000000000000000 T main
0000000000000000 W void func(int)

6. A C++11 új levezetési és összevonási szabályai

Mint a fentiekből látjuk, a C++98-as szabályok alapján nem volt lehetőségünk arra, hogy a függvénysablonokkal megkülönböztessük a balérték és jobbérték típusú paramétereket. A nyelv logikája az, hogy nem a hívó, hanem a hívott dönti el, hogy értékként vagy referenciaként veszi át a paramétert, és így annak balértéksége vagy jobbértéksége a függvényben már nem látszik.

template <typename T>
void func(T what);

String s;
func(s);            // String balérték, T = String

func(String());     // String jobbérték, T = String

Referencia referenciája?

A C++11-ben a paramétertovábbítás problémájának megoldására olyan új levezetési szabályokat kellett bevezetni, amely meg tudja különböztetni egymástól a jobbértékeket (rvalue) és a balértékeket (lvalue).

Mindkét szabály a jobbérték referenciákkal kapcsolatos. Az első egy új összevonási szabály, amely a const const-hoz hasonlóan azt adja meg, hogy minek kell történnie akkor, amikor egy referencia referenciája típus alakul ki a definiált típusneveken (typedef, using) keresztül. Alapvetően ilyen nem létezhetne, de új típusneveken keresztül „kialakulhat”:

using IntRef = int &;

int i;
IntRef & j = i;         // int &  & j = i;

Ez C++98-ban még tilos volt, de C++11-ben már szabályos. Az összevonás szabályai az alábbiak:

Melyik típusMilyen referenciájaEredmény
X &&X &
X &&&X &
X &&&X &
X &&&&X &&

Tehát az eredmény jobbértéksége logikai ÉS kapcsolatban van a típus és a kért referencia jobbértékségével: akkor lesz a kapott típus jobbérték referencia, ha jobbérték referencia jobbérték referenciája adódik. Minden más esetben balérték referencia típusú változót kapunk. Mindjárt meglátjuk, ez miért jó.

A paramétertovábbító referencia (forwarding reference)

A második a jobbérték referencia típusú sablonparaméterek esetén alkalmazott levezetési szabály. Ezt az alábbi szintaktika aktiválja:

template <typename T>
void func(T && what);

Azért nevezhetők ezek univerzális referenciának is, mert az így megadott, jobbérték referenciának látszó paramétertípus az automatikus levezetés során balérték és jobbérték referenciává is válhat. Ezt a híváskor az értékül adott paraméter típusa adja meg:

TípusPéldaLevezetett sablonparaméter
Balérték String s1;
func(s1);
T = String &
Jobbérték func( String() ); T = String

A szabály aktiválódása szigorú feltételekhez kötött. Egyik az, hogy a sablonfüggvény paramétere T && formájú kell legyen, semmilyen más forma nem jó. A másik feltétel, hogy a sablonparamétereket a fordítónak kell levezetnie, a konkrét függvényhívásból.

Mi az, ami nem paramétertovábbító referencia?

A fenti megkötésekkel vigyázni kell, mert vannak olyan esetek, amelyeknek látszólag megfelel egy kódrészlet, de jobban megnézve mégsem. Pl. az alábbi sablonparaméter akár a fordító által levezetett is lehet, de nem T &&, hanem MyVector<T> && formátumú:

template <typename T>
void func(MyVector<T> && v);

A következő példa paramétere pedig bár T && formátumú, de nem a hívásból levezetett, hanem az objektum típusából, amelynek a push_back() függvényét hívják:

template <typename T>
class MyVector {
  public:
    void push_back(T && what);
};

Mindkét esetben egyszerű jobbérték referenciáról van szó.

A két szabály együttes alkalmazása

Az új összevonási és levezetési szabályokat azért kellett látszólag ilyen bonyolultra kitalálni, mert kompatibilisnek kellett maradni minden helyen a C++98 szemantikájával. Ne feledjük, bár jobbérték referenciák a C++11 előtt nem voltak, jobbértékek léteztek eddig is! Olyan szabályokat kellett kitalálni, amik a régebben megírt kódrészletek jelentését sem változtatják meg.

A két szabálynak együttesen van értelme. Ha a T && paraméterű sablonfüggvény aktuális paramétere jobbérték (pl. egy temporális sztring), akkor a levezetési szabály alapján T = String adódik, amit a fejlécbe behelyettesítve a függvény fejléce void func(String && what) formát ölti, jobbérték referenciával veszi át az ideiglenes objektumot:

template <typename T>
void func(T && what);

func(String());  // func<String>(String && what)

Ha ugyanennek a függvénynek az argumentuma balérték, akkor a levezetési szabály alapján T = String &, amiből a void func(String & && what) adódik első körben, de erre még alkalmazni kell a referenciák összevonási szabályát is. A végleges változat így void func(String & what) lesz:

template <typename T>
void func(T && what);

String s;
func(s);  // func<String &>(String & what)

Valóban, a függvény balérték referenciával veszi át a balértéket.

int& a[3]; // error: nincs referenciatömb
int&* p;   // error: nincs pointer referenciára
int& &r;   // error: nincs referencia referenciára

Reference collapsing:

typedef int&  lref;
typedef int&& rref;
int n;
 
lref&  r1 = n; // type of r1 is int&
lref&& r2 = n; // type of r2 is int&
rref&  r3 = n; // type of r3 is int&
rref&& r4 = 1; // type of r4 is int&&

7. Az std::move() és az std::forward() függvények

A két új típusszabály ismeretében az std::move() és az std::forward() függvénysablonok működése is érthetővé válik.

Az std::forward() függvény működése

Az std::forward() függvény célja az volt, hogy egy függvényben a paraméterként átvett értéket, annak balérték/jobbérték tulajdonságát megőrizve tudjuk továbbadni egy másik, hívott függvénynek. Az előbb bemutatott, általános gyártófüggvényt egyszerűsítsük most egy egyparaméteres változatra! Az arg paramétert szeretnénk az std::forward() segítségével úgy továbbadni a konstruktornak, ha az eredetileg balérték volt, a konstruktor balértéket lásson, ha jobbérték, akkor pedig jobbértéket; vagyis ha kétféle konstruktor van, akkor a fordító ennek megfelelően választhasson közülük.

template <typename T, typename ARG>
T * create_new(ARG && arg) {
    return new T(std::forward<ARG>(arg));
}

Nézzük meg jobban a függvényt! Az első sablonparamétere, T a létrehozandó objektum típusa, a második, ARG pedig az objektum konstruktora paraméterének típusa. Az első sablonparamétert kötelező kiírni a hívásban, mert az aktuális paraméterekből nem vezethető le; a második sablonparaméter viszont levezethető a hívásból. Az automatikus levezetés és az ARG && forma miatt ez a paraméter paramétertovábbító referencia típusú.

Lássuk az std::forward() implementációját! A függvény fejléce és törzse az alábbi:

template <typename S>
S && forward(typename std::remove_reference<S>::type & arg) {
    return static_cast<S &&>(arg);
}

Az std::remove_reference segédosztály azt csinálja, amit a neve mond: a sablonparamétere típusából mindig levágja a referenciát. Ilyen egyszerűen, két részleges specializációval:

template <typename T> struct remove_reference      { using type = T; };
template <typename T> struct remove_reference<T&>  { using type = T; };
template <typename T> struct remove_reference<T&&> { using type = T; };

Forward: balértékből balérték

Nézzük, hogyan adódik ki ennek a helyes, ARG & vagy ARG && típusú visszatérési értéke a create_new() függvényben! Tegyük fel először, hogy a create_new() függvényt egy balérték paraméterrel hívták meg, mint amilyen itt az s1:

String s1;
X * px = create_new<X>(s1);

A create_new() első paramétere ARG && paramétertovábbító referencia, ezért az ARG = String & sablonparamétert vezeti le a fordító. Az így specializált függvényben a String & && → String & összevonás után balérték típusú lesz a paraméter:

template <>
X * create_new<X, String &>(String & arg) {
    return new X(std::forward<String &>(arg));
}

Ebben adott az std::forward() sablonparamétere, S = String &. Behelyettesítve azt a forward() definíciójába, az alábbi specializáció adódik:

template <>
String & && forward<String &>(typename std::remove_reference<String &>::type & arg) {
    return static_cast<String & &&>(arg);
}

Az egyszerűsítések után:

forward
lvalue → lvalue
template <>
String & forward<String &>(String & arg) {
    return static_cast<String &>(arg);
}

Látszik, hogy ennek a függvénynek balérték a visszatérési értéke. Vagyis ha a create_new() balérték típusú paramétert kapott, akkor az X konstruktor is balérték paramétert fog kapni. A forward ilyenkor mintha ott sem lenne.

Forward: jobbértékből jobbérték

Nézzük most azt az esetet, ha a create_new() jobbérték paramétert kap! Ilyenkor az ARG && formális paraméterezés miatt ARG = String adódik, így a specializált függvény az alábbi formát veszi fel:

template <>
X * create_new<X, String>(String && arg) {
    return new X(std::forward<String>(arg));
}

Ennek a jobbérték referencia típusú paramétere tényleg át tudja venni a jobbérték paramétert. Az std::forward() függvény sablonparamétere ebben az esetben S = String lesz, amit behelyettesítve:

template <typename S>
String && forward(typename std::remove_reference<String>::type & arg) {
    return static_cast<String &&>(arg);
}

Az egyszerűsítés után ennek végleges formája:

forward
rvalue → rvalue
template <typename S>
String && forward(String & arg) {
    return static_cast<String &&>(arg);
}

A példányosítás az std::move()-hoz hasonló függvényt eredményezett, amelyik a balérték típusú paraméterét jobbértékké konvertálja. Ebből az is látszik, ha a create_new() jobbérték típusú paramétert kapott, az X konstruktora is jobbérték paramétert fog látni.

Az std::forward() függvényt használva tehát a balérték paraméterből balérték lesz, a jobbértékből pedig jobbérték: QED.

Most már látszik, hogy a remove_reference miért kell szerepeljen a függvény paraméterében: hogy muszáj legyen megadni a hívásnál a sablonparaméter típusát. Ha a paraméter egyszerűen csak S & típusú lenne, akkor működne rá az automatikus (fordító általi) levezetés is, azonban nem működne helyesen.

Az std::move() függvény működése

A fentiekhez hasonló mágiát tartalmaz az std::move() függvény is. Ennek feladata az volt, hogy a paraméterként átvett balértéket jobbértékké konvertálja. A definíciója az alábbi:

template <typename T>
typename std::remove_reference<T>::type && move(T && arg) {
    return static_cast<typename std::remove_reference<T>::type &&>(arg);
}

Ennek univerzális referencia a paramétere. Lássuk, hogy működik! Ha a paramétere balérték...

String s1;
std::move(s1);

... akkor a sablon T && levezetési szabálya miatt T = String & adódik. A specializáció így ezt a képzeletbeli formát ölti:

template <>
typename std::remove_reference<String &>::type && move<String &>(String & && arg) {
    return static_cast<typename std::remove_reference<String &>::type &&>(arg);
}

Az ismert összevonási szabályok alkalmazása után látszik, hogy a visszatérési értéke jobbérték referencia, így tényleg balértékből jobbértéket csinált:

move
lvalue → rvalue
template <>
String && move<String &>(String & arg) {
    return static_cast<String &&>(arg);
}

Ha pedig esetleg olyan paramétert kapna, amely már eleve jobbérték:

std::move( String("hello") );

... akkor a T && univerzális referencia típusú paramétere miatt T = String levezetésével specializálja automatikusan a fordító:

template <>
typename std::remove_reference<String>::type && move<String>(String && arg) {
    return static_cast<typename std::remove_reference<String>::type &&>(arg);
}

Ebben a szokásos összevonásokat elvégezve az alábbi függvényhez jutunk:

move
rvalue → rvalue
template <>
String && move<String>(String && arg) {
    return static_cast<String &&>(arg);
}

Ez a függvény, jobbérték referencia paramétere lévén, képes átvenni a jobbérték típusú objektumot, és bár belül ez az objektum balértéknek látszik (van neve, arg-nak hívják), a visszatérési értékében megint jobbértékké alakítva látszik.

Végeredményben tehát balértékből jobbérték lett, a jobbérték pedig jobbérték maradt. QED.

Itt azért kellett remove_reference a függvény visszatérési értékében, hogy akármire is oldódott fel a sablonparaméter, a visszatérési érték biztosan jobbérték referencia legyen. Mert így a visszatérési értékben a && biztos olyan típus mellett áll, amelyik nem referencia, tehát nem fog semmilyen összevonási szabály aktiválódni.

A create_new() függvényünk:

template <typename T, typename ARG>
T * create_new(ARG && arg) {
    return new T(std::forward<ARG>(arg));
}

Az std::forward() implementációja:

template <typename S>
S&& forward(typename std::remove_reference<S>::type& arg) {
    return static_cast<S &&>(arg);
}
template <typename T> /* mindháromnál, csak spórolunk */
struct remove_reference      { using type = T; };
struct remove_reference<T&>  { using type = T; };
struct remove_reference<T&&> { using type = T; };

8. Irodalom