Sablon függvények és paramétereik: type_identity
Czirkos Zoltán · 2022.06.21.
Legtöbb esetben egyszerűen hagyjuk a fordítónak, hogy a függvényhívásból levezesse a sablon függvényeink paramétereit. Néha azonban ez látszólag triviálisnak tűnő helyzetekben nem sikerül neki.
Tegyük fel, hogy szeretnénk egy for_each
függvénysablont írni, amely egy std::vector
minden elemére
meghív egy függvényt. A vektorunk bármilyen adatokat tartalmazhat, a függvény pedig legyen egy std::function
:
template <typename T>
void for_each(std::vector<T> vec, std::function<void(T)> func) {
for (auto item : vec)
func(item);
}
A rövid kódunk azonban a legegyszerűbb esetben sem fordítható le:
int main() {
std::vector<int> v = { 1, 2, 3 };
for_each(v, [] (int i) { std::cout << i << " "; });
}
In function ‘int main()’: error: no matching function for call to ‘for_each(std::vector<int>&, main()::<lambda(int)>)’ note: candidate: template<class T> void for_each(std::vector<T>, std::function<void(T)>) note: template argument deduction/substitution failed: note: ‘main()::<lambda(int)>’ is not derived from ‘std::function<void(T)>’
Első kérdés: miért fordítható le a kód, ha a lambda köré kapcsos zárójelpárt írunk?
int main() {
std::vector<int> v = { 1, 2, 3 };
for_each(v, { [] (int i) { std::cout << i << " "; } }); // { } a λ körül
}
Második kérdés: hogyan érjük el, hogy ne kelljen kitenni ezt a zárójelpárt? Ugyanis ez elég szokatlan és zavaró is; sosem szokott kelleni a függvényparaméterek köré kapcsos zárójelet tenni.
Az első kérdés megválaszolásához előbb körül kell járnunk, tulajdonképp a kapcsos zárójelek mit is csinálnak.
A fenti kódban a lambda körüli zárójelpár egy konstruktorhívást jelöl. Figyeljük meg az alábbi kódban, hogy ezzel egy implicit konstruktorhívást tudunk megjelölni:
class Complex {
public:
Complex(double re = 0, double im = 0);
};
Complex get_complex() {
return {2, 3}; // Complex{2, 3}
}
Mivel a függvény visszatérési értéke egy Complex
típusú objektum, a megadott értékek annak konstruktorparaméterei
lesznek. Észre kell venni, hogy ez nem ugyanaz, mintha return 2, 3;
-at írunk volna; akkor a két szám között egy vessző
operátor lenne, és a kód return Complex(3);
jelentésű volna. Ugyanígy, a kerek zárójel sem jelentené a re = 2,
im = 3
paraméterű konstruktorhívást: return (2, 3)
, mivel azt szintén vessző operátornak értelmezné a
fordító. A kapcsos zárójel viszont aggregátumot jelöl (brace-enclosed initializer list), amelyben a vessző felsorolást ad meg, nem
pedig operátorként viselkedik.
Miért segít ez a lambdás függvényhívásnál? Ennek megértéséhez a sablonparaméterek levezetési szabályaiban (template argument
deduction) is el kell mélyednünk egy kicsit. Induljunk ki ehhez is egy egyszerű példából, a max()
függvénysablonból!
Ez két szám közül a nagyobbikat adja vissza:
template <typename T>
T max(T a, T b) {
return a > b ? a : b;
}
std::cout << max(3, 2); // OK
std::cout << max(3.4, 5.6); // OK
std::cout << max(2, 3.4); // fordítási hiba
std::cout << max<double>(2, 3.4); // OK, double(2), 3.4
A hívások helyein a fordítónak ki kell találnia a sablonparamétert a konkrét függvényparaméterekből. Ehhez mindkét konkrét paramétert figyelembe veszi, utána pedig ellenőrzi, hogy nem jutott-e ellentmondásra.
max(3, 2)
esetén:3(int) → T=int
,2(int) → T=int
, oké.max(3.4, 5.6)
esetén ugyanez, csak mindkettőT=double
-t ad.max(2, 3.4)
esetén viszon:2(int) → T=int
,3.4(double) → T=double
, vagyis aT
sablonparamétert nem lehetett ellentmondásmentesen meghatározni, és ez fordítási hibát vált ki.
A lényeg, hogy a levezetést mindkét paraméterre külön-külön elvégzi, és utána összeveti az eredményt. Ha valamelyik paraméter esetén sikertelen a levezetés, vagy a végeredmény ellentmondásos, a fordítás hibajelzéssel leáll. Mindez persze csak akkor érvényes, ha nincs megadva explicite a sablonparaméter; ha megadjuk, mint a negyedik sorban, akkor nincs probléma.
A fenti példában mindkét paraméter egy sablonparaméter ún. levezetési környezetet ad meg (deduced context). Tudnunk kell azt is, hogy a sablonparaméterek levezetése után még függvénykiválasztás (overload resolution) vagy konverziók is történhetnek. Figyeljük meg az alábbi kódrészletet, mert ez rávilágít arra, miért fontos figyelembe vennünk a sorrendet.
template <typename T>
class TemplatedClass {
public:
TemplatedClass(int i) {}
TemplatedClass(double d) {}
};
template <typename T>
void func(T data, TemplatedClass<T> obj) {}
int main() {
func(2, 3); // fordítási hiba
func(2, 3.4); // fordítási hiba
}
Hiába érezzük úgy, a kódból ki lehetne következtetni, hogy milyen sablonparaméterre és melyik konstruktorra lenne szükség: ebben az esetben mindkét hívás fordítási hibát vált ki. A nyelv szabályai szerint ez nincs így, ugyanis azok így szólnak:
- Előbb el kell végezni a sablonparaméterek levezetését.
- Utána pedig az overloadok kiválasztását, esetleg a konverziókat.
Jelen esetben már a sablonparaméterek levezetésén elbukik a dolog. Vegyük példának az első sort! A kérdés tehát itt most csak
az, hogy T
helyére mit kell írni a func<T>
függvénysablonnál.
- Első paraméter alapján:
2(int)
ésT data
-bólT=int
következik. - Második paraméter alapján:
3(int)
, ésTemplatedClass<T>
nem egyeztethető össze, nincs rá levezetési szabály.
Vagyis a problémát igazából az okozza, hogy a fordító a 3(int)
-ből semmiféle következtetést nem tud levonni
a TemplatedClass<T>
típusára. Ne feledjük, az osztály konstruktorai itt még nem lényegesek; a konstruktor
kiválasztása csak később történne, a T
meghatározása után.
Mi a helyzet akkor, ha explicite megadjuk a sablonparamétereket?
int main() {
func<int>(2, 3); // OK, TemplatedClass<int>(int = 3)
func<int>(2, 3.4); // OK, TemplatedClass<int>(double = 3.4)
}
Ezzel a levezetést kikapcsoltuk; a fordító a T = int
behelyettesítés után ezt a teljes specializációt állítja
elő:
template <>
void func<int>(int data, TemplatedClass<int> obj) {}
Ennek hívásához a főprogramban konverziókra is szükség van; mivel az osztálynak az int
és a double
paraméterű konstruktora sem explicit, azokkal a konverzió elvégezhető. A 3(int)
és a 3.4(double)
értékek típusa alapján eldől az is, melyik esetben melyik konstruktort kell hívni.
Akkor vajon mi a helyzet, ha a második paramétert kapcsos zárójelek közé tesszük?
template <typename T>
class TemplatedClass {
public:
TemplatedClass(int i) {}
TemplatedClass(double d) {}
};
template <typename T>
void func(T data, TemplatedClass<T> obj) {}
int main() {
func(2, {3}); // OK, TemplatedClass<int>(int = 3)
func(2, {3.4}); // OK, TemplatedClass<int>(double = 3.4)
}
Ebben az esetben a kódunk működni kezd, de hogy lehet ez? Úgy, hogy ezek a kapcsos zárójelek konstruktorhívást jelentenek,
vagyis objektumokat inicializáló értékeket. Vagyis a 3
és a 3.4
most nem a func()
függvény
paraméterei, hanem a func()
függvény paraméterének konstruktorparaméterei.
Figyeljük meg, hogy jelenleg a func
sablonparaméterei nincsenek megadva, tehát a fordítónak kell majd kitalálnia,
hogy func<T>
-ben a T
típus micsoda. Viszont mivel most a 3
és a 3.4
már
nem számítanak a func
paramétereinek, ezért azokat nem fogja figyelembe venni a levezetés közben (non-deduced
context). Ilyenkor tehát a T
levezetése az alábbiak szerint zajlik le:
- Első paraméter:
2(int)
-bőlT = int
következik. - Második paraméter: nincs figyelembevéve.
Ezek alapján pedig T = int
az eredmény. Példányosodik a sablon, előáll a specializáció; a
TemplatedClass
konstruktorai pedig át tudják venni a 3
és a 3.4
értékeket.
Ugyanezért segít a kapcsos zárójelpár a vector
–function
példában:
template <typename T>
void for_each(std::vector<T> vec, std::function<void(T)> func);
int main() {
std::vector<int> v = { 1, 2, 3 };
for_each(v, { [] (int i) { std::cout << i << " "; } }); // { } a λ körül
}
Ilyenkor nem úgy veszi, hogy a lambda a for_each
paramétere (ami gond lenne, mert a lambda típusából nem lehet
következtetni a function
sablonparaméterére). Hanem úgy, hogy a lambda, mint érték, a func
nevű paraméter
konstruktorparamétere lesz. Vagyis a func
paraméter típusát nem próbálja levezetni belőle, hanem a T
-t
csakis a vektor típusa alapján határozza meg. Abból T = int
vezethető le, utána pedig egy konverzió történik: az
std::function<void(int)>
konstruktora veszi át a lambdát. (Egyébként az utóbbi konstruktor szintén sablon, tehát
sablon osztálybeli sablon konstruktorról van szó.)
Kicsit zavarosnak tűnhet a fenti okfejtés. Nézzük meg egy életszerűbb példán ugyanezt:
void print(std::vector<int> const & v) {
for (auto i : v)
std::cout << i << ' ';
}
int main() {
print(3); // compile error
print({3}); // print(std::vector<int>{3})
}
Az első esetben a print
függvény paramétere a 3
. Ez nyilvánvalóan értelmetlen, és fordítási hibát fog
kiváltani, hiszen a függvény nem egy egész számot, hanem egy vektort vár. A második esetben viszont a kapcsos zárójel azt mondja, a
benne lévő 3
nem a print
paramétere, hanem a vector<int>
konstruktorparamétere: tehát
a print
paraméterként vector<int>{3}
-at kap. Nyelvileg ez a function
és a lambda
esetén is ugyanígy jelenik meg, csak míg itt természetesnek érezzük a kapcsos zárójelet (amelyik amúgy inicializáló listát ad
meg), a függvényes-lambdás esetben inkább zavaró.
Az eddigiek alapján adja magát, hogy a zárójelpártól hogyan lehet megszabadulni. Azt kell elérnünk, hogy a T
sablonparaméter levezetésében a második paraméter, a függvény ne vegyen részt. Mert ha a fejléc ez:
template <typename T>
void for_each(std::vector<T> vec, std::function<void(T)> func);
Akkor a T
-t mindkét paraméter alapján megpróbálja majd levezetni a fordító, és még egyezniük is kell. A másodikat,
a függvényt ki kellene ebből hagyni; az non-deduced context kellene legyen; a T
helyére abban csak a vektor
típusából kikövetkeztetett típust kellene beírnia a fordítónak.
Erre több lehetőségünk is van, a szabvány több olyan helyzetet is felsorol, ahol nincs automatikus levezetés. Az egyik az,
amikor a paraméter típusában decltype
szerepel. Vagyis ha a function
sablonparaméterei közé egy
decltype
-ot csempészünk, pont ez fog történni. Kérdés, hogyan írunk olyan decltype
-t, amely pontosan egy
általunk ismert típust fog visszaadni. Például úgy, hogy deklarálunk egy függvényt, amelyik pont ezzel a típussal tér vissza:
template <typename T>
T for_each_helper();
template <typename T>
void for_each(std::vector<T> vec,
std::function<void( decltype(for_each_helper<T>()) )> func)
{
for (auto item : vec)
func(item);
}
Vigyázat: ez nem ugyanaz, mint az std::declval<T>()
, mert az T&&
-t
adna.
Másik lehetőségünk, hogy egy osztály belső típusát használjuk. Ha a paraméterben valami<T>::akármi
szerepel
(nested name-specifier), akkor a paraméterlevezetés kikapcsol. Ezért bevezetünk egy segédtípust, amely pont így viselkedik,
és amúgy T
-t ad:
template <typename T>
struct type_identity {
using type = T;
};
template <typename T>
void for_each(std::vector<T> vec,
std::function<void( typename type_identity<T>::type )> func)
{
for (auto item : vec)
func(item);
}
Ez a segédosztály std::type_identity
néven lesz elérhető C++20-tól. Pont erre találták ki,
hogy le lehessen vele tiltani a sablonparaméterek levezetését, igazából semmi másra nem jó.