27.3 SFINAE és örökölt template minták

A constraint-alapú programozás mai formája a C++20-as concepts és requires, de a 2000-es évek végétől a 2020-as évek elejéig tartó időszak generikus könyvtárai szinte kizárólag a SFINAE-re (Substitution Failure Is Not An Error — „a helyettesítési hiba nem hiba”) és az enable_if-re épültek. Egy haladó DevOps/platformmérnök reálisan találkozik ilyen kóddal: Boost, nlohmann::json, korai fmt, fmtlog, régi absl-szeletek, saját in-house template-utility könyvtárak, illetve a saját magunk által 5 évvel ezelőtt írt kód. Ez a rész ezért nem új nyelvi képességet tanít, hanem az örökölt minták megértését és a requires-alapú modern megfelelőjükre fordítását célozza. A cél, hogy az olvasó felismerje: mikor számít egy hiba „substitution failure”-nek, mikor „hard error”-nak; és hogy milyen olvashatósági és kompatibilitási kompromisszumokkal jár a migráció.

27.3.1 Substitution failure és overload candidate kiesése

Tekintsük a sablon-argumentumok illesztésének a folyamatát. Amikor egy függvénysablonra hívást illesztünk, a fordító először a sablonparamétereket vezeti le a hívás argumentumaiból (template argument deduction), majd a levezetett argumentumokat behelyettesíti (substitute) a sablondeklarációba. A helyettesítés a teljes deklarációra kiterjed: a visszatérési típusra, a paraméterek típusára, a noexcept-specifikációra, a kivételspecifikációra, az alapértelmezett sablonargumentumokra.

Ha a helyettesítés olyan típust vagy kifejezést eredményez, amely szintaktikailag vagy szemantikailag érvénytelen, de ez a hiba az úgynevezett immediate context-en belül van, akkor a jelölt csendben kiesik az overload-halmazból, és a fordító a többi jelölt közül választ. Nem jelent fordítási hibát. Ha ilyen módon minden jelölt kiesik, a hívás „no matching function” hibát ad — de maga a kiesés nem hiba a nyelv szempontjából.

Definíció. Az immediate context az a leszűkített szintaktikai tartomány, ahol a helyettesítési hiba még nem hard error. Ide tartozik: a függvénysablon teljes deklarációja (visszatérési típus, paramétertípusok, noexcept-specifikáció, alapértelmezett argumentumok), valamint az osztálysablon-részleges-specializáció fejléce. Nem tartozik bele a függvénytest, sem a hívott függvények teste, sem egy másik sablon példányosítása, amelyet a helyettesítés során indítunk.

A klasszikus példa:

#include <type_traits>

template<class T>
typename T::value_type first(const T& c) { return *c.begin(); }

template<class T>
T first(T* p) { return *p; }

A first(&x) hívásnál, ahol x egy int, az első jelölt esetén T = int*, majd a helyettesítés int*::value_type-ot próbál előállítani, ami érvénytelen. Ez az immediate contextben történik (typename T::value_type a visszatérési típus), így az első jelölt kiesik, a második nyer. Ugyanez a helyettesítés egy T::value_type tagon a függvénytesten belül hard error lenne.

27.3.2 Immediate context versus instantiation hard error

A megkülönböztetés kulcsa a hely és az időzítés. Az immediate context a deklaráció szintjén értendő, és a substitution fázisában értékelődik ki. Ami a body-ban történik, azt a substitution fázis már nem érinti; az csak a kiválasztott overload példányosításakor derül ki, és hard error. Formálisan: a sablon kiválasztásakor még nem is példányosítjuk a testet; csak a deklarációt ellenőrizzük.

Tekintsük a következő két sablont:

template<class T>
void write_out(const T& v) { typename T::value_type tmp{}; (void)tmp; }   // (1) hard error

template<class T>
auto read_first(const T& c) -> typename T::value_type { return *c.begin(); } // (2) SFINAE

Az (1)-re write_out(42) hívás esetén a testben int::value_type érvénytelen → hard error, mert a body nem része az immediate contextnek. A (2)-re read_first(42) hívás esetén a T::value_type a visszatérési típusban van, tehát a substitution része, tehát SFINAE: a jelölt kiesik, más overload hiányában „no matching function”.

Ez az a pont, ahol a régi kód olvasója a leggyakrabban téved: a szerző azt hiszi, hogy „SFINAE-zel megvédtem”, de a védelem a body-ra nem terjed ki. Ilyenkor vagy a constraintet át kell tenni a deklarációba (enable_if vagy requires), vagy a body-t kell if constexpr-szel tagolni (ami már a nyelvi eszköztár része, és a requires-szel való kombináció is gyakori).

27.3.3 enable_if, void_t és a detection idiom

A SFINAE-t a gyakorlatban leggyakrabban két eszközzel használták: std::enable_if és std::void_t. Mindkettő a <type_traits>-ben található.

Az enable_if<B, T = void> definíciója: ha B igaz, van ::type tagja, értéke T; ha B hamis, ::type tag nincs. A hiba, ha B hamis, egy substitution failure, nem hard error — ezért alkalmas SFINAE-re.

Két tipikus felhasználási hely:

  1. Visszatérési típusban: typename std::enable_if_t<cond, T> f(...). A hiba a return type-ban substitution failure.
  2. Extra sablonparaméterként alapértelmezett argumentummal: template<class T, class = std::enable_if_t<cond>> void f(...). Ez robusztusabb, mert nem kell a visszatérési típust dedukáltatni.

A visszatérési típusbeli forma korlátja komoly: a két overload visszatérési típusának meg kell egyeznie, ha mindkettő viábilis, mert a nyelv nem engedi ugyanazt a függvénynevet két különböző visszatérési típussal. Ezért tipikusan enable_if_t<cond, T> és enable_if_t<cond2, T> páros szerepel, ahol T ugyanaz.

A void_t C++17 óta standard könyvtári eszköz:

template<class...> using void_t = void;

A detection idiom lényege: egy elsődleges sablon false_type-ból öröklődik, és egy részleges specializáció void_t<decltype(...)>-re szűkít. Ha a decltype érvénytelen, a specializáció nem illeszkedik, marad az elsődleges sablon (false); ha érvényes, void_t mindig void, tehát a specializáció illeszkedik (true). Például egy serialize() tagfüggvény detektálása:

template<class, class = void>
struct has_serialize : std::false_type {};

template<class T>
struct has_serialize<T, std::void_t<decltype(std::declval<const T&>().serialize())>>
    : std::true_type {};

A decltype operátorán belül std::declval<T>() csak kiértékelés nélküli kontextusban legális; ez a detection idiom kötelező eszköze. A void_t-nek legalább egy sablonparamétert tartalmazó packra van szüksége: void_t<> ugyanaz, mint void, és nem lenne dependens, tehát a specializáció akkor is illeszkedne, amikor a belső kifejezés érvénytelen.

27.3.4 Return-type constraint korlátai

A visszatérési típusra tett constraint (klasszikusan typename std::enable_if_t<..., T>) három okból nem ideális:

  1. A feltételnek dependensnek kell lennie egy levezetett sablonparamétertől, különben a substitution nem tudja értékelni, és hard error lesz belőle.
  2. Két viábilis overload esetén a visszatérési típusnak egyeznie kell — ez szűkíti a megfogalmazható feltételeket.
  3. Diagnosztikai szempontból a hibaüzenet a függvény szignatúrájára hivatkozik, nem egy nevesített predikátumra, ezért nehezen olvasható.

Továbbá a visszatérési típus az Itanium ABI-ban részét képezi a függvénysablon mangled nevének (nem úgy, mint a közönséges függvényeknél), ezért a feltétel visszatérési típusba ágyazása potenciálisan ABI-érzékeny. Eldobható prototípuson ez nem probléma, de egy dinamikusan linkelt, verziózott könyvtárban a szerződés része lehet. Ha erre érzékeny felületet tervezünk, a feltételt érdemes inkább extra sablonparaméterbe tenni, vagy requires-re áttérni — a requires-klóz nem része a függvénytípusnak, és az Itanium ABI manglingjában hagyományosan nem jelenik meg, bár ezt adott fordítón és ABI-verzión ellenőrizni kell nm-mel.

27.3.5 requires versus SFINAE összehasonlítás

A C++20-as requires-klóz ugyanazt a szerepet tölti be, de három lényeges előnnyel. Először is nevesíthető: egy concept deklarációban van a jelentés, és a hibaüzenetben a concept neve jelenik meg, nem egy hosszú szignatúra. Másodszor részlegesen rendezhető: két azonos nevű overload közül a szigorúbban korlátozott (több altételt kielégítő) nyer, nem ambiguity lesz. Harmadszor összefűzhető: concept A = ...; concept B = A && ...; szintaktikailag és szemantikailag is komponálható.

Ugyanaz a függvény két formában:

// SFINAE (C++11/14/17)
template<class T>
typename std::enable_if_t<std::is_integral_v<T>, T> add(T a, T b) { return a + b; }

// requires (C++20)
template<class T>
requires std::integral<T>
T add2(T a, T b) { return a + b; }

A requires előnye a részleges rendezésnél élesedik:

#include <concepts>

template<class T> concept addable = requires(T a, T b) { a + b; };
template<class T> concept ordered = addable<T> && requires(T a, T b) { a < b; };

template<addable T> void f(T) {}           // (A)
template<ordered T> void f(T) {}           // (B) szigorúbban korlátozott

f(1);          // (B) választódik, mert ordered subsumálja addable-t
f(1.0);        // (B) választódik, mert a double is ordered

A detection idiom requires-re fordítva:

template<class T>
concept serializable = requires(const T& t) { t.serialize(); };

static_assert(!serializable<int>);

Ez ugyanazt a predikátumot fejezi ki, mint a void_t-s has_serialize, de nevesített concept, és a hibaüzenetben is ez a név jelenik meg. Ha a detection visszatérési típusát is ellenőrizni kell, a requires-expression kifejezéskövetelménye tehető: requires(T t) { { t.size() } -> std::convertible_to<std::size_t>; }. Ez a fajta finomság SFINAE-vel csak sokkal hosszabban és olvashatatlanabbul volt megfogalmazható.

27.3.6 Olvashatóság és binary/source compatibility migrációkor

A migráció kulcskérdése: mikor biztonságos az enable_if-et requires-re cserélni? Három tengelyen érdemes vizsgálni.

Forráskompatibilitás. A requires-be kerülés megváltoztathatja az overload-feloldást: a részleges rendezés (subsumption) olyan eseteket is eldönthet, amit a régi SFINAE-szett nem — tipikusan két hasonló feltételnél, ahol eddig ambiguity volt, most az egyik nyer. Emiatt a meglévő hívások viselkedése megváltozhat. Ezért migrációkor a teljes tesztmátrixot kell futtatni, nem csak a sikeres buildet.

Bináris kompatibilitás. A requires-klóz nem része a függvénytípusnak, ezért az Itanium ABI-ban a mangled név jellemzően változatlan marad, ha az instantiated típusok azonosak. A visszatérési típusba ágyazott enable_if azonban mangling-érzékeny: ha a feltétel hamis, a jelölt kiesik; ha igaz, a függvény visszatérési típusa az enable_if<true,T>::type == T, tehát az instantiated típus megegyezik a plain T-vel. Emiatt a mangled név is azonos. Ez azonban adott fordító és ABI-verzió mellett ellenőrizendő — nm -C és c++filt a gyakorlati ellenőrző eszköz. A requires-re való áttérés tipikusan nem tör meg bináris felületet, ha a hatékony típusok egyébként megegyeznek; a szerződésnek azonban ezt nem szabad hallgatólagosan feltételezni.

Olvashatóság. Örök kódban a typename std::enable_if_t<...> zsúfolt, többsoros feltételek benne nehezen követhetőek, a diagnosztika a fordítótól függően kaotikus. Migrációkor érdemes két lépésben haladni: először nevesített concept-eket bevezetni a meglévő predikátumokból, még a régi SFINAE-szignatúrák mellett; majd amikor a concept-ek stabilizálódtak, lecserélni a szignatúrákat. Ez a stratégia lehetővé teszi, hogy a fogalmi réteg és a mechanikai implementáció külön fejlődjön, és hogy a régi overloadok addig éljenek, amíg a hívások át nem állnak.

Gyakori migrációs hiba, hogy a fejlesztő a régi enable_if-et requires-re cseréli, és elfelejti, hogy a régi overload enable_if-ben visszatérési típusba ágyazva volt — ilyenkor a requires-re való áttérés megváltoztatja a függvény tényleges visszatérési típusát, például T helyett void lesz, ha a fordító nem a visszatérési típusban találja a típust. A place-of-constraint mindig tudatos döntés legyen.

27.3.7 Gyakori hibák

Leggyakrabban az alábbi hibákba futunk:

  1. Hard error a body-ban. A substitution failure csak a deklaráció szintjén működik; ha a hiba a függvénytestben keletkezik, a fordító hard error-t ad.
  2. Nem-dependens feltétel. Ha az enable_if feltétele nem függ a levezetett sablonargumentumtól, akkor a substitution az összes jelöltre egyszerre értékelődik ki, és az érvénytelen feltétel hard error lesz, nem SFINAE.
  3. Hiányzó ::type. enable_if<nem_igaz> nem rendelkezik ::type taggal, ezért enable_if<nem_igaz>::type érvénytelen — a helyes használat enable_if_t<igaz, Cél> vagy enable_if<igaz>::type.
  4. void_t pakk nélkül. std::void_t<> ugyanaz, mint void, és nem tesz függővé semmit, ezért a specializáció akkor is illeszkedhet, amikor a belső kifejezés érvénytelen.
  5. Két különböző visszatérési típus. Ha a visszatérési típusba ágyazott enable_if során a feltétel igaz, és a másik overload feltétele is igaz, a két visszatérési típusnak meg kell egyeznie, különben a fordító nem tudja ugyanazt a nevet két különböző visszatérési típussal feloldani.
  6. declval futásidőben. std::declval<T>() csak kiértékelés nélküli kontextusban legális; futásidejű hívás hard error.
  7. Migráció alatt az elsődlegesen érintett overload feledése. A requires-re áttérés után az overload-halmaz részleges rendezése más, mint korábban; a teljes mátrixot tesztelni kell.

27.3.8 Feladatok

1. feladat (magyarázó). Adott a következő két overload:

template<class T>
typename std::enable_if_t<std::is_integral_v<T>, T> foo(T);

template<class T>
typename std::enable_if_t<std::is_floating_point_v<T>, T> foo(T);

Válaszoljon: mi történik foo(1), foo(1.0), foo("x") hívás esetén? Hol történik a substitution, és a hiba az immediate contextben van-e? Miért nincs hard error, mégis elutasítja a fordító a foo("x") hívást?

2. feladat (kódolási). Fordítsa le a lenti void_t-alapú detection idi…