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.

1. Hogy működik a sablonparaméterek levezetése?

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 a T 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) és T data-ból T=int következik.
  • Második paraméter alapján: 3(int), és TemplatedClass<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.

2. Mit csinál a kapcsos zárójelpár?

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ől T = 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 vectorfunction 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ó.

3. Hogy szabadulunk meg a zárójelpártól?

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ó.