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ó.
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.
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) SFINAEAz (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).
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:
typename std::enable_if_t<cond, T> f(...). A hiba a
return type-ban substitution failure.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.
A visszatérési típusra tett constraint (klasszikusan
typename std::enable_if_t<..., T>) három okból nem
ideális:
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.
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 orderedA 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ó.
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.
Leggyakrabban az alábbi hibákba futunk:
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.::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.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.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.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.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.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…