C++14/17 újdonságok

Czirkos Zoltán · 2022.06.21.

C++14/17 újdonságok

Ez az oldal áttekintést ad a C++14 és a C++17 nyelv újdonságairól (a teljesség igénye nélkül). Nem olyan részletes, mint a tananyag többi része. De ezek a részek nem is szerepelnek a számonkérésekben, hanem inkább kitekintést nyújtanak.

C++17 tekintetében érdemes ezt az oldalt is nézni: http://www.bfilipek.com/2017/01/cpp17features.html. Ez az egyes eszközöket támogató fordítóprogramok verziószámát is jelzi. Hasznos gyűjtemény még ez: Changes between C++14 and C++17 DIS.

1. Kiértékelési sorrendek

A C nyelvben annak idején elég lazán vették a kiértékelési sorrendek meghatározását. Nem volt megkötve, hogy az értékadások bal vagy jobb oldala értékelődik ki előbb. Nem volt megkötve, hogy a függvényhívások paraméterei milyen sorrendben értékelődnek ki. Azonban azóta a programozási stílus sokat változott, és ezt követnie kell a nyelvnek is. A C++17-ben ezért:

  • A postfix kifejezések, pl. az indexelések, függvényhívások, balról jobbra értékelődnek ki.
  • Az értékadások operandusai jobbról balra értékelődnek ki.
  • A léptető (és egyben kiíró, beolvasó) operátorok balról jobbra értékelődnek ki.

Hogy erre miért volt szükség, arról a hivatkozott írásban lehet olvasni; néhány kézzelfogható példával együtt. Ez a változás egyébként, bár igencsak a nyelv alapjait érinti, jól megírt programok esetén nem okoz semmiféle problémát.

Vigyázat! Az operátorok precedenciája és asszociativitása nem ugyanazt jelenti, mint a kiértékelési sorrend! A precedencia azt határozza meg, hogy melyik operátornak mik az operandusai. A kiértékelési sorrend pedig azt, hogy a mellékhatások (változások) milyen sorrendben történnek meg. Például az alábbi kódban az indexelő és a függvényhívó operátor nagyon magas, míg az értékadó operátor nagyon alacsony precedenciájú. Ezért biztos, hogy az értékadás az indexelő operátor által visszaadott memóriahelyre fogja írni a tagfüggvény által visszaadott értéket. Ez teljesen egyértelmű.

std::map<int, int> m;
m[0] = m.size();

Csak az nem volt eddig egyértelmű, hogy az indexelő operátor, vagy a méret lekérdezése hívódik meg előbb. Mert ha az indexelő, akkor a .size() már 1-et fog adni (a map indexelése létrehozza a nem létező elemet), viszont ha fordítva, akkor 0-t. Most már kötött a sorrend, a méret lekérdezése fog előbb meghívódni, és csak utána történik meg az indexelés.

2. Literálisok

Bináris literálisok

A rendszerközeli programozásban gyakran használunk bináris konstansokat. Hajdanán, a C nyelv születésekor sokkal nagyobb volt a rendszerközeli feladatok megoldására írt programok aránya, sőt a C nyelvet eleve ilyen célra találták ki – mégsem lehetett benne bináris, csak oktális hexadecimális konstansokat megadni. Aztán ezt a C++ nyelv örökölte is. Ennek fényében meglepő, hogy a C++14-be behozták a bináris literálisokat – de most már ezek léteznek és használhatóak: 0bxxxx formában kell megadni őket.

C++14
std::cout << 0b1100;    // 12

Számjegyek csoportosítása: „ezres” elválasztók

A számok olvasását megkönnyíti az ezres elválasztók használata. Lokalizációtól (országtól, nyelvtől) függően az 1000 számot 1 000, 1.000, 1,000 vagy 1'000 formában is szokás írni. C++-ban nyilván se a szóköz, se a pont vagy a vessző nem működhetnének (mást jelentenek), de a C++14 óta az aposztrófok használhatók a számjegyek csoportosítására:

C++14
std::cout << 1'234'567.8;

A fordító nem veszi figyelembe az aposztrófok helyzetét, csak a kód olvashatóságát javítják. A nyelvi elem kombinálható az eddig ismert literális módosítókkal:

std::cout << std::boolalpha << (0xFC == 0b1111'1100); // true

std::cout << 0'100; // 64

Literális operátorok a szabványos könyvtárban

A C++11 új eleme volt a literális operátorok megadásának lehetősége, de maga a nyelv nem definiált ilyeneket. A C++14-ben ez változott:

C++14
auto str = "hello vilag"s; // std::string
auto hello = "hello vilag"s + "!"s;

auto dur = 60s; // std::chrono::seconds

auto z = 1i; // std::complex<double>

Ezek persze mind kényelmi eszközök. Aki std::string + char[] és char[] + std::string mellett írt már le véletlenül char[] + char[] alakú kódot is: "hello vilag" + "!", az tudja. A fenti esetben egyébként "hello vilag"s + "!" is működne.

Külön érdekesség, hogy két operator"" s van. De emlékezzünk vissza, a kettő könnyen megkülönböztethető:

  • A "hello vilag"s kódrészlet az operator"" s(char const *, size_t) függvényt hívja, mert sztring van az s előtt.
  • A 60s kifejezés pedig a operator"" s(unsigned long long) függvény meghívását eredményezi. Vagy esetleg az operator"" s(char const *)-ét, aminek a size_t híján szintén más a fejléce, de valószínűleg nem így implementálták ezt az operátort, mert az előző megoldás célravezetőbb.

3. Inline és sablon változók

Inline változók

A C++ nyelvnek van egy szabálya, amit a szabvány és más szövegek is nagyon gyakran hivatkoznak. Ez az ODR (one definition rule), amelyik azt mondja ki, hogy bár deklarálni mindent sokszor szabad, definiálni csak egyszer lehet. A teljes fordítási modell erre épül, emiatt biztosított az, hogy a különálló forrásfájlokból lefordított program működni tud.

A C nyelvtől örökölt szabályba az inline függvények és a sablonok egyszer már bekavartak. Mert mindkét nyelvi elemnél lényegében elvárás az, hogy függvénytörzseket is tegyünk fejlécfájlokba. De amíg minden fordítási egységben pontosan ugyanaz a függvénytörzs, osztálydefiníció stb. látszik, addig ez nem jelent problémát. Lehet hogy így egy függvénynek több lefordított változata lesz, de lévén hogy egyformák, a linker tetszőlegesen választhat közülük.

A C++17 kiterjeszti a többszöri definíciók lehetőségét a globális változókra is. Ha létre szeretnénk hozni egy globális változót, de nem szeretnénk csak amiatt külön, új fordítási egységet is kezdeni, az inline kulcsszóval megjelölhetjük azt:

C++17
#ifndef MYHEADER_H
#define MYHEADER_H

/* ... */

inline int i = 3;

/* ... */
#endif

Ez egyszerűbbé teszi a header-only függvénykönyvtárak fejlesztését is.

Sablon változók

Gyakran szükség van sablon változókra is. Azaz önálló változókra, amik sablonok kell legyen, de emiatt nem akarunk egy külön osztályt létrehozni. Az eredeti javaslatból vett példa alapján:

C++14
#include <iostream>
#include <iomanip>

template<typename T>
constexpr T pi = T(3.1415926535897932385);

template<>
constexpr const char* pi<const char*> = "pi";

int main() {
    std::cout << std::setprecision(10) << pi<double> << std::endl;
    std::cout << std::setprecision(10) << pi<float> << std::endl;
    std::cout << pi<char const*> << std::endl;
}

4. Lambda függvények

Generikus lambdák

C++11-ben a lambda függvényeknél mindig meg kellett adni a paraméterek típusát:

auto lambda = [](int x, int y) { return x+y; };

C++14-ben ezekre a helyekre már az auto kulcsszót is írhatjuk:

C++14
auto lambda = [](auto x, auto y) { return x+y; };

std::cout << lambda(3, 4) << std::endl; // 7
std::cout << lambda(5.6, 7.8) << std::endl;    // 13.4
std::cout << lambda("lo", 3) << std::endl; // lo

Nagy varázslatra itt egyébként a megvalósítás terén nem kell gondolni, csak két meglévő nyelvi elemet kombináltak. Az int x, int y paraméterezésű lambdából operator() (int x, int y) tagfüggvénnyel rendelkező osztályt generál a fordító. A generikus lambdák esetén a szóban forgó függvényhívó operátor egyszerűen sablon lesz:

class Lambda {
  public:
    template <typename XTYPE, typename YTYPE>
      auto operator() (XTYPE x, YTYPE y) { return x+y; }
};

Lambda kifejezésekben létrehozott változók

A mutable lambdák kapcsán sok vita volt a C++11 véglegesítése után. A C++-ban szokatlan ez a viselkedés, hogy valamit lemásolunk magunknak, és utána nem változtathatjuk meg azt. Ezért a C++14-ben bevezettek még egy módot, ahogyan egy lambda funktornak inicializált tagváltozót adhatunk meg:

C++14
int arr[] = { 4, 7, 18, 16, 14, 16, 7, 13, 10, 2, 3};
std::for_each(std::begin(arr), std::end(arr),
    [count = 0] (int i) mutable {
        std::cout << ++count << ". " << i << std::endl;
    }
);

Itt a count kívül nem is létezik, csak a lambdán belül jön létre egy auto count = 0, azaz int típusú változónk. Erre azért is szükség volt, mert különben nem lehetett csak mozgatható, de nem másolható (move-only) objektumokat tenni a lambdákba. Most már lehet:

std::ofstream os("hello.txt");
auto lambda = [file = std::move(os)] (auto data) mutable {
    file << data;
};
lambda("hello vilag");

Lambda *this

C++11-ben a lambda kifejezések capture-specifier részében használhattuk a this kulcsszót: ez azt jelentette, hogy a this pointer eltárolódott a lambda objektumban, a függvény pedig az adott objektum tagfüggvényeként működött.

Ez némileg talán szokatlan viselkedés (1, 2). Megszoktuk, hogy a lambdák esetén az address-of operátor jelzi a referencia szerinti eltárolást: [i] hatására érték szerint, [&i] referencia szerint látja a lambda az i változót. A [this] leírása esetén azonban egy pointer fog csak másolódni érték szerint. A mutatott objektumot referencia szemantikával látjuk! Ez a viselkedés és az élettartam szempontjából is egész más: az eredeti objektum változhat, és figyelnünk kell az élettartamára is, léteznie kell a lambda függvény meghívásakor.

C++17

A C++17-ben lehetséges [*this]-t is írni: ez a lambda kifejezést létrehozó objektum érték szerinti másolását jelenti.

5. std::string_view

Gyakran szükségünk van sztring paraméterű függvényre: például egy megnyitandó fájl nevét kaphatja meg a függvényünk. Ilyenkor mindig fölmerül a kérdés: char const * vagy std::string típusú legyen a paraméter? C++17 óta egyik sem: helyette std::string_view típusú objektumot érdemes létrehozni. Ezzel a típussal, és az előnyeivel egy külön írás foglalkozik.

6. Garantált copy elision optimalizáció

Amikor egy függvényből objektummal térünk vissza, az objektum többször is lemásolódhat:

Obj foo() {
    Obj x;
    return x;   /* 1 */
}

int main() {
    Obj y = foo();  /* 2 */
}

Itt az objektum kétszer is lemásolódhat: egyszer a függvényből visszatéréskor az x változó memóriaterületéről a visszatérési érték helyére, utána pedig a visszatérési érték helyről az y változó memóriaterületére.

A két felesleges másolást meg kellene spórolni. A jobbérték referenciák bevezetésével sokat javult a helyzet, mert ez a két másolás inkább mozgatás lehet. De már előtte is, a nyelv '98-as változatában megengedett volt a fordítók számára az, hogy az objektum áthelyezése céljából hívott másoló konstruktor + destruktor párosakat kioptimalizálják, és ezt szinte mindig meg is tudják tenni.

Egy szokatlan helyzet adódik itt. Amikor a mozgatás (másoló/mozgató konstruktor + destruktor) kioptimalizálódik, akkor tulajdonképp egyik függvény sincsen meghívva. Ennek ellenére a fordító ellenőrzi a meglétüket, és az elérésüket is (privát/publikus), ami elég furcsa annak fényében, hogy nem lesznek meghívva. Gondot jelent az is, hogy ez lényegében csak egy optimalizáció, tehát a programozó sosem lehet biztos abban, hogy megtörténik-e vagy nem. Gyakran az ígéret helyett garanciát szeretnénk erre, meg azt is szeretnénk, ha nem kellene megírni olyan függvényt, ami garantáltan nem fog meghívódni.

Vagyis most azt szeretnénk jelezni, hogy egy osztálynál kötelezővé akarjuk tenni a fordító számára a copy elision optimalizációt. C++17-ben ennek a szintaxisa az alábbi:

C++17
struct NonMoveable {
    NonMoveable() {}
    std::array<int, 1024> arr;

    NonMoveable(NonMoveable &) = delete;    /* nem hívható */
    NonMoveable(NonMoveable &&) = delete;
};

NonMoveable make() {
    return NonMoveable(); /* rögtön a visszatérési érték helyén jön létre */
}

auto nm = make();         /* nem hoz létre újat, megtartja a visszatérési értéket */

7. Visszatérési típus levezetése és decltype(auto)

auto f(): automatikusan meghatározott visszatérési típus

A C++11-ben, ha egy függvény visszatérési értékét az auto kulcsszóval adtuk meg, akkor a fejléc végén, a trailing return type szintaxissal meg kellett adnunk a levezetés módját. Általában ehhez a decltype kulcsszót használtuk, amelyben a visszatérésnél előállított (vagy azzal ekvivalens) kifejezést adtunk meg. Azért volt erre szükség, hogy a függvény deklarációját és definícióját el lehessen választani egymástól. A return utasítás ugyanis csak a függvénytörzsben látszik, a deklarációban még nem:

template <typename T1, typename T2>
  auto add(T1 a, T2 b) -> decltype(a+b);

Ellenben ha például egy inline függvényt írunk, vagy a definíció más okból ismert, a nyíl utáni rész igazából csak kódduplikáció:

template <typename T1, typename T2>
inline auto add(T1 a, T2 b) -> decltype(a+b) {
    return a+b;
}

Ezért a C++14 óta megengedett, a C++11 lambda függvényeihez hasonlóan, hogy ezt elhagyjuk. Feltéve persze, hogy a függvény definíciója, azaz törzse, látszik a függvényhívás helyén. Tehát:

C++14
template <typename T1, typename T2>
inline auto add(T1 a, T2 b) {
    return a+b;
}

A lambdákhoz hasonlóan, ha több return utasítás van, akkor az ott megadott kifejezések mind ugyanarra a típusra kell kiértékelődjenek.

Rekurzív függvényt is megadhatunk így, csak figyelni kell arra, hogy a visszatérési érték típusa már ismert kell legyen a használat helyén:

OK
auto fact(int n) {
    if (n == 0)
        return 1;
    else
        return n*fact(n-1);
}

OK; a visszatérési érték int az első return alapján, és a másodiknál is ez az eredmény.

Nem
OK
auto fact(int n) {
    if (n > 0)
        return n*fact(n-1);
    else
        return 1;
}

Nem jó: az első return-nél nem ismert még a fact() hívás visszatérési értéke, ezért nem hívható meg.

decltype(auto) alakú változódefiníció

Az auto és a decltype kapcsán tudjuk, hogy ezek eltérő levezetési szabályokat alkalmaznak. Az auto általában értéket ad, tehát megszünteti az érték referencia voltát:

int& f();
auto x = f(); // int

Ez általában logikus, mert amikor egy valami x = ...; alakú változódefiníciót látunk, egyből értékre gondolunk, és nem referenciára. Viszont gyakran a pontos típusra lenne szükségünk. A decltype(f()) kódrészlet megadja az f függvény visszatérési típusát, de kódduplikációt jelent, mert a hívást kétszer is le kell írnunk, és ez még egy ilyen rövid kódnál is zavaró:

int& f();
decltype(f()) x = f(); // int&

Ezért C++14-ben bevezették a decltype(auto) jelölést, amellyel változót tudunk úgy létrehozni, hogy nem az auto-s, hanem a decltype-os levezetési szabályokat alkalmazzuk:

C++14
int& f();
decltype(auto) x = f(); // int&

Sajnos elég nehéz érteni a jelölést, két kulcsszót kötöttek össze eléggé esetleges módon – meg kell tanulni, hogy ez ezt jelenti. auto = automatikusan kitalált típus, decltype = decltype szabályokkal, nem az auto-sak. Ugyanez függvények visszatérési értékénél is használható.

auto f(auto)

A C++17 még azt is megengedi, hogy a függvényparaméterek típusai helyett auto-t írjunk. Elsőre nem tűnik annak, de ez egy sablon függvény:

C++17
auto add(auto a, auto b) {
    return a+b;
}

Ezt a C++14 még csak lambda függvényeknél engedte.

8. constexpr függvények

A C++11 constexpr függvények törzse return utasítással kellett kezdődjön. Ez az erős megkötés sok eszköz használatát kizárja: nem lehet vezérlési szerkezeteket használni (if, for), nem lehet változókat definiálni és így tovább.

A C++14-ben lazítottak ezeken a szabályokon. Most már:

  • Lehet változókat létrehozni. Értelemszerűen nem statikusakat, mert egy constexpr függvénynek nem lehet hatása, csak értéke.
  • Lehet elágazásokat: if, switch és ciklusokat: for, while használni.
  • Objektumok tagfüggvényei is hívhatóak, amennyiben az objektumok a constexpr függvényben jöttek létre.

A fentiek alapján C++14-ben ez egy helyes kódrészlet:

C++14
constexpr int fact(int n) {
    int acc = 1;
    for (int i = 2; i < n; ++i)
        acc *= i;
    return acc;
}

C++17-ben lambda függvényeket is lehet definiálni constexpr függvény belsejében.

9. Attribútumok

A fordítás menetét – implementációtól függő módon – hagyományosan #pragma direktívákkal volt szokás jelölni. Van azonban sok általános, minden környezetben egyformán érvényes információ, metaadat a forráskód mellé, amit a fordítóval közölni szeretnénk. Például hogy egy paramétert szándékosan nem használunk (és ezért ne figyelmeztessen), vagy hogy egy függvény nem tér vissza. Ezeket a C++11 óta ún. attribútumokkal lehet jelölni, a forráskódban dupla szögletes zárójel között. Például:

C++11
[[nodiscard]] void* malloc(size_t s);

A fenti deklaráció egy olyan függvényt ad meg, aminek a visszatérési értékét mindenképpen használnunk kell valamire – mert ha nem, az instant memóriaszivárgás lenne.

Szabványosított attribútumok:

[[noreturn]]
(C++11) Annak jelölése, hogy a függvény soha nem tér vissza.
[[carries_dependency]]
(C++11) Többszálú kódok optimalizálását segíti.
[[deprecated("Hibaüzenet")]]
(C++14) Függvénykönyvtárak elavult függvényeinek megjelölésére. A fordító figyelmeztetést ad, ha a függvényt valaki használja.
[[fallthrough]]
(C++17) switch() szerkezetben a hiányzó break szándékos voltát jelzi.
[[nodiscard]]
(C++17) A jelölt függvénynél, vagy a jelölt típus összes használatánál azt jelzi, hogy a visszatérési értéket nem szabad eldobni.
[[maybe_unused]]
(C++17) Szándékosan nem használt változó vagy paraméter.

A jelöléshez használt dupla nyitó szögletes zárójel sajnos más szövegkörnyezetben is előfordulhat. Bár extrém eset, de mégis lehetséges: indexelő operátor belsejében létrehozott lambda kifejezés. Ha ez előjön, egy plusz zárójelezés megoldja a problémát: obj[ ([](){}) ], vagy esetleg az indexelő operátor helyett használhatunk függvényhívó operátort: obj( [](){} ).

10. Szabványos könyvtár

C++14

std::make_unique

A szabványos std::make_shared<> függvénysablon dinamikusan létrehoz egy objektumot, és std::shared_ptr okos pointerbe csomagolja azt. Mivel van std::unique_ptr is, logikusnak tűnik, hogy kell legyen std::make_unique<> is. Ezt azonban a nyelvből kifelejtették. (A szabványt is csak emberek írják, akárhányan is ellenőrzik azt.) A C++14 óta létezik ez a függvény.

C++14

std::cbegin, std::rbegin, std::crbegin

A fentihez hasonlóan, ha a tárolóknak vannak .cbegin() és .rbegin() függvényeik, akkor azt gondolnánk, hogy globális std::cbegin(), std::rbegin() és std::crbegin() is létezik. C++14 óta megvannak ezek a függvények is.

Parallel STL

A C++17-ben szinte az összes STL algoritmusnak van párhuzamosított változata. Például:

C++17
for_each(std::par, first, last, [](auto& x){ process(x); });

std::any

A boost:any-hez, és az előadáson bemutatott Any-hez hasonló osztály, amely futási időben bármilyen típusú értéket, objektumot képes érték szerint lemásolni, eltárolni. Ez képes arra is, hogy kicsi objektumok (pl. egész számok) esetén elkerülje a dinamikus memóriakezelést.

C++17
std::any a;
 
a = "hello"s;                               /* std::string */
std::cout << std::any_cast<std::string>(a); /* hello */
 
a = std::vector<int>{1, 2, 3};              /* std::vector<int> */
std::cout << std::any_cast<std::vector<int>>(a)[0]; /* 1 */
 
try {
    std::cout << std::any_cast<double>(a);  /* nem jó */
} catch (std::bad_any_cast &e) {
    std::cerr << "Nem double van benne!";
}

Hasonló osztály az std::variant, amelyik a boost::variant-hoz hasonlóan egy okos union-t csinál. Ennek sablonparaméterben előre meg kell adni, mik a lehetséges típusok; ha ezek ismertek előre, hatékonyabban működik, mint az any.

11. Elnevezés: „universal reference” vs. „forwarding reference”

A C++11-ben új sablonparaméter levezetési szabályok is lettek. Ezek alapján az alábbi két függvény paraméterezése gyökeresen eltérő dolgot jelent: az egyik függvény paramétere egy jobbérték objektum lehet, a másiké viszont akár jobbérték, akár balérték is:

void egyik(X && x);

template <typename X>
  void masik(X && y);
C++17

Emiatt a masik() függvény paraméterét nem nevezhetjük jobbérték referenciának. Kellett ennek, és az ugyanígy viselkedő auto &&-nek egy név, hogy lehessen beszélni róla. Sokáig a „universal reference” volt használatos (univerzális referencia). Végül a „forwarding reference” név mellett döntöttek (kb. paramétertovábbító referencia). A C++17-ben így nevezik ezt az eszközt.

12. Osztályok sablonparamétereinek levezetése

Függvényeknél kényelmes, hogy a hívás helyén a fordító képes levezetni a sablonparamétereket. Osztályok esetén sajnos ez nincs így. Pedig gyakran egy sablon osztály konstruktorparamétereiből következtetni tudnánk, és következtetni szeretnék az osztály sablonparamétereire. Legegyszerűbb példa erre az std::pair osztály, amelyik két tetszőleges típusú objektumot tárol:

#include <utility>

template <typename F, typename S>
struct Pair {
    F first;
    S second;
    
    Pair(F first, S second)
        : first(std::move(first))
        , second(std::move(second))
    {}
};

template <typename F, typename S>
Pair<F, S> make_pair(F first, S second) {
    return Pair<F, S>{std::move(first), std::move(second)};
}

int main() {
    // error: missing template arguments before ‘(’ token
    auto p1 = Pair(5.6, "string");                          // error
    
    auto p2 = Pair<double, char const*>(5.6, "string");     // ok, de :(
    
    auto p3 = make_pair(5.6, "string");                     // ok, de :(
}

A p1 változót nem tudjuk létrehozni így, fordítási hibához vezet. Nincsenek megadva az osztály sablonparaméterei. A p2-t létrehozó sor műr működik, de kényelmetlen. Ki kell írni a sablonparamétereket. A p3-as sor mutatja a megoldást: egy olyan függvényt kell írni, amely tulajdonképpen semmit nem csinál; csak azért van, mert függvényeknél levezethető a sablonparaméter, osztálykonál nem. Az STL nagyon sok ilyen gyártófüggvényt tartalmaz: make_pair, make_tuple, ...

A gyártófüggvényeket általában kioptimalizálja a fordító, de jobb lenne, ha inkább a konstruktorhívásból levezetné a sablonparamétereket. A C++17 a sablonparaméterek levezetését kiterjeszti a sablon osztályok konstruktoraira is. Tehát az alábbi definíciók már helyesnek számítanak:

std::pair p(1, 2.3);                /* ok, std::pair<int, double> */

double square(double d) {
    return d * d;
}

std::function f(square);            /* ok, std::function<double(double)> */

Ehhez vezettek be C++17-től egy új nyelvi elemet, amellyel megadhatjuk, hogy a konstruktorparaméterekből hogyan következtetünk az osztály sablonparamétereire. Ezek az ún. levezetési útmutatók (class template argument deduction guide, CTAD). Ilyeneket a programozó is definiálhat. A szintaxis a következő:

#include <utility>

template <typename F, typename S>
struct Pair {
    F first;
    S second;
    
    Pair(F first, S second)
        : first(std::move(first))
        , second(std::move(second))
    {}
};

template <typename F, typename S>
Pair(F first, S second) -> Pair<F, S>;  // C++17

int main() {
    auto p = Pair(5.6, "string");
}

Itt a jelölt, középső rész a lényeg. Ennek első fele egy függvénydeklarációhoz hasonló rész: Pair(F first, S second), mintha a konstruktort hívnánk meg. Ezt egy nyíl követi. A végén pedig egy típus található: Pair<F, S>, ilyen sablonparaméterekkel kell példányosítani az osztályt.

Néhány levezetési szabályt a fordító maga is előállít; egyparaméteres konstruktoroknál tudjuk ezt kihasználni. Például ha egy tárolót szeretnénk írni, amely inicializáló listát is kaphat a konstruktorában, akkor az inicializáló elemek típusából is levezethető a sablonparaméter:

template <typename T>
class MyVector {
    public:
        MyVector(std::initializer_list<T> l) {}
        /* ... */
};

int main() {
    MyVector v = { 1, 2, 3 };       // ok, MyVector<int>
}

Ez olyan, mintha megírtuk volna az alábbi szabályt:

template <typename T>
MyVector(std::initializer_list<T> l) -> MyVector<T>;

13. Sablon metaprogramozás eszközök

if constexpr

Az std::enable_if (SFINAE) és a tag dispatching nagyon hasznos technikák. Sajnos viszonylag körülményes használni őket, és az ezekkel megírt kód nagyon nehezen kezelhető. Egyik legfájóbb probléma a fenti eszközöknél a kód lokalitásának romlása. Általában a kódunkat több függvényre kell darabolnunk, miközben gyakran csak valami ehhez hasonlót szeretnénk írni:

template <typename ITER, typename VALUE>
bool my_search(ITER begin, ITER end, VALUE what) {
    if (/* ITER egy véletlen elérést biztosító iterátor */) {
        /* bináris keresés, ami ITER+int, ITER-ITER kifejezéseket is használ */
    }
    else {
        /* lineáris keresés, ami csak ITER++ kifejezéseket használ */
    }
}

Tehát fordítási időben választani szeretnénk két vagy több kódrészlet közül, miközben tudjuk, hogy bizonyos esetekben a nem kiválasztott, figyelmen kívül hagyandó kódrészlet szemantikailag hibás is. A fenti esetben például, ha láncolt lista iterátoraival hívjuk meg a függvényt, akkor pont ez a helyzet. A bináris kódrészlet, bár szintaktikailag helyes (nincsenek hiányzó zárójelek vagy ilyesmi), szemantikailag helytelen (hiányoznak a megfelelő operátorok).

Mivel úgyis tudjuk, hogy az elágazás feltétele fordítási időben kiértékelhető (hiszen az az ITER típusától függ, nem az értékétől), valahogy jelezhetnénk azt is a fordítónak, hogy a hamis, nem lefutó ágat meg se próbálja lefordítani. Erre való az if constexpr nyelvi eszköz: ez egy olyan elágazást ad meg, amelynél a feltétel fordítási idejű konstans kell legyen, és a végre nem hajtott ágat a fordító teljesen figyelmen kívül hagyja. Így abban lehet akár olyan kifejezés is, amely nem kiértékelhető, vagy az adott körülmények között szemantikailag hibás kód.

Így például a véletlen elérésű tárolók esetén bináris keresést használó függvény:

C++17
#include <iostream>
#include <type_traits>
#include <iterator>
#include <list>
#include <vector>

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

    if constexpr (std::is_base_of<std::random_access_iterator_tag, iter_categ_tag>::value) {
        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;
    }
    else {
        std::cout << "Linearis kereses" << std::endl;
        for (ITER it = begin; it != end; ++it)
            if (*it == what)
                return true;
        return false;
    }
}

int main() {
    std::vector<int> v = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    std::list<int> l = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    
    std::cout << my_search(std::begin(v), std::end(v), 3) << std::endl;
    std::cout << my_search(std::begin(l), std::end(l), 3) << std::endl;
}

(Az is_base_of<B, D> nem csak akkor ad igazat, ha D leszármazottja B-nek, hanem akkor is, ha D = B. Bár egy osztály nem leszármazottja saját magának, ez mégis helyes válasz. Az is_base_of igazából a „B egy fajta D” kapcsolat ellenőrzésére van kitalálva, csak a neve félrevezető.)

Egy egyszerű move_if_noexcept megvalósítás:

C++17
template <typename T>
decltype(auto) my_move_if_noexcept(T && data) {
    using TYPE = typename std::remove_reference<T>::type;
    if constexpr (std::is_nothrow_move_constructible_v<TYPE>)
        return static_cast<TYPE &&>(data);
    else
        return static_cast<TYPE &>(data);
}

Itt érdemes megfigyelni, hogy a függvény decltype(auto) visszatérési értékű. A fordító azonban az if constexpr miatt mindig csak az egyik return utasítást veszi figyelembe, ezért nem gond, hogy különálló típusok (T && és T &) vannak a visszatéréseknél.

Jó példa a mindent kiíró függvény függvény is:

C++17
#include <iostream>

template <typename HEAD, typename... TAIL>
void my_print(HEAD const & head, TAIL const & ... tail) {
    std::cout << head;
    if constexpr (sizeof...(tail) > 0) {
        std::cout << ", ";
        my_print(tail...);
    }
    else {
        std::cout << std::endl;
    }
}

int main() {
    my_print(3, 5.6, "hello", 'x');
}

Ha már nincs több paraméter, tehát a tail üres, az elágazásban lévő kódot meg sem próbálja lefordítani a fordító. Nem is lehetne, mert a (head, tail...) paraméterezésű függvény legalább egy paramétert várna, nulla paraméterrel pedig a hívás hibás lenne.

std::integer_sequence

A C++14-es std::integer_sequence osztály sablon metaprogramokban adattárolásra használható. Hasonló egy tömbhöz, megadhatjuk az elemek típusát is, és magukat az elemeket. Például std::integer_sequence<int, 3, 4, 5> egy háromelemű int sorozat, std::integer_sequence<bool, false, true> pedig egy kételemű bool sorozat.

Gyakran szükségünk van a természetes számok sorozatára, például indexelésekhez. Ilyeneket az std::make_index_sequence hoz létre. Például az std::make_index_sequence<3> típus egyenértékű azzal, mintha std::integer_sequence<size_t, 0, 1, 2>-t írtunk volna, csak épp paraméterezhetően állítható elő.

Mire jók ezek? Például egy std::tuple „kicsomagolására”. Az std::get<N>(t) függvényhívással kivehetjük az N-edik értéket a t tuple-ből – ahol N természetesen fordítási idejű konstans kell legyen. Sablon metaprogramozással ez megoldható. Az alábbi kódban a call_f függvény valamelyik f nevű függvényt fogja meghívni attól függően, hogy a paraméterként adott tuple milyen értékeket tartalmazott.

#include <iostream>
#include <tuple>
#include <utility>

void f(int i) {
    std::cout << "(int) " << i << std::endl;
}

void f(double d, char c) {
    std::cout << "(double, char) " << d << " " << c << std::endl;
}

template <typename... ARGS, size_t... IDX>
void call_f_helper(std::tuple<ARGS...> t, std::integer_sequence<size_t, IDX...>) {
    f(std::get<IDX>(t)...);
}

template <typename... ARGS>
void call_f(std::tuple<ARGS...> t) {
    call_f_helper(t, std::make_index_sequence<sizeof...(ARGS)>{});
}

int main() {
    call_f(std::make_tuple(5));
    call_f(std::make_tuple(5.3, 'A'));
}

A kód működése a következő. Először meghívódik a call_f függvény, amely átveszi paraméterként a tuple-t. Ez már a tuple típusából kitalálja a sablonparaméter levezetésekor, hogy milyen típusú adatokat tartalmaz. Mivel itt ezek már látszanak, a számuk is kiderül; sizeof...(ARGS) megadja a tuple-ben lévő adatok számát. Például ha a tuple 3 elemű, ezt a függvényhívást kellene csinálni:

f(std::get<0>(t), std::get<1>(t), std::get<2>(t));

Ezért a call_f függvény létrehoz egy std::integer_sequence-t, amelyben a get paraméterei, azaz az indexek vannak: std::integer_sequence<size_t, 0, 1, 2>. Ezután történik egy újabb függvényhívás. A call_f_helper függvény csak az újabb sablonparaméter-levezetés miatt szükséges. Ennek második paramétere az std::integer_sequence, amelynek IDX... sablonparamétere miatt az abból hívott call_f_helper IDX nevű variadikus sablonparamétere a számsort fogja tartalmazni: 0, 1, 2. Ezek a számok helyettesítődnek be egyesével az std::get sablonparamétereként. (Mivel a három pont a get hívása után van, nem pedig az f hívása után, ezért f(p1, p2, p3) lesz a kód értelme, nem pedig f(p1), f(p2), f(p3). Értelemszerűen az előbbit szeretnénk.) A kifejtés után pedig a fordító megkeresi a megfelelő f függvényt, és meghívja, ha van.

Ehhez hasonlóan valósították meg az std::apply függvényt. Az lényegében ezt csinálja: std::apply(f, t) meghívja az f() függvényt a t tuple-be csomagolt paraméterekkel.

Folding expressions

A variadikus paraméterlistát általában rekurzív függvényekkel dolgozzuk fel. Például ha tetszőlegesen sok szám összegére van szükségünk:

auto sum() {
    return 0;
}

template<typename T>
auto sum(T t) {
    return t;
}

template<typename T, typename... Ts>
auto sum(T t, Ts... ts) {
    return t + sum(ts...);
}

Igazából itt csak ennyit szeretnénk mondani a fordítónak: „vedd az összes paramétert, írd le közéjük a + operátort, és az így kapott kifejezést kell kiértékelni”. C++17-ben ezt már könnyen megtehetjük:

template<typename... T>
auto sum(T... t) {
    return (0 + ... + t);
}

int main() {
    std::cout << sum(1, 2, 3);
}
6

A zárójelezett kifejezés neve: folding expression. Fontos, hogy a kifejezésnek része a kerek zárójel is, amelyet kötelező kitenni. A fenti példában, az 1,2,3 paraméterek hatására a return utasításban (((0+1)+2)+3) lesz a kiértékelt kifejezés. Ez az ún. binary left fold; a 0 megadja a kezdőértéket, illetve a 0 és a paramétercsomag egymáshoz képesti helye a zárójelezést.

(t + ... + 0) esetén a kiértékelt kifejezés (1+(2+(3+0))) lenne (binary right fold), bár az összeadás kommutativitása miatt ugyanazt az összeget kapnánk. Nem úgy, mint a következő példában. Itt sejtjük is, hogy nem írhatnánk fordított sorrendben a cout-ot és a t-t:

template <typename... T>
void print_all(T... t) {
    (std::cout << ... << t);
}

int main() {
    print_all(1, ' ', 2.3);
}

Egyes operátoroknál a kezdőérték elhagyható, ilyenkor a nulla tagból álló kifejezésnek előre adott értéke van. De csak három ilyen van összesen: && és || esetén az értékek true és false (a Boole-algebra egységeleme és nulleleme), illetve vessző operátor esetén void. Tehát pl. And metafüggvényt nagyon könnyű készíteni:

template <bool... B>
class And : public std::bool_constant<(B && ...)> {};

int main() {
    std::cout << And<true, false, true>::value;
    std::cout << And<true, true>::value;
    std::cout << And<>::value;
}

A folding expression részeként bonyolultabb kifejezések is szerepelhetnek, amelyekbe a szokásos módon, egyesével behelyettesítődnek az argumentumok. Itt a vessző operátor segítségével épül a kifejezés:

template <typename T, typename... ARGS>
void push_back_vec(std::vector<T>& v, ARGS&&... args)
{
    (v.push_back(std::forward<ARGS>(args)), ...);
}

int main() {
    std::vector<double> v;
    push_back_vec(v, 1, 2.3);
}