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.
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.
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.
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:
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:
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 azoperator"" s(char const *, size_t)
függvényt hívja, mert sztring van azs
előtt. - A
60s
kifejezés pedig aoperator"" s(unsigned long long)
függvény meghívását eredményezi. Vagy esetleg azoperator"" s(char const *)
-ét, aminek asize_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.
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:
#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:
#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;
}
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:
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:
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.
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.
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.
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:
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 */
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:
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:
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.
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:
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:
auto add(auto a, auto b) {
return a+b;
}
Ezt a C++14 még csak lambda függvényeknél engedte.
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:
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.
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:
[[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( [](){} )
.
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.
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:
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.
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
.
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);
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.
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>;
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:
#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:
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:
#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);
}
- Herb Sutter: Lambda Correctness and Usability Issues.
- Herb Sutter: Capture Quirk in C++14.
- Faisal Vali et al.: Wording for Constexpr Lambda.
- Herb Sutter: GotW #102: Exception-Safe Function Calls – miért nincs
std::make_unique()
? - Herb Sutter: Trip Report: ISO C++ Spring 2013 Meeting.
- Scott Meyers: Universal References in C++11 – az „univerzális referencia” kifejezést bevezető cikk.
- Herb Sutter et al.: Forwarding References – a C++17-ben bevezetett „forwarding reference” elnevezésről.
- Jared Hoberock: The Parallelism TS Should be Standardized.
- Mike Spertus et al.: Template argument deduction for class templates.
- Arne Mertz Modern C++ Features – Class Template Argument Deduction.
- Beman Dawes et al.: Any Library Proposal (Revision 3).
- Jens Maurer: constexpr if: A slightly different syntax.
- Richard Smith: Guaranteed copy elision through simplified value categories.
- Jonas Devlieghere: Guaranteed Copy Elision.
- Hal Finkel and Richard Smith: Inline Variables.
- Gabriel Dos Reis: Variable Templates.
- Gabriel Dos Reis: Refining Expression Evaluation Order for Idiomatic C++.
- Peter Sommerlad: apply() call a function with arguments from a tuple.
- Arne Mertz: Modern C++ Features – Attributes.
- Andrew Sutton, Richard Smith: Folding expressions.
- Changes between C++14 and C++17 DIS.