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.
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.
#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 */
}
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.
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 */
}
function my_print(x) {
if(typeof(x)=="number"){
console.log("number ");
}
else
if(typeof(x)=="string"){
console.log("string ");
}
else {
console.log("ismeretlen");
}
}
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:
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:
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:
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:
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?
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.
![]()
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:
- 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.
- 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.
- 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.
- Ha több is, akkor a jobban illeszkedőt kell választani.
- 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écbeT = std::ostream
-et helyettesítveprint_if_class_helper(int std::ostream::* ptr)
adódik. Ez lehet, hogy jó lesz, mert a0
paraméter null pointernek tekinthető. - A másik függvényből
print_if_class_helper(...)
lesz. A0
paraméter itt a változó hosszúságú argumentumlistánint
-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)
sablonbaT = double
-t helyettesítveprint_if_class_helper(int double::* ptr)
adódik. Ez szintaktikai hiba, mert adouble
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.
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ó.
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 azstd::declval<T>()
függvénysablonA 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))));
};
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:
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, mintFact<4, 1>
(a default paraméter miatt). Ez az osztály üres, de örökölFact<3, 4>
-ből.Fact<3, 4>
üres, de örökölFact<2, 12>
-ből.Fact<2, 12>
üres, de örökölFact<1, 24>
-ből.Fact<1, 24>
üres, de örökölFact<0, 24>
-ből.Fact<0, 24>
tartalmaz egyvalue = 24
statikus változót.- ... amelyet megörököl a
Fact<1, 24>
, amelyet megörököl aFact<2, 12>
, amelyet megörököl ... aFact<4, 1>
, az eredeti osztály.
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ó)
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:
void do_iter(int from, int to) {
for (int i = from; i < to; i = i+1)
/* ... */;
}
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:
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:
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:
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.
- Bjarne Stroustrup: The Design and Evolution of C++. Addison-Wesley, 1994.
- Wikipedia: Duck typing.
- C++ template sugárkövető program: Metatrace.
- Fordítási idejű tetris Super Template Tetris és Snake.
- Keith Schwarz: Template Metaprogramming in C++.
- Meta Crush Saga: a C++17 compile-time game.
- Szűgyi Zalán, Cséri Tamás, Porkoláb Zoltán: Random number generator for C++ template metaprograms. Fordításonként más véletlenszámokat generál.
- Josh Walker: Template Metaprogramming. N királynő sablon metaprogram a hőskorból.