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 aTsablonparamé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=intkö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 = intkö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ó.