Sablon metaprogramozás

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

Sablonok használata. Viselkedés- és típusinformációs osztályok. A SFINAE szabály. A sablon metaprogramozás elemei.

A sablonokat (template) a C++ nyelvben eredetileg a tárolókhoz és az algoritmusokhoz találták ki. A cél kezdetben az volt, hogy az egyforma tömb, lista, rendezés stb. programrészeket ne kelljen többször megírni, mégis típushelyes, és a fordító által ellenőrizhető legyen a kód. Később azonban kiderült, hogy ezek a nyelvi eszközök sok egyéb dologra is alkalmasak, és a sablonokra akár egy önálló programozási nyelvként is lehet tekinteni.

1. Sablonok tárolóknál és algoritmusoknál

A programozási nyelveket gyakran csoportosítják aszerint, hogy erősen típusosak vagy gyengén típusosak (strongly typed, weakly typed). A két fogalomnak többféle, némileg eltérő definíciója is létezik. Nem is lehet minden programozási nyelvet egyértelműen a két kategória valamelyikébe sorolni. Mint azt már láttuk, a C++-ra azt szokták mondani, hogy erősen típusos nyelv, és azt is, hogy erősebben típusos, mint a C, mert nincsen benne automatikus void*T* konverzió. Nevezzük akárhogy is, annyi bizonyos, hogy ezek miatt a C++ típusrendszere szigorúbb, mint a C nyelvé.

A C++-ban minden változó típusát létrehozáskor a programkódban meg kell adnunk, és így minden változó és érték típusa már fordítási időben ismert. Ennek két előnye van:

  • A szigorú típusrendszer sok programozási hibát kiküszöböl. Már fordítási időben, tesztelés nélkül kiderül, ha egy objektumnak nem létező metódusát vagy operátorát próbáljuk használni. Ezzel szemben legtöbb parancsértelmezett (interpreted) nyelvben még az is csak tesztelés közben derül ki, ha egy változó vagy metódus nevét elgépeltük.
  • Lehetővé teszi a függvénynév túlterhelést. Mivel minden értéknek fordítási időben ismert a típusa, a túlterhelt függvénynevek közül a fordító már fordítási időben, nulla futási idejű költséggel tud választani.

JavaScript vs. C++

Nézzük meg ezt egy egyszerű C++ és JavaScript kódrészleten! Mivel a JavaScript gyengén típusos, ha függvénynév-túlterhelést szeretnénk imitálni, akkor azt magunknak kell leprogramoznunk; a nyelvi eszköz hiánya miatt a fordító nem végzi el helyettünk ezt a feladatot. Ennek sajnos futási idejű költsége is van: minden egyes függvényhíváskor le kell futnia a típust kiválasztó programrésznek, szemben azzal, hogy C++-ban ez már fordítási időben megtörténik.

C++
#include <iostream>

void my_print(int i) {
    std::clog << "int " << i << "\n";
}

void my_print(char const *s) {
    std::clog << "str " << s << "\n";
}


int main() {
    my_print(5);         /* int 5 */
    my_print("hello");   /* str hello */
    my_print(std::clog); /* nem fordul */
}
JS
function my_print(x) {
    if (typeof(x) == "number") {
        console.log("number " + x);
    }
    else if (typeof(x) == "string") {
        console.log("string " + x);
    }
    else {
        console.log("ismeretlen típus");
    }
}


my_print(5);       /* number 5 */
my_print("hello"); /* string hello */
my_print(console); /* ismeretlen típus */

Sok helyzetben hátrány ez a szigorúság, és azt szeretnénk, ha bizonyos kódrészletekben bizonyos típusnevek helyére bármit írhatnánk. Ilyen egy tároló, ahol igazából mindegy, hogy mi a tárolt típus: a lényeg csak annyi, hogy másolható, vagy legalább mozgatható legyen. És ilyen egy rendezés is: a rendező algoritmust nem érdekli, hogy mik a rendezendő objektumok, számára elég, ha a < operátor értelmezett rajtuk.

Ezt az elvet a programozásban duck typing-ként szokták emlegetni: „a madár, amelyik úgy úszik, mint egy kacsa, és úgy hápog, mint egy kacsa... az egy kacsa”. Duck typing esetén az objektumoktól nem azt várjuk el, hogy adott típusúak legyenek (adott ősből származzanak le), hanem csak annyit, hogy egy bizonyos környezetben valamilyen elvárt módon viselkedjenek. Mint például az iterátorok: aminek van == operátora, ++ operátora és * operátora, az pointernek látszik, még akkor is, ha igazából nem az.

A kétféle típusszemléletet a C++ nyelvben a sablonok kötik össze. Szükség volt egy olyan nyelvi eszközre, amellyel azt tudjuk mondani a fordítónak, hogy:

  • a megadott helyen fogadjon el bármilyen típusú objektumot (bármilyen osztálybeli, vagy akár beépített),
  • ugyanakkor mégis ellenőrizze fordítási időben a műveletek (metódushívások) helyességét.

A sablonokat eredendően kifejezetten a tároló osztályok számára találták ki, mert azoknak hasonlít egymásra a legjobban a programkódja. Annyira, hogy double elemek tömbjéből vagy láncolt listájából egy egyszerű „keresés és csere” művelettel Complex objektumok tömbjét vagy láncolt listáját tudjuk készíteni. Ez a csere a sablonokon keresztül fordítási időben, a fordító által ellenőrzötten történhet, így nem lesz olyan törékeny a kód, mintha a C-hez hasonlóan void* pointerekkel és kézi konverziókkal ügyeskednénk.

Így jöttek létre a függvénysablonok és az osztálysablonok. Idővel azonban kiderült, hogy a sablonok sokkal több mindenre jók ennél.

C++ erős típusosság előnyei:

  • Fordítási időben kiderül, ha egy objektumnak nem létező metódusát vagy operátorát próbáljuk használni. (Legtöbb parancsértelmezett nyelvben csak tesztelés közben.)
  • Függvénynév túlterhelés: a fordító már fordítási időben, nulla futási idejű költséggel tud választani.
C++
void my_print(int i) {
  std::clog << "int\n";
}
void my_print(char const*s){
  std::clog << "str\n";
}
int main() {
  my_print(5);   /* int */
  my_print("hello");/*str*/
  my_print(std::clog); 
      /* nem fordul */
}
JS
function my_print(x) {
 if(typeof(x)=="number"){
   console.log("number ");
 }
 else
 if(typeof(x)=="string"){
   console.log("string ");
 }
 else {
  console.log("ismeretlen");
 }
}

2. Viselkedések megadása sablonon keresztül: okos(abb) pointerek

Az okos pointerek nem csak arra képesek, hogy automatikusan felszabadítsák a nem használt objektumokat. A konstruktoraikat, destruktoraikat, dereferáló operátoraikat mind saját magunk írhatjuk meg, és így tetszőlegesen variálhatjuk a működésüket.

Az egyik pont, ahol gyakran módosítani szeretnénk egy okos pointer osztályt, az a null pointerek kérdésköre. Mi a teendő, ha valaki egy null értékű okos pointert dereferált? Hagyjuk az ellenőrzést figyelmen kívül, hogy gyorsabb legyen a program? Dobjunk kivételt? Egyáltalán lehet az okos pointer null értékű, vagy már létrehozni sem szabadna olyat?

Az egymástól alig különböző okos pointer osztályok írása helyett érdemes egyetlen egy olyan osztályt írni, amely konfigurálható. Ebben a megvalósításban a szokásos, mutatott objektum típusa sablonparaméter mellett az okos pointer osztály kap még egy sablonparamétert. Ez is egy osztály, amely a kiegészítő működéseket leírja:

Okos pointer implementáció
nullpointer stratégiákkal
template <typename T, typename NullPtrPolicy> // !
class SmartPtr {
  public:
    SmartPtr(T *ptr) : ptr_{ptr} {
        NullPtrPolicy::check_on_create(ptr); // !
    }
    T& operator*() const {
        NullPtrPolicy::check_on_dereference(ptr); // !
        return *ptr_;
    }
    T* operator->() const {
        NullPtrPolicy::check_on_dereference(ptr); // !
        return ptr_;
    }
  private:
    T *ptr_;
};

Ebben a kiegészítő osztályban olyan statikus függvények vannak, amelyeket a megfelelő pillanatban kell meghívni, és elvégzik az ellenőrzést. Például így:

Stratégia
implementációja:
nincs ellenőrzés
template <typename T>
struct NullPtrPolicyDontCare {
    static void check_on_create(T *ptr) {}
    static void check_on_dereference(T *ptr) {}
};

Vagy így:

Stratégia
implementációja:
Java szabályok
template <typename T>
struct NullPtrPolicyExceptionOnDereference {
    static void check_on_create(T *ptr) {}
    static void check_on_dereference(T *ptr) {
        if (ptr == nullptr)
            throw NullPointerException();
    }
};

Ezeknek az osztályoknak a neve policy class. A működésük hasonló az OOP tervezésben alkalmazott stratégia osztályokhoz (strategy design pattern). Itt az okos pointernél sablonparaméterrel kell megadni az alkalmazandó stratégiát:

Alkalmazás
try {
    SmartPtr<int, NullPtrPolicyExceptionOnDereference<int>> p1{new int};
    *p1 = 2;    // OK

    SmartPtr<int, NullPtrPolicyExceptionOnDereference<int>> p2{nullptr};
    *p2 = 4;    // throw
} catch (std::exception &e) {
    /* ... */
}

A stratégia osztály ebben a példában maga is sablon, hogy az ellenőrizendő pointert típushelyesen kaphassa meg. Így a példányosításnál kétszer kell megadni ugyanazt a típust: egyszer az okos pointernek, egyszer pedig a stratégia osztálynak:

SmartPtr<TYPE, NullPtrPolicyDontCare<TYPE>> p;

C++-ban a sablonparaméterként megadott típus nem feltétlenül kell konkrét osztály legyen, hanem lehet maga is egy sablon. Ezt úgy nevezik, hogy sablon sablonparaméter (template template parameter). Ezzel az apró kényelmetlenség kiküszöbölhető:

template <typename T>
struct NullPtrDontCare {
    static void check_on_dereference(T *ptr) {}
};

template <typename T, template <typename U> class NullPtrPolicy = NullPtrDontCare>
class SmartPtr {
    public:
    T& operator*() const {
        NullPtrPolicy<T>::check_on_dereference(nullptr);
    }
};

int main() {
    SmartPtr<int, NullPtrDontCare> p;
}

A szintaxis elsőre furcsa, de amúgy logikus. A SmartPtr sablonparamétereinek megadásánál a NullPtrPolicy sablonosztály megadása pont olyan, mint amit az osztályok definíciójánál is megszoktunk: template <typename U> class NullPtrPolicy. Ez kapja alapértelmezett értékként a NullPtrDontCare sablon osztályt. Az operator*() függvényben, a sablon osztály használatánál fel is kell a sablont paraméterezni: az okos pointer itt T-vel, a saját paraméterével példányosítja a stratégiát. Az okos pointer példányosításánál pedig már nem NullPtrDontCare<int>-et, hanem csak NullPtrDontCare-t kell írni, mert a második paraméter nem a konkrét típus (példányosítás után), hanem a sablon kell legyen (példányosítás előtt).

A stratégiák használata

  • Meg kell keresni a (fogadó) osztály olyan működéseit, konfigurálási pontjait, amelyek egymástól függetlenek.
  • Meg kell határozni egy interfészt a sablonparaméterként használt osztályok számára.
  • A stratégiák sablonparaméterként, és általában statikus függvények hívásai által emelhetőek be a fogadó osztályba.

Az okos pointerek működését a tagfüggvények módosításával befolyásolhatjuk.

Gyakori módosítás a nullpointerek kezelése. Dereferáláskor mi legyen?

  • Ne ellenőrizzük, hogy gyorsabb legyen a program?
  • Dobjunk kivételt?
  • Lehet null értékű, vagy már létrehozni sem szabadna olyat?

3. Típusinformációs osztályok használata: iterátorok típusai

A sablonokból, mint nyelvi eszközből, nőtt ki az STL is (Standard Template Library), amelyhez legalább annyi dokumentáció tartozik, mint amennyi magához a nyelvhez. Nézzünk meg egy STL-közeli példát, ahol sablonok és típusok segítségével fordítási időben választunk algoritmust!

Tudjuk, hogy eltérő típusú tárolókban más-más lehetőségeink vannak a tárolt elemek elérésére. Míg egy tömbben ide-oda ugrálhatunk, egy duplán láncolt listában mindig csak az aktuális előtti és mögötti elemet látjuk közvetlenül. A szimplán láncolt listánál pedig már visszafelé sem tudunk haladni, csak előre. Ennek megfelelően a tárolók iterátorai is különféle kategóriákba tartoznak, mert vagy rendelkeznek egy bizonyos a mozgásiránynak megfelelő operátorral, vagy nem.

Az iterátorok lehetséges kategóriái közötti leszármazási hierarchiát a jobb oldalon látható ábra mutatja. Lássuk sorban! Az iterator segítségével bejárható egy tároló: minden iterátornak van operator++ művelete. Ebből az ősből származik le a bemeneti és a kimeneti iterátor (input_iterator, output_iterator). Az előbbi által mutatott helyről lehet olvasni, az utóbbi által mutatott helyre lehet írni adatot, maximum egyszer. (Az ilyenek nem feltétlenül a szokásos értelemben vett tárolók. Egy kimeneti iterátor egy nyitott fájlt is jelképezhet, így nem véletlen, hogy ennek egy képzeletbeli helye csak egyszer írható. Ha többször írnánk, többször kerülne adat a fájlba.)

A forward_iterator az írást és az olvasást egyesíti: az előre haladó iterátor által mutatott hely a tárolóban írható, olvasható, akár többször is. A kétirányú iterátor (bidirectional_iterator) annyival tud többet ennél, hogy hátrafelé is lehet benne lépkedni: operator--. Ilyen lehet egy duplán láncolt lista iterátora. A legokosabbak pedig a random_access_iterator kategóriába tartoznak. A közvetlen elérést biztosító iterátorok neve arra utal, hogy a tárolóban tetszőlegesen nagyokat ugorhatunk vele előre és hátra O(1) időben (iterator + int operátor): ilyen egy tömb iterátora. A hangsúly az O(1) időn van. Láncolt listában is ugorhatunk előre egynél többet, de egyesével kell lépkednünk, és az O(n) időbe telik.

Az STL az iterátor kategóriákat segédosztályokkal reprezentálja. A bemeneti iterátorokat pl. az std::input_iterator_tag osztály, a véletlen elérésűeket az std::random_access_iterator_tag osztály jelképezi. Ezek az osztályok nem tartalmaznak semmit, csak az ábra szerinti leszármazási viszonyban vannak, hogy függvénynév túlterhelésben lehessen használni őket.

A sablonok az std::iterator_traits<> osztállyal jönnek képbe. Ezt egy iterátor típusával példányosítva egy olyan osztályt kapunk, amely tartalmaz egy iterator_category nevű belső típust; az pedig megmutatja azt, hogy az iterátor melyik kategóriába tartozik. Pl. az std::iterator_traits<std::vector<int>::iterator>::iterator_category típusnév az std::random_access_iterator_tag típussal egyezik meg.

Az STL-ből vett kódrészlet:

// Marking input iterators.
struct input_iterator_tag { };

// Marking output iterators.
struct output_iterator_tag { };

// Forward iterators support a superset of input iterator operations.
struct forward_iterator_tag : public input_iterator_tag { };

// Bidirectional iterators support a superset of forward iterator
// operations.
struct bidirectional_iterator_tag : public forward_iterator_tag { };

// Random-access iterators support a superset of bidirectional
// iterator operations.
struct random_access_iterator_tag : public bidirectional_iterator_tag { };

Nézzünk egy konkrét példát, hogy mire jó ez! Tegyük fel, hogy van egy tárolónk, benne növekvő sorrendbe rendezett, egész számokkal. Kapunk egy számot, és két iterátort a tárolóhoz; el kell döntenünk, hogy a kapott szám szerepel-e a tartományban. A rendezett számsorról egyből eszünkbe juthat a bináris keresés, ezt azonban nem biztos, hogy tudjuk alkalmazni. A bináris keresés csak akkor tud hatékonyan működni, ha véletlen elérésű iterátoraink vannak (pl. a tároló egy tömb). Ha nem lehet ide-oda ugrálni, csak előrefelé haladni, akkor hiába rendezett a tároló, rákényszerülünk a lineáris keresésre.

Jó lenne, ha az alkalmazható keresőalgoritmust a fordító választaná ki, nem nekünk kellene fejben tartani, milyen tárolóról van szó. Valami ilyesmit szeretnénk:

template <typename ITER, typename VALUE>
bool my_search(ITER begin, ITER end, VALUE what) {
    if (/* ... ITER == random_access_iterator ... */) {
        /* bináris keresés... */
    } else {
        /* lineáris keresés... */
    }
}

Azonban ez így nem fog működni. Egyrészt mert az if feltételével típusokat nem vizsgálhatunk, csak értékeket. Másrészt mert a /* bináris keresés */ megjegyzéssel jelölt helyen lévő kódot a fordító akkor is megpróbálná lefordítani, amikor épp nem véletlen elérésű iterátorról van szó, és ez fordítási hibához vezetne a hiányzó operátorok miatt. Ehelyett az alábbi segédfüggvényt írhatjuk:

template <typename ITER, typename VALUE>
bool my_search(ITER begin, ITER end, VALUE const &what) {
    using iter_categ = typename std::iterator_traits<ITER>::iterator_category; // 1

    return my_search_helper(begin, end, what, iter_categ{}); // 2
}

Ez az std::iterator_traits osztályt példányosítja a kapott iterátor típusával, és kiveszi abból az iterátor kategóriáját jelképező típust (1. sor). Az így kapott típus lehet pl. az std::random_access_iterator_tag, vagy az std::forward_iterator_tag. Ebből a típusból létrehoz egy objektumot (2. sor), és meghív egy másik függvényt, amely paraméterként megkapja ezt, az iterátor kategóriát jelképező objektumot is.

A hívott my_search_helper() függvénynek így lesz egy negyedik paramétere is, amely értékének azonban semmi jelentősége nincs, csak a típusának (ezért még nevet sem kell adni neki). Ez a fontos lépés! Az utolsó, iter_categ() paraméter miatt tudunk függvénynév túlterhelést használni. Vagyis nem is egy, hanem két függvényt írunk:

/* Lineáris keresés */
template <typename ITER, typename VALUE>
bool my_search_helper(ITER begin, ITER end, VALUE const &what,
                      std::forward_iterator_tag)            // !
{
    std::cout << "Linearis kereses" << std::endl;
    for (ITER it = begin; it != end; ++it)
        if (*it == what)
            return true;
    return false;
}


/* Bináris keresés */
template <typename ITER, typename VALUE>
bool my_search_helper(ITER begin, ITER end, VALUE const &what,
                      std::random_access_iterator_tag)      // !
{
    std::cout << "Binaris kereses" << std::endl;
    while (begin != end) {
        ITER mid = begin + (end - begin) / 2;
        if (*mid == what)
            return true;
        if (*mid < what)
            begin = mid + 1;
        else
            end = mid;
    }
    return false;
}

A két függvény közül így a fordító automatikusan, fordítási időben fogja kiválaszatni a tároló típusának megfelelő, gyorsabb változatot. Az eredeti my_search() függvényt pedig valószínűleg nyomtalanul ki fogja optimalizálni.

A my_search()-höz hasonló szerepű függvényeket irányító (dispatcher) függvénynek nevezzük. Az egész módszer pedig az ún. „tag dispatch” technika.

Saját tároló iterátora?

Mi a teendő akkor, ha a saját tároló osztályunk iterátorát szeretnénk beilleszteni ebbe a rendszerbe? Például egy ilyet:

class MyContainer {
  private:
    /* ... */

  public:
    class Iterator {
      private:
        /* ... */

      public:
        Iterator();
        T& operator*() const;
        T* operator->() const;
        Iterator& operator++();
        bool operator==(Iterator const &rhs) const;
        /* ... */
    };
    /* ... */
};

A my_search() függvény jelen változatában elvárja, hogy a példányosító iterátorról az std::iterator_traits osztály adjon információt. Ezért az std::iterator_traits osztályt specializálnunk kell a saját iterátorunkra (pl. MyContainer::Iterator), és bele kell írnunk, hogy melyik kategóriába tartozik az. Egyéb dolgok mellett, definiálnunk kell az iterator_category típust:

template <>
struct std::iterator_traits<MyContainer::Iterator> {
    using iterator_category = std::random_access_iterator_tag;
    /* ... */
};

Így már működni fog. Egyébként ez az egyetlen eset, amikor valamit az std névtérbe szabad tennünk: amikor egy ott lévő, „gyári” osztálysablont specializálunk a saját típusunkra. Egyébként az std névtér tabu, csak a szabványos típusok és függvények lehetnek benne.

A másik út volt, amit követhettünk, hogy az iterátorunkat az std::iterator osztályból származtattuk. Bár C++17 óta ez az osztály már nem létezik, nincs rá szükség. Helyette a szükséges típusokat a saját osztályunkba tesszük.

Viselkedéseket leíró osztályok használata

A „trait” szó jellemzőt, jellegzetes vonást jelent. „Traits class”-nak olyan sablonosztályokat nevezünk, amelyek a paramétereikről adnak információt.

  • Létre kell hozni azokat a jelölő osztályokat (osztályhierarchiát), amelyek leírják a típusok tulajdonságait.
  • A megírt függvény paraméterezését ezekkel az osztályokkal lehet túlterhelni. Általában kell egy segédfüggvény is, amely valamely jelölő osztályt példányosítja, és paraméterként adja a túlterhelt nevű függvénynek.
  • Keresni kell egy olyan módszert, ahogyan a függvényt példányosító típus tulajdonságai lekérdezhetőek. Ez legtöbbször egy traits class.

4. Osztály vagy nem osztály? A SFINAE szabály

Az irányító (dispatcher) függvények segítségével olyan függvényt is tudunk írni, amelyik fordítási időben megmondja egy típusról, hogy beépített típus vagy osztály. Miért is jó ez? Például azért, mert így el tudjuk kerülni a többszörös inicializálást. Vizsgáljuk meg a MyVector (dinamikus tömb) osztályunknak azt a konstruktorát, amelynek paramétere a létrehozandó tömb mérete. Ez valahogy így nézhet ki:

template <typename T>
MyVector<T>::MyVector(size_t size) {
    size_ = size;
    pData_ = new T[size];
    for (size_t i = 0; i != size; ++i)  // !
        pData_[i] = T();
}

A jelölt ciklus a lefoglalt memóriaterületet inicializálja, az alapértelmezett konstruktor által létrehozott elemekkel. Ez beépített típusok esetén rendben is van (azoknál a külön kiírt alapértelmezett konstruktor a nullás értéket jelöli), és szükség is van rá, különben memóriaszemét maradna a tömbben. Osztályoknál viszont csak lassítja a programot, és egyáltalán nincsen rá szükség, mert a new[] kifejezés garantálja a konstruktorok futtatását is. Olyan programkódot kellene tehát írni, amely beépített típusoknál elvégzi a nullázást, osztályoknál pedig teljesen kihagyja a ciklust.

Ehhez először is szükségünk lenne egy metaadatra a T típussal kapcsolatban: nevezetesen arra, hogy T egy osztály-e vagy egy beépített típus. Ezt a következő trükkel tudhatjuk meg.

#include <iostream>


template <typename T>
void print_if_class_helper(int T::* ptr) { // adattag mutató
    std::cout << "Ez valamilyen osztály." << std::endl;
}


template <typename T>
void print_if_class_helper(...) { // változó argumentumszám
    std::cout << "Ez beépített típus." << std::endl;
}


template <typename T>
void print_if_class(T const &t) {
    print_if_class_helper<T>(0); // !
}


int main() {
    print_if_class(std::cout);
    print_if_class(1.2);
}

A működés megértéséhez vizsgáljuk meg először a két print_if_class_helper() függvényt! Az első változat ptr nevű paraméterének típusa int T::*, amely egy adattag mutató. Ez egy olyan pointer, amely valamilyen osztálybeli objektumok int típusú adattagjaira tudna mutatni. A második változatnál ugyanezen a helyen csak egy ... van, amely a régi, C-ből örökölt nyelvi elem; ismeretlen típusú, változó számú paramétereket jelent (variable argument list). Ilyen fejléce van a jól ismert printf() függvénynek is: printf(char const *fmt, ...), így tud az átvenni akárhány paramétert.

A lényeg a print_if_class() irányító függvényben, a felkiáltójellel jelölt helyen van elrejtve. Ott fog a fordító választani a kétfajta print_if_class_helper() függvény közül. A trükk megértéséhez nagyon pontosan kell ismerni, hogy a C++-ban hogyan működik a meghívott függvény kiválasztása (overload resolution). Az azonos nevű, de eltérő paraméterlistájú függvények közül több is lehet, és ezekről a fordító listát is készít (candidate set for overload resolution), mielőtt a konkrét függvényt kiválasztja. A végrehajtott lépéssorozat az alábbi:

  1. Először meg kell vizsgálnia a megadott nevű konkrét, azaz nem sablon függvényeket. Ha valamelyik ráillik a hívásra név és paraméterek típusa alapján, akkor azt kell meghívni.
  2. Ha több is ráillik, akkor a jobban illeszkedőt. A jobban illeszkedés konverzió nélküli hívást jelent, illetve a leszármazási hierarchiában közelebbi típusokat.
  3. Ha nem volt ilyen függvény, akkor meg kell vizsgálni a függvénysablonokat, hátha valamelyikből előállítható a kért fejlécű függvény. Ebben a vizsgálatban sem vesz részt a függvénysablonok törzse, hanem csakis a fejlécüket fogja nézni a fordító, abba próbálja meg behelyettesíteni a konkrét sablonparamétereket. Az összes lehetséges függvénysablont vizsgálni kell, kihagyva azokat, amelyeknél a sablonparaméterek behelyettesítése szintaktikai hibához vezet. Ha valamelyikre pontosan ráillik, akkor az lesz a hívott függvény.
  4. Ha több is, akkor a jobban illeszkedőt kell választani.
  5. Ha ez sem sikerült, akkor nem létezik a hívásnak megfelelő függvény: fordítási hiba.

A kódban, a print_if_class() jelölt helyén az első lépés kimarad, mert a <T> minősítés miatt függvénysablonról lehet csak szó, nem pedig függvényről. Lássuk, mi történik az std::cout, és mi az 1.2 paraméter esetén!

1. eset: print_if_class(std::cout);

Az std::cout paraméter típusa std::ostream, tehát T = std::ostream adódik a main()-ben a sablonparaméterek levezetésekor. Létrejön a print_if_class<std::ostream> függvény, és le kell fordítani a törzsét. Ebben szerepel egy print_if_class_helper<std::ostream> hívás, amihez a fordítónak meg kell keresnie, melyik print_if_class_helper()-ről van szó. Kettő is lehet:

  • A print_if_class_helper(int T::* ptr) fejlécbe T = std::ostream-et helyettesítve print_if_class_helper(int std::ostream::* ptr) adódik. Ez lehet, hogy jó lesz, mert a 0 paraméter null pointernek tekinthető.
  • A másik függvényből print_if_class_helper(...) lesz. A 0 paraméter itt a változó hosszúságú argumentumlistán int-ként átadható.

Tehát T = std::ostream esetén mindkét függvény hívható. Ilyenkor aszerint választ a fordító, hogy melyik a jobban illeszkedő. Jelen esetben ez az első, adattag mutatós változat kiválasztását jelenti, mivel az adattag mutató speciálisabb típus, mint az ismeretlen típus. Mondhatjuk úgy is, hogy a „fogjuk rá a 0-ra, hogy az null pointer akar lenni” több információ, mint az, hogy „semmit nem tudunk róla, mi akar lenni az a 0 ott”. Ezért a felső függvény hívódik meg, amely kiírja, hogy az std::cout osztályból lett példányosítva.

2. eset: print_if_class(1.2);

A másik hívásnál az 1.2 érték típusa double, tehát ott T = double adódik. Létrejön a print_if_class<double> függvény, benne egy print_if_class_helper<double> hívással. Két print_if_class_helper() sablon van, meg kell vizsgálni mindkettőt:

  • A print_if_class_helper(int T::* ptr) sablonba T = double-t helyettesítve print_if_class_helper(int double::* ptr) adódik. Ez szintaktikai hiba, mert a double nem osztály, nem lehet adattag mutatója. Ezért ezt a függvénysablont el kell dobni, mindenféle hibaüzenet nélkül.
  • A másik függvény fejléce print_if_class_helper(...) lesz.

Így végül egyetlen egy hívható függvény maradt, és az meg is fog hívódni; a print_if_class(1.2) a „beépített típus” szöveget írja ki.

A bemutatott programrész amiatt tud működni, mert a sablonfüggvények fejlécébe történő behelyettesítéskor adódó hibákat a fordító figyelmen kívül hagyja (SFINAE: substitution failure is not an error; a betűszót tipikusan „szfiné”-nek ejtjük). Ilyenkor a nyelv ezt előíró szabálya alapján hibaüzenetet sem ad, hanem egyszerűen eldobja az adott deklarációt.

A sablonfüggvények fejlécébe történő behelyettesítéskor adódó hibákat a fordító figyelmen kívül hagyja (SFINAE: substitution failure is not an error; „szfiné”). Hibaüzenetet sem ad, hanem egyszerűen eldobja az adott deklarációt.

=> Irányító (dispatcher) függvények segítségével olyan függvényt is tudunk írni, amelyik fordítási időben megmondja egy típusról, hogy beépített típus vagy osztály.

5. A SFINAE alkalmazásai

A SFINAE szabály hasznos, mert ezt kihasználva vezérelni tudjuk a sablonokból példányosodó függvények létrejöttét. Ha le szeretnénk tiltani egy példányosodást; csak annyi a teendőnk, hogy szándékosan szintaktikai hibát teszünk a függvény fejlécébe. Persze olyat, ami csak bizonyos feltételek esetén jön elő.

Írjunk például egy olyan sablont, amely csak karakterrel példányosítható! Ehhez előbb szükségünk van egy segédosztályra (ez lényegében egy trait class), amely megmondja egy típusról, hogy az karakter-e. Ez explicit specializációval egyszerűen megvalósítható:

template <typename T>
struct IsCharacter {
    static constexpr bool value = false;
};


template <>
struct IsCharacter<char> {
    static constexpr bool value = true;
};


template <>
struct IsCharacter<unsigned char> {
    static constexpr bool value = true;
};


template <>
struct IsCharacter<signed char> {
    static constexpr bool value = true;
};

Ebben az osztálysablonban a statikus változó értéke mindig hamis, kivétel a karakter típusoknál, mert azoknál igaz értékű. Lényegében ezzel egy metafüggvényt kaptunk. A sablonparaméter ennek a függvénynek a paramétere, az értéke pedig a statikus változó kiolvasásával érhető el. IsCharacter<char>::value értéke true, IsCharacter<int>::value értéke false. Emlékezzünk vissza: a constexpr minősítő fordítási idejű konstanst jelent, így ezek a változók még sablonparaméterként is használhatóak lesznek.

A következő lépés egy olyan segédosztály létrehozása, amely a bool típusú sablonparaméterétől függően vagy tartalmaz egy belső típust, vagy nem. Ebben a sablonparaméter névtelen, mert az osztályban nem kell semmire, csak a specializációhoz használjuk:

template <bool>
struct MyEnableIf;

template <>
struct MyEnableIf<false> {
    /* szándékosan üres osztály */
};

template <>
struct MyEnableIf<true> {
    using type = void;
};

Végül pedig már csak egy olyan függvény kell, amely használja ezt a belső típust a fejlécében:

template <typename T>
void print_char(T what, typename MyEnableIf<IsCharacter<T>::value>::type * = nullptr) {
    std::cout << "Karakter típusú: " << what;
}


int main() {
    print_char('c');  // OK
    print_char((signed char) 'a');  // OK

    print_char(5);  // szándékos fordítási hiba
}

Ez a print_char() függvénysablon csak a megadott három karaktertípusra fog tudni példányosodni. A korlátozást a második, névtelen paramétere vezeti be. Ez a paraméter azért névtelen, mert ennek sincsen szükségünk az értékére, semmit nem jelent; és azért kapja a nullptr alapértelmezés szerinti értéket, hogy kiírni se kelljen a függvény hívásánál. A pointer típusra pedig azért van szükség, mert a typename MyEnableIf<T>::type típus void, és void típusú paraméter nem létezhet. Viszont void* igen.

A függvény hosszú fejléce valójában csak annyi, mint ami a lenti kódrészletben is látható. A második paraméterben, a void szó helyén van a mágia.

template <typename T>
void print_char(T what, void * dummy = nullptr) {
    std::cout << "Karakter típusú: " << what;
}

A függvény hívásánál a fordító látja, hogy a sablonparamétert az első paraméter alapján kell levezetnie. Pl. 'c' char típusú, ezért T = char. A második paramétert pedig már a MyEnableIf sablonosztályból veszi; ha abban van type nevű belső típus, akkor az lesz, ha nincs, akkor pedig a SFINAE miatt a függvénysablon figyelmen kívül lesz hagyva. Ez pedig az IsCharacter<T>::value értékétől függ.

A C++11-es STL már beépítve tartalmaz ehhez hasonló eszközöket. Az #include <type_traits> fejlécfájl std::is_integral, std::is_array, std::is_class, std::is_polymorphic, std::is_move_constructible, std::is_nothrow_copy_constructible stb. sablonjai információkat adnak a sablonparaméterként megadott típusról. A bennük lévő value statikus változó igaz/hamis értéke adja meg, hogy teljesül-e a típusra a megadott feltétel. Az std::enable_if sablon pedig type néven tartalmaz egy típust, de csak akkor, ha az első sablonparamétere true értékű. Ezekkel könnyedén megadhatjuk, hogy létezzen-e egy adott sablonfüggvény, vagy nem:

#include <iostream>
#include <type_traits>


template <typename T>
void print_num(T what, typename std::enable_if<std::is_integral<T>::value>::type * = nullptr) {
    std::cout << "Egész: " << what << std::endl;
}


template <typename T>
void print_num(T what, typename std::enable_if<std::is_floating_point<T>::value>::type * = nullptr) {
    std::cout << "Valós: " << what << std::endl;
}


int main() {
    print_num(5);
    print_num(5.1);
}

Az std::enable_if type típusa nem csak utolsó, „rejtett” függvényparaméterként használható, hanem a visszatérési értékben is:

template <typename T>
typename std::enable_if<std::is_integral<T>::value>::type   /* void */
print_num(T what) {
    std::cout << "Egész: " << what << std::endl;
}

Vagy esetleg sablonparaméterként, mint lentebb. Ne felejtsük el, hogy sablonparaméter is lehet névtelen, az is lehet pointer típusú, és annak is lehet alapértelmezés szerinti értéke. Tehát a lenti függvény a használója számára egyetlen sablonparaméterűnek tűnik, sőt ez a sablonparaméter a szokásos módon levezethető a konkrét függvényhívásból:

template <typename T,
          typename std::enable_if<std::is_integral<T>::value>::type * = nullptr>
void print_num(T what) {
    std::cout << "Egész: " << what << std::endl;
}

A „rejtett” sablonparaméter lehet típus is:

template <typename T,
          typename = typename std::enable_if<std::is_integral<T>::value>::type>
void print_num(T what) {
    std::cout << "Egész: " << what << std::endl;
}

Általában véve amúgy ez a legjobb megoldás. Az extra függvényparaméteres és visszatérési értékes változat nem mindig használható: egyes műveleteknek nem lehet extra paramétere (pl. operátorok), másoknak nincs visszatérési értéke (konstruktorok). A sablonparaméteres változat előnye az is, hogy olvashatóbb, nem zavarja össze a függvény fejlécét.

Ezzel már az eredeti problémát is megoldhatjuk. A vektor osztályunk, amelynek konstruktora csak akkor inicializálja külön is a tömb tagjait, ha azok beépített típusúak, nem pedig valamilyen osztály objektumai:

#include <iostream>
#include <type_traits>
#include <vector>


template <typename T>
class MyVector {
  private:
    size_t size_;
    T *pData_;

  public:
    /* ha osztály */
    template <typename U = T, typename std::enable_if<std::is_class<U>::value>::type * = nullptr>
        MyVector(size_t size);

    /* ha beépített */
    template <typename U = T, typename std::enable_if<!std::is_class<U>::value>::type * = nullptr>
        MyVector(size_t size);
};


template <typename T>
    template <typename U, typename std::enable_if<std::is_class<U>::value>::type *>
        MyVector<T>::MyVector(size_t size) {
            std::cout << "Osztályból: maguktól inicializálódnak." << std::endl;
            size_ = size;
            pData_ = new T[size_];
        }


template <typename T>
    template <typename U, typename std::enable_if<!std::is_class<U>::value>::type *>
        MyVector<T>::MyVector(size_t size) {
            std::cout << "Beépített: külön kinullázva." << std::endl;
            size_ = size;
            pData_ = new T[size_];
            for (size_t i = 0; i != size_; ++i)
                pData_[i] = T();
        }


int main() {
    MyVector<int> v1{12};
    MyVector<std::vector<int>> v2{12};
}

Itt a konstruktort sablonná kellett tenni (saját sablonparaméterrel), hogy a SFINAE szabály érvényesülni tudjon; a behelyettesítést a konkrét függvényhívás kell kiváltsa. Ezért ez kapott egy saját U sablonparamétert, amelytől a második, std::enable_if-es sablonparamétere függhet. A hívásnál nem kell kiírni a sablonparamétert. A fordító nem is tudná levezetni, de nem is kell: az U = T alapértelmezést már a deklarációnál jelezni tudjuk.

Másképp nem is lehetne, mert egy sablon konstruktornak nem lehet explicite megadni a sablonparamétereit. Ez azért van így, mert a sablonparamétereket a függvények neve után kell tenni (pl. foo<int>()), a konstruktornak viszont nincsen neve.

Segédosztály, amely megmondja egy típusról, hogy az karakter-e:

template <typename T> struct IsCharacter {
    static constexpr bool value = false;
};

template <> struct IsCharacter<char> {
    static constexpr bool value = true;
};

template <> struct IsCharacter<unsigned char> {
    static constexpr bool value = true;
};

template <> struct IsCharacter<signed char> {
    static constexpr bool value = true;
};

Ezzel lényegében egy metafüggvényt kaptunk. A constexpr fordítási idejű konstans => sablonparaméterként is használható.

6. std::move_if_noexcept

Hasonlóan van megvalósítva a kivételek kapcsán bemutatott std::move_if_noexcept függvénysablon. Ez jobbérték referenciává konvertálja a balérték paraméterét, de csak akkor, ha a mozgató konstruktora nem dobhat kivételt. Amúgy a visszatérési értéke balérték – és ez azt jelenti, hogy a visszatérési érték típusa a paraméter típusának valamilyen tulajdonságától függ. Ez az std::conditional sablonosztállyal valósítható meg. Ez úgy működik, mint a ?: operátor, csak típusokra: a benne lévő type nevű belső típus a második sablonparaméterrel egyezik meg, ha igaz értékű, és a harmadikkal, ha hamis értékű, az első sablonparaméter:

template <typename T>
typename std::conditional<std::is_nothrow_move_constructible<T>::value, T&&, T&>::type
move_if_noexcept(T& x) noexcept {
    using Ref = typename std::conditional<std::is_nothrow_move_constructible<T>::value, T&&, T&>::type;
    return static_cast<Ref>(x);
}

Az std::is_nothrow_move_constructible sablon a noexcept operátort használja arra, hogy megkérdezze a fordítótól, a mozgató konstruktor noexcept minősítésű-e:

template <typename T>
struct is_nothrow_move_constructible {
    static constexpr bool value = noexcept(T(std::move(*static_cast<T*>(nullptr))));
};

Ebben a kódrészletben egy képzeletbeli objektumra próbáljuk meg meghívni a mozgató konstruktort. Erre azért van szükség, mert valamilyen kifejezést meg kell adni a noexcept operátornak, amit vizsgálni tud. Az objektum pedig csak képzeletbeli lehet (a dereferált null pointeren keresztül). Olyan kifejezést nem adhatunk meg, amely létre is hoz egy objektumot: pl. a noexcept(T(T())) kifejezés nem csak azt vizsgálná, hogy a mozgató konstruktor noexcept-e, hanem azt is, hogy az alapértelmezett konstruktor is noexcept-e, mert a legbelső T() kifejezésrészlet az alapértelmezett konstruktor hívását jelenti. Ezt azonban nem akarjuk vizsgálni, csak a mozgató konstruktort.

A nullptr trükk és az std::declval<T>() függvénysablon

A sablon metaprogramozásban gyakran használjuk a fenti trükköt, nevezetesen hogy egy null pointert konvertálunk valamilyen típusúra. Kiértékeletlen környezetben ez nem probléma, a null pointer még dereferálható is. A noexcept-ben a kifejezés valójában nem lesz kiértékelve, de a fordító közben alkalmazza azokat a szabályokat, amelyeket a kiértékelésnél is kellene.

Erre van is egy beépített segédfüggvény, amely pont ilyen helyzetekben használható, az std::declval<T>(). Ez jobbértéket ad, T &&-et, tehát ennyit kell írnunk:

template <typename T>
struct is_nothrow_move_constructible {
    static constexpr bool value = noexcept(T(std::declval<T>()));
};

Az std::declval() függvénynek nincs is definíciója, csak deklarációja, mert éppen ilyen helyzetekre szánták – kiértékeletlen környezetekben való használatra.

template <typename T> typename std::conditional
<std::is_nothrow_move_constructible<T>::value, T&&, T&> ::type move_if_noexcept(T& x) noexcept {
  using Ref = typename std::conditional
  <std::is_nothrow_move_constructible<T>::value, T&&, T&>
    ::type;
  return static_cast<Ref>(x);
}

Az std::is_nothrow_move_constructible sablon a noexcept operátort használja arra, hogy megkérdezze a fordítótól, a mozgató konstruktor noexcept minősítésű-e:

template <typename T>
struct is_nothrow_move_constructible {
  static constexpr bool value = // kiértékeletlen környezet !
		noexcept(T(std::move(*static_cast<T*>(nullptr))));
};

7. Sablon metaprogramozás

A C++ programozók egy idő után rájöttek, hogy egészen más dolgokra is lehet használni a sablonokat, nem csak tárolók és generikus algoritmusok megvalósítására. Konkrétan számítások is végezhetők sablonokkal. Tekintsük az alábbi faktoriális osztálysablont és használatát:

„Meta-
függvény”
template <int n>
class Factorial {
  public:
    static constexpr int value = n * Factorial<n-1>::value;
};


template <>
class Factorial<0> {
  public:
    static constexpr int value = 1;
};


int main() {
    std::cout << Factorial<6>::value;
}

A felső osztálysablon nem tartalmaz mást, mint egy statikus egész konstanst. Ennek kiolvasása a főprogramban látszik: a Factorial osztálysablont példányosítjuk n = 6 sablonparaméterrel, és az így keletkező osztályban lévő value értéket írjuk a képernyőre.

A mágia a value érték megadásakor történik, ezt ugyanis egy kifejezéssel adjuk meg: a sablonparaméter n szorozva a másik osztályból vett value értékével. A másik osztály pedig szintén egy faktoriális, amit n-1-gyel példányosítunk. Így a fordító rekurzívan példányosítani fogja az osztályokat: Factorial<6>-hoz Factorial<5>-öt, ahhoz Factorial<4>-et és így tovább. Ezeket a példányosításokat a fordító kénytelen elvégezni fordítási időben, mert a statikus konstans értékét meg kell határoznia. (Mindez amúgy C++98-ban is lehetséges volt, csak ott a static constexpr helyett enum típust kellett használni.)

Már látjuk, hogy ebből faktoriális lesz: n! = n*(n-1)!, csak a végtelen rekurziót meg kell állítani valahogy. Erre való a Factorial<0> specializáció. Mivel ez az explicit, felhasználó által adott specializáció létezik, a Factorial<0>-t nem az alapsablonból (base template) példányosítja a fordító, hanem a megadott osztályt használja. Mint a többiben, úgy ebben is van egy value nevű érték megadva, hogy a kétféle módon példányosított osztály egyformán viselkedjen.

A számítás elvégzését így futási időből áthelyeztük fordítási időbe. Ez is a sablon metaprogramozásnak (template metaprogramming, TMP) egy fajtája. A dolog érdekessége – és ez az, amire utólag jöttek csak rá –, hogy a sablonok nyelve önálló, teljes értékű programnyelvként használható, mert Turing-teljes. (Ez nagyjából azt jelenti, hogy bármi, ami algoritmikusan kiszámítható, megoldható sablon metaprogrammal is.) Ez bizonyítható matematikailag is, de sejtjük is, hogy így van. Az egész szám típusú sablonparamétereken keresztül tudunk számokkal dolgozni, a ciklusok helyett rekurziót használhatunk, az elágazásokat pedig a specializációk helyettesítik. Nem könnyű így programozni (az ilyen programban hibát keresni még nehezebb), de ennek ellenére született még C++ sablonnyelven írt sugárkövető program is.

Rekurzió örökléssel

A faktoriális osztályra, sőt általában rekurzió szervezésére nem az az egyetlen egy megoldás, hogy a sablonosztály hasában egy másik sablonosztályt példányosítunk: örökölni is lehet.

Alakítsuk át a szokásos faktoriális függvényünket:

int fact(int n) {   // eredeti
    if (n == 0)
        return 1;
    else
        return n * fact(n - 1);
}
int fact(int n, int acc = 1) {  // új
    if (n == 0)
        return acc;
    else
        return fact(n - 1, acc * n);
}

Az új függvény ún. jobbrekurzív tulajdonsággal rendelkezik (terminális rekurzió, farokrekurzió; angolul: tail recursion). Ez azt jelenti, hogy a rekurzív hívás helyén a visszatérés után már nem csinálunk semmit, csak visszaadjuk a hívásból kapott értéket. Ez az eredeti változatban nem volt így, mert a rekurzív hívás után még egy szorzást elvégeztünk. Az acc ún. gyűjtőargumentumban akkumulálódik a szorzat. Mire az n = 0 híváshoz jutunk, addigra az acc paraméter értéke éppen a keresett faktoriális.

Ugyanez sablon metaprogramozással:

template <int N, int ACC = 1>
struct Fact : Fact<N - 1, ACC * N> {};

template <int ACC>
struct Fact<0, ACC> {
    static constexpr int value = ACC;
};

Ennek működése egy példán:

  • Fact<4> ugyanaz, mint Fact<4, 1> (a default paraméter miatt). Ez az osztály üres, de örököl Fact<3, 4>-ből.
  • Fact<3, 4> üres, de örököl Fact<2, 12>-ből.
  • Fact<2, 12> üres, de örököl Fact<1, 24>-ből.
  • Fact<1, 24> üres, de örököl Fact<0, 24>-ből.
  • Fact<0, 24> tartalmaz egy value = 24 statikus változót.
  • ... amelyet megörököl a Fact<1, 24>, amelyet megörököl a Fact<2, 12>, amelyet megörököl ... a Fact<4, 1>, az eredeti osztály.
„Meta-függvény”

template <int n> class Factorial {
  public:
    static constexpr int value = n * Factorial<n-1>::value;
};

template <> class Factorial<0> {
  public:
    static constexpr int value = 1;
};

int main() {
    std::cout << Factorial<6>::value;
}

Az osztálysablon egy statikus egész konstanst tartalmaz. Ennek kiolvasása a főprogramban látszik.

A sablonok nyelve önálló, teljes értékű programnyelvként használható, mert Turing-teljes. (ciklus => rekurzió, elágazás => specializáció)

8. A sablon metaprogramozás elemei

Ha valamilyen okból sablon metaprogramozást kell alkalmaznunk, a C++-ban megszokott imperatív gondolkodásmódról át kell állítani az agyunkat a funkcionális programozás gondolkodásmódjára. A sablon metaprogramozásban ugyanis nincsenek elágazások, nincsenek ciklusok, nincsenek értékadások – csak feltételes kiértékelés és rekurzió létezik, épp mint a funkcionális programnyelvekben. (A tisztán funkcionális nyelveket éppen arról ismerjük meg, hogy egyáltalán nincsen bennük értékadás, és így nincsenek változók sem, csak konstansok.) Nézzük meg ezt egy egyszerű példán, egy függvényen, amely megmondja egy számról, hogy prímszám-e! „Sima” C++-ban ezt írnánk (n≥2-re működik):

bool is_prime(int n) {
    for (int d = 2; d < n; ++d)
        if (n % d == 0)
            return false;
    return true;
}

A keményebb diónak a ciklus tűnik, de szerencsére egy jól formált ciklus könnyedén, szinte gondolkodás nélkül átalakítható rekurzióvá. Az átalakítás trükkje az, hogy a ciklusváltozót függvényparaméterré kell alakítani. A ciklustörzs végrehajtása után a következő „iterációra” úgy ugrunk, azaz rekurzívan meghívjuk a függvényt a ciklusváltozó következő értékével. Szükségünk lehet egy segédfüggvényre is, amely elindítja a rekurziót; ennek a szerepe csak annyi, hogy a segédparaméternek a ciklusváltozó induló értékét hagyja. (Sokszor ez el is hagyható.) A séma:

iteratív változat
void do_iter(int from, int to) {
    for (int i = from; i < to; i = i+1)
        /* ... */;
}
rekurzív változat
void do_rec_helper(int from, int to, int i) {
    if (i < to) {
        /* ... */;
        do_rec_helper(from, to, i+1);
    }
}

void do_rec(int from, int to) {
    do_rec_helper(from, to, from);
}

A feltételekkel egyszerűbb a dolgunk, azokat legtöbb helyen könnyedén kicserélhetjük egy ?: operátorra. Persze miután úgy alakítottuk a függvényünket, hogy egyetlen return utasításból álljon, mert a funkcionális programozásban minden függvény törzse egyetlen kifejezés.

A prímszámos feladatra visszatérve, a fenti gondolatmenetet az alábbiak szerint alkalmazhatjuk:

bool is_prime_helper(int n, int d) {
    if (n == d)
        return true;
    if (n % d == 0)
        return false;
    return is_prime_helper(n, d+1);
}

bool is_prime(int n) {
    return is_prime_helper(n, 2);
}

Ebben a függvényben már megvan a ciklusváltozónak használt d paraméter, amely az osztót jelképezi. Az első feltétel azt ellenőrzi, hogy a d osztóval elértük-e a vizsgált számot. Ha igen, akkor a szám prímszám, mert nem találtunk osztót. A második feltétel azt ellenőrzi, hogy a vizsgált számnál amúgy kisebb osztó maradék nélkül osztja-e a számot. Ha igen, akkor az nem prímszám. Ha egyik feltétel sem teljesült, akkor meg kell vizsgálni a következő osztót.

Az így alakított program már egyetlen kifejezéssé írható át:

bool is_prime_helper(int n, int d) {
    return
        n == d
            ? true
            : n % d == 0
                ? false
                : is_prime_helper(n, d+1);
}

bool is_prime(int n) {
    return is_prime_helper(n, 2);
}

Ebből már így látszólag könnyedén sablont csinálhatunk, mert sablonparaméterek lehetnek az n és a d változók is. Kövessük az eddig látott sémát, a kiszámolt érték legyen mindig statikus tagváltozója az osztálynak, value néven! Az első, de sajnos még nem működő verzió az alábbi:

HIBÁS
template <int N, int D>
struct IsPrimeHelper {
    static constexpr bool value =
        N == D
            ? true
            : N % D == 0
                ? false
                : IsPrimeHelper<N, D+1>::value;
};

template <int N>
struct IsPrime {
    static constexpr bool value = IsPrimeHelper<N, 2>::value;
};

A kód papíron helyes, lefordítani viszont a nyelv logikája miatt nem lehet. Az IsPrimeHelper<n, d+1> osztályt a fordító mindenképpen szeretné példányosítani, nem csak akkor, ha az n % d == 0 kifejezés hamisra értékelődik ki – így végtelen rekurzióba keveredik. A problémát megoldhatjuk egy specializációval is (template <int n> struct IsPrimeHelper<n, n>), de inkább nézzünk ehelyett egy általános megoldást!

A sablon metaprogramozásban gyakran használják az alábbi osztályt. Ez az első sablonparaméterétől függően a második vagy a harmadik sablonparaméterében megadott osztály value statikus adattagját másolja le a saját statikus value adattagjába, hasonlóan a ?: operátorhoz:

„Meta-
feltétel”
template <bool CONDITION, typename TRUECLASS, typename FALSECLASS>
struct Condition {
    static constexpr auto value = FALSECLASS::value;
};

template <typename TRUECLASS, typename FALSECLASS>
struct Condition<true, TRUECLASS, FALSECLASS> {
    static constexpr auto value = TRUECLASS::value;
};

Mivel ez az osztály két második osztály közül képes választani csak, a programunkban használt konstansokat is osztályokba kell csomagolnunk. Két konstansunk van, a true és a false, ezek becsomagolva:

„Meta-
konstans”
struct True {
    static constexpr bool value = true;
};

struct False {
    static constexpr bool value = false;
};

Ezekkel együtt a prímszámos metaprogramunk:

template <int N, int D>
struct IsPrimeHelper {
    static constexpr bool value =
        Condition<N == D,
                  True,
                  Condition<N % D == 0,
                            False,
                            IsPrimeHelper<N, D+1>>>::value;
};


template <int N>
struct IsPrime {
    static constexpr bool value = IsPrimeHelper<N, 2>::value;
};


int main() {
    std::cout << IsPrime<13>::value;
}

Tekintve a kapott kód olvashatóságát, a sablon metaprogramozás nem mindennapos használatra javasolt. Ahol fordítási idejű kiértékelésre van szükségünk, C++11-ben inkább constexpr minősítésű függvényeket érdemes használni. A sablon metaprogramozást hagyjuk meg azokra a különleges esetekre, amikor pl. típusok listájával kell dolgozni!

Hogyan tudnánk sablon metaprogramozással megvasósítani a prímellenőrzést?

bool is_prime(int n) {
    for (int d = 2; d < n; ++d)
        if (n % d == 0)
            return false;
    return true;
}

A sablon metaprogramozásban nincsenek elágazások, nincsenek ciklusok, nincsenek értékadások – csak feltételes kiértékelés és rekurzió létezik, épp mint a funkcionális programnyelvekben.

9. Irodalom és érdekességek