void_t
Czirkos Zoltán · 2022.06.21.
void_t: a template metaprogramozás egyik csodafegyvere
Walter E. Brown kitalált egy miniatűr sablon kódot, ami ilyen egyszerűen hangzik:
template <typename...>
using void_t = void;
Olyan, mintha ez a kódrészlet nem csinálna semmit, de kiderül, hogy mégis: szinte csodafegyvernek nevezhető.
Mit csinál a fenti típusdefiníció? Lényegében semmit. Létrehoz egy sablon típusnevet (alias), amit bármilyen típussal
vagy típusokkal példányosítunk, void
-ot kapunk eredményül. Tehát void_t<void> = void
,
void_t<bool, double> = void
és így tovább.
Mire jó ez? Arra, hogy SFINAE-vel és specializációkkal együtt használjuk.
Tegyük fel, hogy a feladatunk a következő. Egy olyan sablon osztályra van szükségünk, amelyik megmondja egy tárolóról,
hogy az definiál-e iterátor típust. Például A
nem ilyen, B
viszont igen. Ezért a
has_iterator<A>::value
értéke hamis, B
-vel ugyanennek a metafüggvénynek az értéke
igaz kell legyen:
class A {};
class B {
public:
class iterator {};
};
int main() {
std::cout << has_iterator<A>::value << std::endl; /* false */
std::cout << has_iterator<B>::value << std::endl; /* true */
}
A void_t
használatával ez ilyen egyszerű:
template <typename...>
using void_t = void;
template <typename, typename = void>
class has_iterator : public std::false_type {};
template <typename C>
class has_iterator<C, void_t<typename C::iterator>> : public std::true_type {};
Nézzük meg jobban a fenti kódot! Adott a has_iterator
osztály, amelynek sablonparamétere a vizsgálandó osztály
lesz. Van egy második sablonparamétere is, de annak az alapértelmezett értéke void
, és láthatóan a használatnál nem is
kell kiírni. Adott ezen kívül ennek az osztálynak egy részleges specializációja, amelyben az első sablonparaméter még mindig bármi
lehet, a második pedig a void_t
alias. Látszik az is, hogy az alap sablon a false_type
-ból örököl, a
specializáció pedig a true_type
-ból, így lesz a value
tag értéke hamis vagy igaz, attól függően, hogy
az alap sablont vagy a specializált sablont használta a fordító egy adott típusra.
Próbáljuk meg példányosítani ezt az osztályt az A
-val! Ebben az esetben a specializált változat elszáll, mivel
A::iterator
nem létezik. Ezért ezt a szokásos módon, a SFINAE
szabály alapján eldobja a fordító. Marad az alap sablon, amelyik a false_type
-ból örököl,
ezért has_iterator<A>::value
értéke false
lesz.
Mi lesz a helyzet a B
-vel példányosítás esetén? Ilyenkor a B::iterator
is valid; void_t<B::iterator>
értéke pedig void
. Tehát a specializáció példányosítható. Igaz, az alap sablon is, de ilyenkor a fordító a specializációt
fogja választani – mert az a specializált változat. Tehát has_iterator<B>::value
értéke true
lesz.
És ennyi, meg van oldva a feladat.
Itt már látszik egyébként, hogy a void
-nak mi a jelentősége: igazából semmi, a void
helyett lehetne
bármilyen másik típust is használni. Lényeg, hogy ugyanazt a típust adjuk meg az alap sablonnál default paraméterként:
typename = void
, és a típusdefiníciónál: using void_t = void
. Legegyszerűbb a void
, mert
nem jelent semmit.
Ha meg szeretnénk nézni, van-e egy osztálynak statikus, paraméter nélkül hívható create()
függvénye:
template <typename, typename = void>
class has_create : public std::false_type {};
template <typename C>
class has_create<C, void_t<decltype(C::create())>> : public std::true_type {};
Ha arra vagyunk kíváncsiak, van-e az osztálynak x
nevű, int
típusú adattagja:
template <typename, typename = void>
class has_x : public std::false_type {};
template <typename C>
class has_x<C, void_t<decltype(&C::x)>> : public std::is_same<int C::*, decltype(&C::x)> {};
Az is_same
vizsgálat az adattag típusát ellenőrzi.
Ha meg szeretnénk vizsgálni, az iterátorunk biztosít-e véletlen elérést (random access iterator),
pl. rendelkezik-e az iterator + integer
, iterator - iterator
műveletekkel,
amikre az algoritmusunknak szüksége van:
template <typename, typename = void>
class is_random_access : public std::false_type {};
template <typename I>
class is_random_access<I, void_t<decltype(I() + 0), decltype(I() - I())>> : public std::true_type {};
Látszik, hogy a typename...
miatt bármennyi vizsgálandó kifejezést megadhatunk. Ha bármelyik részlet
SFINAE miatt elszáll, a specializáció nem példányosítható, így tulajdonképpen a végső eredményt lényegében az egyes
vizsgálatok eredményeinek logikai ÉS kapcsolata adja.
A void_t
definíciója duplán C++11-es: a typename...
miatt és a template<> using
miatt is.
De minden, ami a következő írásban szerepelni fog, működött volna C++98-ban is, csak akkoriban a void_t
-t
még nem találták fel. A C++98-as forma:
template <typename>
struct void_t {
using type = void;
};
A működése ennek ugyanaz: adott egy típus, ami bármit kap, void
-ot állít elő. Igaz, itt sablonparaméter is
csak egy lehet, de SFINAE használatához ez általában elég.
Kis történeti érdekesség, hogy eredendően ez a void_t
trükk nem minden fordítóval működött. GCC-vel például igen,
clang-gel pedig nem. Kiderült, hogy ez nem a fordítók hibája, ugyanis a szabvány nem adta meg egyértelműen, hogy egy ilyen kódban
mi a teendő, és az egyes gyártók eltérőképpen értelmezték a szöveget. A szabványnál is, nem egy hibáról, hanem egy hiányosságról
van szó. A fenti has_iterator
metafüggvénynek ugyanis, ha a B
-vel példányosítjuk, mindkét változata
egyforma. Csak a deklarációkat tekintve:
template <typename, typename = void>
class has_iterator; /* alap */
template <typename C>
class has_iterator<C, void_t<typename C::iterator>>; /* specializáció */
B-t behelyettesítve, és a void_t
-t kifejtve:
template <>
class has_iterator<B, void>; /* alapból lett */
template <>
class has_iterator<B, void>; /* specializációból lett */
Tehát az alap sablonnál és a részleges specializációnál is végeredményben egy ugyanolyan formájú teljes specializáció áll elő automatikusan – és az nem volt definiálva a C++98-ban, hogy ilyenkor mi a teendő. Azóta ezt már rögzítették: ilyenkor az eredeti sablonok közül a specializáltat kell választania a fordítónak.