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:
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:
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');
}
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
}
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 */
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";
}
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)
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ípus | Milyen referenciája | Eredmé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ípus | Példa | Levezetett sablonparaméter |
---|---|---|
Balérték | String 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&&
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:
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:
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:
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:
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; };
- Herb Sutter: Why Not Specialize Function Templates? – miért nincs részleges specializáció függvényekhez?
- Template argument deduction – sablonparaméterek levezetési szabályai.
- C++ auto and decltype Explained – a levezetési szabályokról, illetve a jobbértékekről és argumentumtovábbításról minden.