Tároló osztályok
Czirkos Zoltán, Pohl László · 2024.10.12.
Alacsony szintű memóriakezelés. New operátorok, tároló osztályok, iterátorok. Tömbök és referenciák. Inicializáló listák, az auto és a decltype kulcsszavak. Az std::begin és az std::end függvények. Paraméterátadási szabályok, tömbök referenciái.
A C++ nyelv nem csak nyelvtani szabályokból áll. A nyelv része egy nagy
osztálygyűjtemény is, ide értve a mindenféle stream
osztályokat,
az STL tárolóit és algoritmusait, és sok egyebet is.
Ezeket az osztályokat a C++ nyelv szabványosítási bizottsága tervezi,
akik a nyelv alapjait, nyelvtanát is kitalálják. Sokat lehet tanulni a
nyelvről ezen osztályok belső működését tanulmányozva. Ez a jegyzet
az std::vector
működése kapcsán mutatja be a haladó memóriakezelést,
és olyan C++11 nyelvi elemeket, amelyekkel a tárolók használata leegyszerűsíthető.
Tekintsük az alábbi osztályt!
class Noisy {
public:
Noisy(): i_{0} {
std::cout << "Noisy default ctor\n"; count++;
}
explicit Noisy(int i) : i_{i} {
std::cout << "Noisy{" << i << "} ctor\n"; count++;
}
Noisy(Noisy const &o) : i_{o.i_} {
std::cout << "Noisy copy ctor " << i_ << "\n"; count++;
}
Noisy& operator=(Noisy const &o) {
i_ = o.i_; std::cout << "Noisy copy assignment " << i_ << "\n";
return *this;
}
~Noisy() {
std::cout << "Noisy dtor " << i_ << "\n"; count--;
}
static void report() {
std::cout << count << " instance(s).\n";
}
private:
int i_;
static int count;
};
int Noisy::count = 0;
Ez mindenért szól, ami az objektumaival történik. Az összes konstruktor és a destruktor jelzi a létrejött objektumokat, jelzi az értékadást is. Bármikor lekérdezhető, hogy hány példány van belőle. Például:
int main() {
Noisy::report();
{
Noisy y;
Noisy::report();
}
Noisy::report();
}
0 instance(s). Noisy default ctor 1 instance(s). Noisy dtor 0 0 instance(s).
Ebben a kódban (noisy1.cpp) a külön utasításblokk belsejében létrejön, és meg is szűnik egy példány. Ezért a blokkon belül lekérdezve 1 példányt látunk, utána pedig már csak nullát, és látszik a konstruktor és a destruktor is, amiket a fordító automatikusan meghívott.
Ezt az osztályt fogjuk használni az std::vector
megismeréséhez.
Példányosítsuk az std::vector
tárolót a kiabálós osztályunkkal, és
figyeljük meg, mi történik!
int main() {
std::vector<Noisy> v(3);
std::cout << "=====\n";
}
Noisy default ctor Noisy default ctor Noisy default ctor ===== Noisy dtor 0 Noisy dtor 0 Noisy dtor 0
A v(3)
konstruktorhívás hatására létrejön egy három elemű vektor. Ez
három konstruktorhívást jelent, és természetesen három destruktort is látunk, amikor a vektor
megszűnik.
Tegyünk be egy új elemet a vektorba! A push_back()
tagfüggvény feladata, hogy a vektor végéhez egy új
elemet fűzzön, előtte pedig szükség szerint a vektort át is méretezze.
v.push_back(Noisy{5});
std::cout << "=====\n";
Noisy{5} ctor 1 Noisy copy ctor 5 2 Noisy copy ctor 0 Noisy copy ctor 0 3 Noisy copy ctor 0 Noisy dtor 0 Noisy dtor 0 4 Noisy dtor 0 Noisy dtor 5 5 =====
A fenti kódsor hatására a következő dolgok történnek:
- A
push_back()
hívás előtt létrejön egy ideiglenes Noisy objektum, azint
paraméterű konstruktorával. - A vektor rájön, hogy eddig 3 elemnek foglalt helyet, de most egy negyediket is el kell tárolni. Ezért átméretezésbe kezd. Foglal egy új memóriaterületet. Az új memóriaterületre bemásolja a paraméterként kapott Noisy-t.
- Átmásolja az eddigi 3 Noisy objektumot is. (Vajon miért az új adatot másolja a vektor előbb, és csak utána a régieket? Erről egy külön írásban lehet olvasni: push_back, 1. rész.)
- A régi memóriaterületet fel lehet szabadítani, ezért a lemásolt három Noisy eredeti példánya megszűnik.
- A függvényből visszatérés után az ideiglenes objektum is megszűnhet.
Az STL tervezésekor mindvégig figyeltek arra, hogy hatékonyak maradhassanak
az osztályok. A push_back()
függvény is okosítva van, megoldja, hogy
ne kelljen mindig átméretezni és másolni a tárolót. (Rakjuk csak be ciklusba a
beszúrást, látszani fog!) Azonban néha nem árt neki segíteni. Ha tudjuk előre, hogy
hány elem lesz, akkor a reserve()
függvénnyel lefoglalhatjuk előre
a helyüket. Így az újrafoglalás és a másolások is elmaradhatnak:
std::vector<Noisy> v2;
v2.reserve(100);
v2.push_back(Noisy{6});
v2.push_back(Noisy{7});
Noisy::report();
std::cout << "=====\n";
Noisy{6} ctor Noisy copy ctor 6 Noisy dtor 6 Noisy{7} ctor Noisy copy ctor 7 Noisy dtor 7 2 instance(s). =====
A reserve(100)
hívással szóltunk a vektornak, hogy sok
push_back()
lesz, ezért előre le kellene foglalni a helyett 100 elem
számára. Ezt meg is teszi. A következő két push_back()
-nél
átméretezést nem látunk, csak az ideiglenes Noisy-k létrehozását, bemásolását és
megszűntét (noisy2.cpp).
Vegyük észre: a reserve()
híváskor a vektor a 100 objektumnak való
helyet előre le tudta foglalni. Hogy látszik ez a kimenetből? Nem lehetett
memóriafoglalás a push_back()
-ekben, mert ha lettek volna, akkor új memóriaterületet kapott
volna a vektor, ahova a meglévő Noisy objektumokat át kellett volna másolnia. A másolást
viszont nem látjuk, mindig csak az új elem létrehozását.
Hogyan lehetséges ez? Ha ilyet tartalmazna a reserve()
belseje...
template <>
void SomeVector<Noisy>::reserve(size_t how_many) {
/* ... */
pData = new Noisy[how_many]; // NEM így működik
/* ... */
}
... akkor a reserve(100)
hívásnál a 100 darab Noisy objektum az alapértelmezett konstruktorával jönne
létre, amit látnunk kellene – a Noisy szólna. Szemfüleseknek feltűnhetett, hogy ez a kérdés már a push_back()
kapcsán
is felmerülhet. Ott meg lehetett figyelni egy másik érdekességet is: nevezetesen azt, hogy a vektor osztály nem használta az
objektumok értékadó operátorát (copy assignment operator). Bár az átméretezést első körben így képzelhetnénk el:
template <>
void SomeVector<Noisy>::push_back(Noisy const &what) {
/* ... */
Noisy *newPData = new Noisy[size + 1]; // NEM így működik
for (size_t i = 0; i != size; ++i)
newPData[i] = pData[i]; // NEM így működik
/* ... */
}
De sem az alapértelmezett konstruktor, sem az értékadó operátor meghívódását nem látjuk.
Menjünk egy kicsit tovább! A vektor C++11 óta létező emplace_back()
tagfüggvénye új elemet hoz létre a tároló végén. De nem úgy, hogy egy meglévő objektumot
másol a végére, mint a push_back()
, hanem az objektum ott jön létre először, a vektor
belsejében. Az emplace_back()
a saját paramétereit közvetlenül a Noisy
konstruktorának adja át:
std::vector<Noisy> v2;
v2.reserve(100);
v2.emplace_back(8); // Noisy(8) jön létre a vektor végén
v2.emplace_back(9);
Noisy::report();
std::cout << "=====\n";
Noisy{8} ctor Noisy{9} ctor 2 instance(s). =====
Mi kell ehhez? Az, hogy a memóriaterület foglalását és az objektumok létrehozását külön-külön tudjuk elvégezni. Biztosan nem az
eddig tanult new Noisy[]
kifejezéssel, mert az mindkét műveletet elvégzné. Pedig ha hatékony
push_back()
-et és reserve()
függvényt szeretnénk – vagy egyáltalán azt, hogy létezhessen
reserve()
–, akkor a memóriafoglalást és az inicializálást szét kell választani egymástól.
class Noisy {
public:
Noisy(): i_{0} {
std::cout << "Noisy default ctor\n"; count++; }
explicit Noisy(int i) : i_{i} {
std::cout << "Noisy{" << i << "} ctor\n"; count++; }
Noisy(Noisy const &o) : i_{o.i_} {
std::cout << "Noisy copy ctor " <<i_<<"\n"; count++; }
Noisy& operator=(Noisy const &o) {
i_ = o.i_; std::cout << "Noisy = " << i_ << "\n";
return *this; }
~Noisy() {
std::cout << "Noisy dtor " << i_ << "\n"; count--; }
static void report() {
std::cout << count << " instance(s).\n"; }
private:
int i_;
inline static int count = 0; // C++17
};
/* int Noisy::count = 0; */ // < C++17
A C++ dinamikus memóriakezelése sokkal fejlettebb, mint a C-ben volt. Ott be kellett érnünk egy
malloc()
–free()
párossal, itt viszont saját memóriakezelést is definiálhatunk. Ez leginkább a globális
operator new(size_t)
függvényen, és annak overload társain keresztül valósul meg.
Memóriafoglalás: a globális operator new(size_t)
függvény
A C++ nyelvi szinten biztosít egy dinamikus memóriát foglaló függvényt. Ennek paraméterezése és működése megegyezik a
malloc()
-éval: minden hívása egy új memóriaterületet ad a kupacról (heap), vagy C++-os szóhasználattal élve, a „free
store”-ból. A függvény egy implicit deklarált függvény, azaz ennek deklarációját minden forrásfájl elejére odaképzeli a fordító,
akkor is, ha nem írjuk ki az #include <new>
-t:
/* globális függvények */
void * operator new(size_t);
void operator delete(void *) noexcept;
A delete
-nél a noexcept
azt jelenti,
hogy nem dobhat kivételt – erről majd később lesz szó.
A C-től ez abban különbözik, hogy a nullptr
visszatérési érték helyett std::bad_alloc
típusú hibát
kapunk, ha sikertelen a foglalás. Lényegében azonban ugyanúgy kell használni ezt a két függvényt, mint C-ben. Mivel az egyes
osztályok saját new
operátorokat definiálhatnak, ennél a függvénynél ki szoktuk írni a globális névteret jelző
::
jelölést, hogy biztosan a globális függvényt értse alatta a fordító:
malloc()
-szerűnew
és delete
void * memory = ::operator new(sizeof(int) * 45); // int[45]
int * my_ints = static_cast<int *>(memory);
my_ints[19] = 75;
::operator delete(memory);
Ez a függvény csakis és kizárólag memóriafoglalást végez, inicializálást nem. Tehát ez lesz az okos vektor megvalósításának egyik eleme.
Operator new, akár több paraméterrel
A fenti két függvényt át is definiálhatjuk. Például
megírhatjuk őket úgy, hogy a könyvtári, C-s malloc()
és
free()
függvényeket hívják, esetleg úgy, hogy tartsák nyilván a
lefoglalt területek darabszámát.
Megírhatjuk őket úgy is, hogy további paramétereket
vegyenek át, és azokat akár az explicit hívásukkor, akár a
szokásos new T
alakú hívásokban használni lehet majd.
A paraméterezett new
operátorok első paramétere mindig
kötelezően a méret, a többi paraméter viszont már az, amit a zárójelek között megadunk:
void *operator new(size_t size);
void *operator new(size_t size, int a, char b);
int *a1 = new int; /* ::operator new(sizeof(int)) */
int *a2 = new (8, 'x') int; /* ::operator new(sizeof(int), 8, 'x') */
A new
operátort általában azért szokták paraméterezni, hogy
egyszerre több, különféle, egymástól független memóriakezelést is lehessen
használni. Erről a laboron még lesz szó.
Konstruktor hívása: a „placement new”
A new
operátornak van egy olyan változata is, amely
memóriafoglalást nem végez, csak egy objektum konstruktorát hívja meg.
Ezt placement new-nak nevezzük, és onnan lehet megismerni, hogy a
new
kulcsszó után zárójelben egy void *
pointer van megadva.
Ez a pointer mutat arra a lefoglalt memóriaterületre, ahol az objektumot létre
kell hozni. A hívás előtt ott memóriaszemét van; éppen az objektum
konstruktorának a feladata az, hogy a memóriaterületet értelmes adatokkal töltse
fel. Valahogy így:
void *noisy_mem = malloc(sizeof(Noisy)); /* memóriafoglalás */
Noisy *noisy_ptr = new (noisy_mem) Noisy{5}; /* konstruktorhívás */
Az első sorban egy szokásos memóriafoglalás történik, akár írhattuk volna
C++-osan, ::operator new
-ként is. A placement new
a
második sorban van. Ennek odaadjuk a void *
típusú pointert, és
meghívja ott a Noisy{5}
konstruktort. Vagyis konstruktorban a
this
pointer egyenlő lesz a void *
pointerrel. Az egész kifejezés értéke egy
Noisy *
típusú pointer, és ehhez nincsen szükség konverzióra: logikus
is, hiszen a memóriaterületet a konstruktor kitöltötte úgy, hogy ott már egy Noisy
objektumként értelmezhető bájtsorozat van.
Erre a különleges szintaxisra azért van szükség, mert a konstruktorokat
tagfüggvényként nem lehet meghívni. Nem így a destruktoroknál: a destruktor, bár
sok helyen automatikusan hívódik, egy teljesen szokványos függvény, amit
tagfüggvény szintaxissal meg is lehet hívni. Mivel a placement new
használatával azt
jeleztük a fordítónak, hogy mi magunk szeretnénk megmondani, mikor és milyen
címen hívódjon a konstruktor, ezért az így inicializált objektumok
destruktorának hívásáért mi felelünk. Valahogy így:
noisy_ptr->~Noisy();
free(noisy_mem);
Az első sor a destruktor hívása. A furcsa ->~
szintaxis csak
azért van, mert a meghívott függvény neve ~Noisy()
. Egyébként az ott
egy sima nyíl operátor, amelyen keresztül a mutatott objektum tagfüggvénye
hívódik. Természetesen azt figyelembe véve, hogy virtuális-e a destruktor,
hiszen egy tagfüggvény hívásáról van szó.
Eltekintve a patologikus esettől, amikor kivétel keletkezik az objektum
létrehozása közben, egy szokványos new T{xyz}
kifejezés
lényegében ezzel egyenértékű:
new T{xyz}
működése
void *mem = ::operator new(sizeof(T)); /* foglalás */
obj = new (mem) T{xyz}; /* konstruktor */
A delete ptr
pedig nagyjából ezzel:
delete ptr
működése
obj->~T(); /* destruktor */
::operator delete(mem); /* felszabadítás */
Mindezek egyébként már a C++98-ban is megvoltak.
Hányféle placement new van?
A new (memptr) T
kifejezés működése mögött az van, hogy a fordító a szokásos módon előbb meghívja az
::operator new(sizeof(T), memptr)
függvényt. Tehát első paraméter a méret, a zárójelben megadottakból lesz a többi
paraméter. Vagyis ilyenkor is van egy függvényhívás – csak a hívott ::operator new(size_t, void*)
függvény
direkt úgy van megírva, hogy ne csináljon semmit:
void *operator new(size_t s, void *ptr) noexcept {
return ptr;
}
Emiatt történik csak a new (void_memptr) T
kifejezésben, memóriafoglalás nélkül.
A paraméterezhető new
operátorok tehát úgy vannak kitalálva, hogy egyszerre két problémát is megoldjanak: 1)
adott helyen történő objektumlétrehozás, 2) parametrizált memóriakezelés. Így csak egy új nyelvi elemet kellett bevezetni,
egyféle placement new
létezik, amely azonban többféle feladat megoldására alkalmas, csak megfelelő
overloadok kellenek hozzá.
Ha már az elnevezésekről van szó: operator new-nak szokás nevezni a memóriafoglaló függvényeket, new operator-nak pedig azt az operátort, amely ezeket és a konstruktorokat meghívja. Innen szép nyerni.
Összefoglaló táblázat
Az alábbi táblázat áttekintést ad a használható műveletekről. Sajnos a szintaxis sok helyen nem túl intuitív – meg kell tanulni, melyik mit jelent, és figyelmesen kezelni ezeket a kódban.
Művelet | Visszafelé | Magyarázat |
---|---|---|
p = new T() |
delete p |
Egy objektumnak foglal helyet, és hívja a konstruktorát |
p = new T[100] |
delete[] p |
Tömbnek foglal helyet, mindegyik elem konstruktorát hívja |
p = malloc(sizeof(T)) |
free(p) |
Csak memóriafoglalás, konstruktor nélkül |
p = operator new(sizeof(T)) |
operator delete(p) |
Csak memóriafoglalás, konstruktor nélkül |
new (p) T() |
p->~T() |
Csak konstruktor, memóriafoglalás nélkül |
Fontos, hogy egy adott műveletet és annak párját mindig együtt kell használni; soha nem szabad ezeket keverni. Tehát pl.
new T[100]
által adott pointeren nem használható delete
operátor, malloc()
által foglalt
terület nem szabadítható fel operator delete()
függvénnyel, és így tovább.
C++: saját din. mem. kezelés definiálható a globális
operator
new(size_t)
függvényen, és annak overload társain keresztül.
Memóriafoglalás: a globális operator new(size_t)
függvény
Paraméterezése és működése megegyezik a malloc()
-éval. Implicit deklarált függvény: az #include <new>
elhagyható.
/* globális függvények */
void * operator new(size_t);
void operator delete(void *) noexcept;
Az std::vector
azt ígéri, hogy egy olyan tömbként használható
objektumot ad, amely hatékonyan kezeli a tömb végéhez új elem hozzáfűzését is.
Az ilyet nyújtózkodó tömbnek nevezik.
Különválasztva a memóriaterület foglalását és az objektumok inicializálását, a megvalósítás már egyszerű. Három tagváltozóra van szükségünk:
- Egy pointerre, amely a lefoglalt memóriaterület elejére mutat.
- Egy egészre, amely az elemek számát mutatja. Ez a méret.
- Még egy egészre, amely a foglalt terület nagyságát mutatja. Ez a kapacitás.
template <typename T>
class MyVector {
private:
T *pData_;
size_t size_;
size_t capacity_;
};
A lefoglalt terület elején, 0
-tól size-1
indexig
az inicializált objektumok vannak, efölött a kapacitásig pedig memóriaszemét. Ide
kerülhetnek a push_back()
-elt elemek, egészen addig, amíg a
területet ki nem töltik. Ha az megtörtént, akkor kell majd újrafoglalni és
másolni. Értelemszerűen a size ≤ capacity
kifejezésnek minden
pillanatban igaznak kell lennie.
A konstruktor:
template <typename T>
MyVector<T>::MyVector(size_t size) {
size_ = size;
capacity_ = size;
pData_ = static_cast<T*>(::operator new(sizeof(T) * capacity_)); // 1
for (size_t i = 0; i != size_; ++i)
new (&pData_[i]) T(); // 2
}
Ez egyelőre nem foglal nagyobb helyet, mint amennyi feltétlenül szükséges, ezért
a méretet és a kapacitást is egyformára állítja. Az 1-essel jelölt sorban csak a
memóriafoglalás történik meg, míg a 2-essel jelöltben a lefoglalt területen
inicializálódnak az objektumok. A pointert az 1-es sor azért konvertálja T*
típusúvá, hogy a 2-es sorban kényelmes legyen a címaritmetika, de ezt természetesen
másképp is meg lehetne oldani.
A reserve()
függvény működése jól mutatja az egész megvalósítás lényegét:
template <typename T>
void MyVector<T>::reserve(size_t newcapacity) {
if (newcapacity < size_)
return;
T *newPData = static_cast<T*>(::operator new(sizeof(T) * newcapacity)); // 1
for (size_t i = 0; i != size_; ++i)
new (&newPData[i]) T(pData_[i]); // 2
for (size_t i = 0; i != size_; ++i)
pData_[i].~T(); // 3
::operator delete(pData_); // 4
capacity_ = newcapacity;
pData_ = newPData;
}
Ez először megnézi, hogy nem kisebb kapacitást adott-e meg a hívó, mint ahány elem
van. Ha kisebb, akkor a kérést figyelmen kívül hagyja. (Ezt megteheti, mert a vektor
kapacitása nem kívülről érzékelhető tulajdonság. A hívó által tárolt adatok nem fognak
elveszni.) Az 1-essel jelölt sor foglalja le az új memóriaterületet. A 2-essel jelölt
sor végzi a meglévő objektumok másolását. A konstruktorok a pData_[i]
kifejezés által a régi objektumokat kapják, így a fordító a másoló konstruktort
fogja kiválasztani. Épp ez kell! Itt nem használhatnánk értékadó operátort, mert
az csak inicializált objektumon működik, a newPData[i]
helyen viszont egyelőre csak memóriaszemét van.
Miután a másolás megtörtént, a régi objektumok felszabadíthatóak. Ezek destruktorát
is külön meg kell hívni a 3-assal jelölt sorban, mert a 4-essel jelölt delete
csak a memóriaterület felszabadításáért felel, a destruktorok hívásáért nem. A sablont
példányosító osztály neve T
, ezért a destruktorát ~T
-ként
tudjuk megnevezni – sablon kód lévén, a fordító tudni fogja, miről beszélünk.
A push_back()
előbb ellenőrzi, hogy van-e hely, és ha kell, a
reserve()
hívásával megnyújtja a memóriaterületet. Ha ez megvolt, az
új elem létrehozható a paraméter lemásolásával:
template <typename T>
void MyVector<T>::push_back(T const &what) {
if (size_ + 1 > capacity_)
reserve(size_ + 10);
new (&pData_[size_]) T(what);
++size_;
}
A vektor méretét nem érdemes lineárisan növelni, mondjuk 10-esével, mint a fenti példában. Ez még mindig O(n2) átméretezést és másolást eredményez, hiába csak minden tizedik esetben történik meg. Helyette exponenciálisan kell, 2-szeres, vagy még inkább 1,5-szeres szorzóval. Lásd az irodalomjegyzékben hivatkozott cikket.
A destruktor, másoló konstruktor és másoló értékadó operátor a szokásos módon működik. Természetesen mindegyikben figyelni kell arra, hogy bár a foglalt terület a kapacitásig tart, inicializált objektumok csak a méret tagváltozóban megadott indexig vannak. A destruktort csak az utóbbiakon szabad hívni.
A saját vektor letölthető innen: myvector.cpp. Ez már tudja azt, amit a beépített std::vector
:
int main() {
MyVector<Noisy> s;
s.reserve(10);
std::cout << "=====\n";
s.push_back(Noisy{5});
std::cout << "=====\n";
s.push_back(Noisy{6});
std::cout << "=====\n";
}
===== Noisy{5} ctor Noisy copy ctor 5 Noisy dtor 5 ===== Noisy{6} ctor Noisy copy ctor 6 Noisy dtor 6 ===== Noisy dtor 5 Noisy dtor 6
Összefoglalásként
A saját vektorunkban az std::vector
-hoz hasonlóan kihasználtuk azt, hogy
a memóriafoglalás és az objektumok létrehozása elválasztható egymástól.
A szokásos new T[]
kifejezés helyett, amelyik előbb memóriát foglal, utána
konstruktorokat hív, két lépésben végeztük el ezeket a műveleteket: az operator new()
függvénnyel előbb memóriát foglaltunk, utána placement new segítségével objektumot
tettünk oda. Visszafelé pedig ugyanígy: külön a destruktor és külön a memóriaterület felszabadítása.
Mindezt az alábbi állapotgráf foglalja össze:
Három tagváltozóra van szükségünk:
- Egy pointer a lefoglalt memóriaterület elejére.
- Egy egész, az elemek száma. Ez a méret.
- Egy egész, a foglalt terület nagysága. Ez a kapacitás.
template <typename T> class MyVector {
private:
T *pData_;
size_t size_;
size_t capacity_;
};
A C-s tömböket könnyedén lehetett inicializálni:
int arr[] = { 1, 2, 3, 4, 5 };
A C++98-ban sajnos a tároló osztályoknál már nem volt lehetőség ennek megvalósítására. A C++11-ben ezt a hiányosságot pótolták, ebben már bármelyik tároló osztály rendelkezhet ilyen konstruktorral:
std::vector<int> v = { 1, 2, 3, 4, 5 };
std::set<std::string> s = { "hello", "world" };
A cél természetesen az volt, hogy ez ne a beépített típusok (tömb, struktúra) privilégiumaként jelenjen meg, hanem bármelyik, akár saját osztályunkat inicializálhassuk így.
A működés egy speciális, könyvtári osztálysablonon alapszik. Ez az #include <initializer_list>
fejlécfájlban
definiált std::initializer_list
osztálysablon. A saját osztályunk konstruktorának egy ilyen objektumot kell
átvennie:
template <typename T>
class MyVector {
public:
MyVector(std::initializer_list<T> list);
};
Az inicializáló lista objektum tárolja a listában megadott értékeket. Ez
szokványos tárolóként használható; a size()
tagfüggvénnyel
lekérdezhető az elemszám, a begin()
és end()
tagfüggvényei iterátorokat adnak. Az inicializáló lista objektumot
átvevő konstruktorunkban így semmi egyéb dolgunk nincs, mint lemásolni
ezeket az elemeket:
template <typename T>
MyVector<T>::MyVector(std::initializer_list<T> list)
: MyVector() {
reserve(list.size());
for (auto it = list.begin(); it != list.end(); ++it)
push_back(*it);
}
A C++11 óta egy konstruktor inicializáló listáján meghívhatjuk ugyanazon osztály (és értelemszerűen ugyanazon objektum) más paraméterezésű konstruktorát. Ez az ún. konstruktordelegálás, amely nem csak kényelmi funkció, hanem további szerepe is van. Erről később lesz szó.
Ehhez tehát először is inicializáljuk a vektort üresen (meghívva a vektor
saját alapértelmezett konstruktorát). Erre létrejön a vektor üresen.
Aztán előre lefoglalunk annyi helyet az előbb megírt reserve()
függvénnyel, ahány elem a kapott inicializáló listában van. Végül pedig,
bemásoljuk az előfoglalt helyre a lista elemeit.
Ha ez megvolt, már működik is a vektorunk listával inicializálása:
MyVector<int> v = { 1, 2, 3 };
for (auto it = v.begin(); it != v.end(); ++it)
std::cout << *it << std::endl;
1 2 3
Az inicializáló listák – tudnivalók
Az inicializáló lista speciális beépített osztály. De csak egyetlen különlegessége
van: az, hogy a fordító hívja meg a konstruktorát, amikor
egy kapcsos zárójeles listával találkozik. A lista
típusát, azaz az std::initializer_list
sablonparaméterét a
használat módja által igényelt típusból vezeti le.
Az inicializáló listában tárolt objektumok temporálisok, az adott blokk vagy kifejezés végére érve megszűnnek. Ha tárolót inicializálunk ezekkel, akkor le kell másolni azokat.
Maga az inicializáló lista objektum kicsi, ezért átvehetjük érték szerint. A „benne” lévő adatok igazából a lista objektumon kívül tárolódnak egy tömbben, maga az objektum csak két pointerből áll (a tömb eleje és vége).
Az inicializáló lista nem számít kifejezésnek, ezért nem használható úgy, mint bármelyik objektum. Például ez hibás:
i = { 1, 2, 3, 4 }.size(); /* FORDÍTÁSI HIBA: nem kifejezés */
Egy-két helyen azért megengedett a használatuk a konstruktorhívásokon kívül is. Ezeket a helyzeteket egyedi szintaktikai szabályok írják le. Példákat a lenti kód mutat.
auto list = { 1, 2, 3, 4, 5, 6, 7 }; /* OK */
for (auto i : { 1, 2, 3, 4 }) /* OK */
std::cout << i << std::endl;
Ahhoz, hogy a fordító ki tudja találni a lista típusát, az inicializáló elemeknek egyformáknak kell lenniük:
auto list2 = { 1, 'a' }; /* FORDÍTÁSI HIBA */
std::initializer_list<int> list3 = { 1, 'a' }; /* OK */
Mint azt láttuk, C++11-ben a konstruktorokat a kapcsos zárójelekkel {}
is meg lehet hívni. Ennek célja az volt, hogy a szintaktikai kétértelműséget
elkerüljük: int f()
egy függvénydeklaráció, int f{}
változódefiníció.
Sajnos ez a kapcsos zárójeles jelölés ütközik az inicializáló listákkal, ezért
újabb kétértelműség került a szintaxisba, még ha kisebb is, mint a függvényes
probléma. Előfordulhat ugyanis, hogy egy inicializáló listában pont olyan értékeket
szeretnénk megadni, amik konstruktorparaméterek is lehetnek. Ilyenkor a kapcsos
zárójeles szintaxis inicializáló listának számít, és ha mégis a másik konstruktort
szeretnénk hívni, a kerek zárójelet kell használnunk:
std::vector<int> v{3}; /* initializer_list<int> ctor, 1 elem, értéke: 3 */
std::vector<int> v(3); /* size_t ctor, 3 elem, értékük: 0, 0, 0 */
Úgy látszik, bármennyiféle zárójel van: ()
, []
, {}
, <>
,
a programozási nyelvek képesek elhasználni mindet. Akár többféle célra is.
A C-s tömböket könnyedén lehetett inicializálni:
int arr[] = { 1, 2, 3, 4, 5 };
C++11-ben már bármelyik tároló osztály rendelkezhet ilyen ctorral:
std::vector<int> v = { 1, 2, 3, 4, 5 };
std::set<std::string> s = { "hello", "world" };
A működés az #include <initializer_list>
fejlécfájlban
definiált std::initializer_list
osztálysablonon alapszik. A saját osztályunk konstruktorának egy ilyen objektumot kell
átvennie:
template <typename T>
class MyVector {
public:
MyVector(std::initializer_list<T> list);
};
Figyeljük meg az előző részben használt auto
kulcsszót! Az
ilyen ciklusokat, amelyek egy tároló iterátoraival dolgoztak, eddig
nem pont így írtuk.
Miről is volt itt szó? A vektor
ad egy iterátor típust, amely segítségével az elemei bejárhatóak. Ezt az iterátort a
pointerekhez hasonlóan lehet használni, főként a ++
és *
operátorokkal. Eddig ezt írtuk:
std::vector<int> v = { 4, 5, 6 };
for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
std::cout << *it << std::endl;
}
4 5 6
A bonyolult típusnévvel rendelkező iterátorok használata kényelmetlen lehet,
ezért a C-ből örökölt auto
kulcsszót egy új szereppel ruházták fel a
C++11-ben. Azokban a változódefiníciókban, ahol rögtön értéket is kap egy
változó, a típus helyén az auto
kulcsszót használhatjuk, és ilyenkor
a típust a fordító automatikusan kitalálja, figyelembe véve a megadott módosítókat:
auto
auto x = 5; // x = int lesz
int i;
auto &j = i; // int &j = i;
int *foo();
auto *k = foo(); // int *k = foo();
auto const l = 5; // int const l = 5;
Természetesen az auto
-t ennél bonyolultabb típusoknál szoktuk
használni. De épp arra való! A fenti iterátoros bejárásokat így írhatjuk
az auto
segítségével:
for (auto it = v.begin(); it != v.end(); ++it) { // C++11
std::cout << *it << std::endl;
}
Ha konstans iterátort szeretnénk,
akkor figyelembe kell venni azt,
hogy eddig az it
változó típusának megadása miatt lett az iterátor
konstans, nem pedig a hívott tagfüggvény miatt. Most pedig az iterátor változó
típusát pont a függvény visszatérési értékének típusából fogja majd meghatározni
a fordító! Ezért C++11-ben a beépített tárolók új tagfüggvényeket kaptak,
cbegin()
és cend()
. Ezek const_iterator
-okkal
térnek vissza:
for (auto it = v.cbegin(); it != v.cend(); ++it) {
*it += 1; /* FORDÍTÁSI HIBA */
std::cout << *it << std::endl; /* OK */
}
Az auto
kulcsszónak sokkal nagyobb szerepe van annál, minthogy kényelmesen
megadhatjuk vele a változók típusát. Sokszor template
kódban előfordul,
hogy nem is ismerjük pontosan a típust. Tegyük fel, hogy adott a lenti függvényünk.
Ez az egyszerűség kedvéért csak annyit csinál, hogy összead két értéket, pl.
2+3 = 5
(egészek) és 2.2+3.3 = 5.5
(valósak).
template <typename T>
T add(T a, T b) {
return a+b;
}
A probléma csak az, hogy ezzel nem adható össze két eltérő típusú érték.
Az add(2, 2.3)
és add(2.3, 2)
kifejezéseknél a fordító
nem tudja levezetni a sablonparaméter típusát, mivel a két paraméter típusa nem egyforma.
Ezek a függvényhívások fordítási hibához vezetnek.
Ha két sablonparaméterünk van a két paraméterhez, akkor vajon abból hogyan mondjuk
meg a visszatérési érték típusát? int+double
→ double
-t ad,
és fordítva, double+int
→ double
eredmény áll elő.
Ha elképzeljük ugyanezt kivonással, ott például létezik egy pointer-pointer
→
egész szám
eset is... Legszívesebben a visszatérési értékhez is auto
-t
írnánk. Határozza meg azt az operátor, amelyik az a+b
kifejezés értékét adja!
template <typename T1, typename T2>
auto add(T1 a, T2 b) {
return a+b;
}
Ez C++14 óta jó is. Sőt C++17-ben
már a paraméterek típusaihoz is írhatunk auto
-t! Bár első ránézésre nem tűnik úgy,
a lenti kódban egy sablonfüggvény látható, hiszen az auto
helyére bármi kerülhet.
Meg kell szoknunk, hogy nem csak az a kód sablon, ahol látjuk a template
kulcsszót,
hanem egy ilyen is:
auto add(auto a, auto b) {
return a+b;
}
Egy kis történelem
Az auto
kulcsszó a C-ben az automatikus
memóriakezelésű változókra vonatkozott, mint tárolási osztály megnevezése.
Azért nem találkozni vele soha, mert semmilyen többletjelentése nincs:
ha elhagyjuk, ugyanúgy automatikus memóriakezelésű lokális változókat kapunk.
Csak a többi tárolási osztály nevét kellett kötelezően kiírni, pl. static
,
extern
vagy register
.
A C++11-ben ezért az auto
kulcsszót az eredeti jelentésétől teljesen
megfosztották, helyette az „automatikusan kitalált típusú változó” jelentést kapta.
Mennyire jó ez? Arról a vélemények megoszlanak. Sokszor rövidebbé, áttekinthetőbbé
teszi a kódot: egy auto iter = valami.begin()
változót látva egyből
tudjuk, hogy egy iterátorral van dolgunk. Máskor viszont elrejti az információt,
hogy milyen típusú objektumokkal dolgozunk épp, és megnehezíti a kód olvasását.
Ezt mindenképp mérlegelni kell a használatakor.
auto
auto x = 5; // x = int lesz
int i;
auto &j = i; // int &j = i;
int *foo();
auto *k = foo(); // int *k = foo();
auto const l = 5; // int const l = 5;
A tárolók bejárását még egyszerűbbé teszi az ún. range-based for szintaxis. Ez ennyire egyszerű:
range-based for
std::vector<int> v = { 3, 4, 5 };
for (int i : v)
std::cout << i << std::endl;
A „range-based for” ciklusban kötelező egy változót deklarálni. Ez
a változó fogja felvenni a tároló értékeit. A típust nem kötelező megadni,
lehet auto
is:
for (auto i : v)
std::cout << i << std::endl;
Tudni kell azt, hogy ez a változó érték szerint fogja felvenni a tároló által tárolt elemeket, tehát ebbe másoló konstruktoraikkal bemásolódnak a tároló elemei. Ha ezt el szeretnénk kerülni, akkor referencia típust kell megadni. Ilyen esetben a tároló elemeinek referenciáját látjuk, és módosítani is tudjuk az elemeket:
for (auto & i : v)
i *= 2;
Az ilyen módon használt for()
ciklus nagy bravúrja, hogy
nem csak az STL tárolóin, hanem saját tárolóinkon, és akár tömbökön is működik.
Ezeket a ciklusokat a fordító a háttérben mindig átírja egy olyan változatra, amely a C++11-ben
új, globális std::begin()
és std::end()
függvényt használja
(#include <iterator>
) a tartomány elejének és végének lekérdezésére.
Az előző ciklust például így fejti ki magának a fordító:
for (auto it = std::begin(v); it != std::end(v); ++it) {
auto &i = *it;
std::cout << i << std::endl;
}
A globális std::begin()
és std::end()
függvénysablon
úgy van megírva, hogy az adott tároló begin()
és end()
tagfüggvényét hívva kérdezze le a tartomány két szélét mutató iterátorokat.
C++14-ben további kényelmi függvények lettek.
A tárolók bejárását még egyszerűbbé teszi az ún. range-based for szintaxis:
range-based for
std::vector<int> v = { 3, 4, 5 };
for (int i : v)
std::cout << i << std::endl;
Lehet auto
is:
for (auto i : v)
std::cout << i << std::endl;
Alapértelmezés szerint értéket tárol a futóváltozó, de lehet ref. is:
for (auto & i : v)
i *= 2;
A tömb típusokra a globális std::begin()
és std::end()
függvénysablon úgy van specializálva, hogy pointert adjon azok elejére és végére. Emiatt
a tárolót bejáró for()
ciklus a tömbökre is működik:
#include <iostream>
#include <iterator>
int main() {
int arr[] = { 3, 4, 5, 6 };
for (auto i : arr)
std::cout << i << ' ';
}
3 4 5 6
Mindez azért lehetséges – akármennyire hihetetlennek tűnik a C tanulmányok után! –, mert a C++-ban lehet olyan függvényt írni, amely megadja egy tömb méretét. Egészen pontosan függvénysablont, nem pedig függvényt. Ennek megértéséhez kicsit jobban meg kell vizsgálni a C/C++ paraméterátadás szabályait.
Tudjuk, hogy C-ben a tömböket függvénynek csak a rájuk mutató pointerrel tudtuk
átadni. Erre azért volt szükség, mert a paraméter mérete, azaz a verembe bemásolt
adat mérete fix kell legyen. Egy emiatt kitalált szintaktikai szabály kimondta,
hogy egy függvény fejlécében akár tömb típust, akár pointert adtunk meg, azt a
fordító pointerként értette. Formálisan: egy függvény fejlécében adott
T[]
típus automatikusan T*
-ként értendő. Az alábbi két
deklaráció így ekvivalens, és az alsó felel meg a tényleges működésnek.
void func(int arr[]);
void func(int *arr);
A híváskor átadott változó ennek ellenére nem csak pointer típusú lehet, hanem tömb is. Ha tömböt adunk át, akkor a hívás helyén két újabb szabály aktiválódik. Nézzük meg ezeket részletesebben!
- Az egyik az, hogy C-ben érték szerinti paraméterátadás van, és a híváskor megadott
érték egy új változóba kerül (a függvény paraméterébe). Ha látszólag egy változót adunk meg
a függvényhívás kifejezésben, akkor igazából nem azt a változót adjuk át, hanem
csak annak értékét. Vagy az értékének valamilyen más típussá konvertált
változatát. Pl. ha
int i=2
, akkor asin(i)
kifejezésben azi
változó értéke kiolvasódik:2
, aztán valóssá konvertálódik:2.0
, és ezt kapja asin()
függvény. - A másik szabály, a tömbökre értelmezett konverziók miatt a tömb átadásakor
ugyanaz történik, mint a
sin()
példában. Mivel a tömb típusú érték nem tehető be egy pointer típusú változóba, konverziót keres a fordító; aT[]
típusú értéket automatikusanT*
típusúvá alakítja, kiszámolva a tömb címét. Az alábbi két hívás megint csak ekvivalens, és az alsó mutatja pontosan a tényleges működést. A konverzió miatt elveszítjük a tömb mérete információt – tehát ez az információvesztés pillanata.int arr[100]; func(arr); func((int *) arr);
C++-ban a helyzet ennél árnyaltabb. A függvények már nem csak érték típusú
paramétereket tudnak átvenni, hanem értéket, konstans értéket (ez most lényegtelen),
referenciát és konstans referenciát is. Tekintsünk egy egyszerű int
-et!
void func_val(int i);
void func_ref(int &i);
void func_constref(int const &i);
Az értékparaméterű függvények ugyanúgy működnek, mint C-ben. A hívás helyén megadhatunk változót (amely lemásolódik), vagy megadhatjuk egy kifejezés értékét is. Mivel egy új változóról van szó, akár konverzióval is előállhat az érték. Az alábbiak mind működnek:
void func_val(int i);
int x;
func_val(x);
func_val(2);
func_val(x + 3);
func_val(5.7);
Ugyanez a helyzet a konstans referenciákkal. Azok vehetnek át változót, de ígéretet tesznek arra, hogy nem változtatják. De vehetnek át értéket is, mivel a C++ tervezésekor úgy döntöttek, hogy a konstansok esetén az átvétel módja a hívott függvény megíróit érdekli inkább, nem pedig azt, aki meghívja a függvényt. Ilyenkor a fordító ideiglenesen egy névtelen objektumba teszi az értéket, hogy a függvény kérése szerint referenciaként adhassa át. Így minden, ami működött az érték típusú paraméternél, működik konstans referencia paraméterrel is:
void func_constref(int const &i);
int x;
func_constref(x); /* OK, az x változót látja */
func_constref(2); /* OK, egy ideiglenes objektumot lát */
func_constref(x + 3); /* OK, egy ideiglenes objektumot lát */
func_constref(5.7); /* OK, egy ideiglenes objektumot lát */
Ami pedig a tömbös problémánk megoldásához elvezet, az az, hogy a nem konstans referencia nem ilyen. A sima referencia nem inicializálható semmi mással, csakis balértékkel, azaz változóval. Ez a függvények paramétereire is igaz: referencia formális paraméterű függvénynek a híváskor csak változót kaphatnak, semmi mást. Ez a konverziót is tiltja, mert a konverzió közben egy másik érték keletkezne, amely már nem azonos a változóval. És amely mellesleg jobbérték, mert temporális. Temporális objektumot pedig értelmetlen lenne átadni – ha úgyis megszűnik, akkor miért hívnánk olyan függvényt, amely változtatni akarja?
void func_ref(int &i);
int x;
func_ref(x); /* OK */
func_ref(2); /* FORDÍTÁSI HIBA */
func_ref(x + 3); /* FORDÍTÁSI HIBA */
func_ref(5.7); /* FORDÍTÁSI HIBA */
Tehát a referencia letiltja a konverziókat. Most emlékezzünk vissza megint a C-re! C-ben a függvények azért vették át a tömböket pointerként, mert 1) a formális paraméterként adott tömb típust pointerként kell értelmezni, 2) a pointer értékparaméter, ezért konverzió lehetséges a hívás helyén a paraméter inicializálása érdekében. Ha C++-ban a tömb típusú paraméter helyett tömb referenciája típust használunk, akkor egyszerre kiiktatjuk mindkét szabályt:
void func(int (&arr)[100]);
Az 1-es szabály kikapcsol, mert a megadott típus nem tömb, hanem valaminek a
referenciája. A 2-es szabály is kikapcsol, mert ennek a függvénynek a referencia
paraméter miatt már nem adhatunk akármilyen értéket, csakis pont ugyanilyen
típusú balértéket. Ez a függvény csak int[100]
típusú paraméterrel
hívható, semmi mással. (A zárójelezésre a precedenciaszabályok miatt van szükség,
hogy a referencia &
jelét az arr
paraméternévhez
tapadónak vegye a fordító, ne az int
-hez. Azaz tömb referenciája,
nem pedig referenciák tömbje legyen deklarálva itt – mondjuk az utóbbi amúgy sem létezhet.)
Ezek alapján, az alábbi függvénysablon egy igazi varázsfüggvény: megmondja egy
tömb méretét! Ráadásul azokban az esetekben, ahol nem tudná megmondani (tömbre
mutató pointer, dinamikus tömb), fordítási hibát kapunk, mivel ott a híváshoz
T*
→T[]
típuskonverzióra lenne szükség.
template <typename T, size_t SIZE>
constexpr size_t arrsize(T (&array)[SIZE]) {
return SIZE;
}
int main() {
int arr[100];
std::cout << arrsize(arr);
}
A tömbökkel használt std::begin()
és std::end()
sablonfüggvények pedig ilyen egyszerűen működnek:
template <typename T, size_t SIZE>
T * mybegin(T (&array)[SIZE]) {
return array;
}
template <typename T, size_t SIZE>
T * myend(T (&array)[SIZE]) {
return array + SIZE;
}
És mindezt már C++98-ban is meg lehetett csinálni!
A tömb típusokra a globális std::begin()
és std::end()
függvénysablon úgy van specializálva, hogy pointert adjon azok elejére és végére. Emiatt
a tárolót bejáró for()
ciklus a tömbökre is működik:
int main() {
int arr[] = { 3, 4, 5, 6 };
for (auto i : arr)
std::cout << i << ' ';
}
3 4 5 6
Mindez azért lehetséges, mert a C++-ban lehet olyan függvénysablont írni, amely megadja egy tömb méretét.
Az auto
kulcsszónak a kényelmes változómegadásnál fontosabb
szerepe is van, de ennek megértéséhez előbb meg kell vizsgálni a C++11 egy újabb
kulcsszavát, a decltype
-ot.
Térjünk vissza az add(a, b) = a+b
függvényhez. Gondolkodjunk el azon
egy pillanatra, mi a helyzet akkor, ha a fordító nem látja a függvény törzsét! Helyette csak a deklarációt ismeri
egy adott fordítási egységben, és úgy kellene lefordítsa az add(1, 2.3)
függvényhívást:
template <typename T1, typename T2>
auto add(T1 a, T2 b); /* ??? */
Ez így egyszerűen nem működhet. Azért, mert a visszatérési típus csak a függvény törzséből, definíciójából
derül ki, abból a függvénytörzsből, amely a deklarációból még nem látszik. Ennél tehát több információra van
szüksége a fordítónak. A sablonparamétereket le tudja vezetni a hívásból, nekünk azt kell még megadnunk, hogy
a visszatérési érték típusát hogyan lehet levezetni. Itt jöhet jól a decltype
kulcsszó.
A decltype
működése: deklarált típus és effektív típus
A decltype
kulcsszó arra való, hogy egy kifejezés típusát kérdezzük
le a fordítótól. Ezt operandusként zárójelben kell megadni, és a lekérdezett típus,
azaz az egész decltype
szerkezet bárhol használható, ahova szintaktikailag
egy típus neve illik. A levezetés egyszerűsített szabályai az alábbiak:
- Ha az operandus zárójelezés nélkül van megadva, akkor az eredmény az a típus, ahogy a hivatkozott változót vagy értéket deklaráltuk.
- Egyébként pedig az eredmény az, ahogyan az adott környezetben látszik.
Ezek homályosnak tűnnek, de majd mindjárt meglátjuk, úgy vannak kitalálva, hogy könnyű legyen használni őket. A megértésükhöz észre kell vennünk azt, hogy általában a változók a kifejezésekben nem olyan típusúként látszanak, mint ahogyan deklaráljuk őket. Ezért meg kell különböztetnük a deklarált típust (declared type) és az effektív típust (effective type). Vegyük az alábbi, egyszerű példát:
struct X {
int i;
};
X first;
first.i; /* nem konstans, nem úgy deklaráltuk */
X const second;
second.i; /* konstans, pedig nem konstansként deklaráltuk */
Az X
osztály tagváltozóját, az i
-t nem konstans
egésznek deklaráltuk. Ez a tagváltozó deklarált típusa. Ehhez képest, a használattól
függően, egy objektum i
tagváltozója lehet konstans vagy nem konstans is.
A second.i
például konstans; ez a kifejezés típusához, azaz az effektív típushoz tartozik.
Egy ravaszabb, de annál fontosabb példa:
int a; /* deklarált: int, effektív: int& */
int &b = a; /* deklarált: int&, effektív: int& */
Itt mindkét kifejezés, a
és b
effektív típusa
int &
, azaz egész referenciája (egész balérték, integer lvalue),
annak ellenére, hogy az első változó nem referenciának lett deklarálva. Az int a
sorral azt mondjuk,
hogy szeretnénk foglalni egy egész számnyi helyet a memóriából, és szeretnénk egy
a
nevet is létrehozni, amelyik hivatkozza ezt a létrehozott helyet,
azaz ennek a helynek a referenciája. Az int &b
sorral pedig azt,
hogy helyet foglalni nem szeretnénk, de az előzőhöz hasonlóan egy nevet szeretnénk
adni egy helynek. Mindkét esetben egy helyről van szó, amelynek címe képezhető,
ezért referencia is hivatkozhat rá – vagyis mindkettő balértékként, int &
típusként
látszik kifejezésekben.
A decltype
kulcsszó szabályai eszerint újrafogalmazva, könnyen
emészthető és megjegyezhető formában:
decltype(e)
→ deklarált típus (deklarációban megadott típus)decltype((e))
→ effektív típus (konkrét kifejezésben látszó típus)
Példák:
struct X {
int i;
};
X first;
X const second;
decltype(first.i) /* int */
decltype((first.i)) /* int & */
decltype(second.i) /* int */
decltype((second.i)) /* int const & */
int foo();
decltype(foo()) /* int */
decltype((foo())) /* int, mert nem balérték! */
int a;
decltype(a) b = 5; /* int b = 5; */
decltype((a)) c; /* HIBA: inicializálatlan int & referencia */
decltype((a)) d = a; /* int &d = a; */
A decltype
belseje egyébként a sizeof
operátorhoz
hasonlóan kiértékeletlen környezet (unevaluated context). A benne lévő kifejezést
a fordító megvizsgálja, hogy milyen értéket eredményezne, de a kiértékelése nem
történik meg, a lefordított kódba nem épül be. Például ha valahol
decltype(i++)
-t írunk, akkor azzal a kifejezés típusára
hivatkozhatunk, de az i
változó nem lesz megnövelve.
A függvény visszatérési típusát a decltype
-pal már
meghatározhatjuk: ha a paraméter a
és b
, a visszatérési
érték típusa decltype(a+b)
. Ilyen módon mutatnánk meg a fordító számára,
hogy a paraméterből hogyan kell levezetni a visszatérési érték típusát. Valami ilyesmi,
bár ez még mindig nem jó:
nem jó
template <typename T1, typename T2>
decltype(a+b) add(T1 a, T2 b); /* FORDÍTÁSI HIBA */
A gond itt még az, hogy a függvény fejléce nem része a függvény törzsének,
ezért a fordító nem tudja, milyen a
-ról és b
-ről beszélünk. Az
a
és b
változók létezni fognak, de csak a függvényen belül. Ahol a
visszatérési értéket adjuk meg, az még a függvényen kívülinek számít, és itt még
a függvényen kívüli nevek között keresgél a fordító. (Ha nincs szerencsénk, van
a
és b
nevű globális változónk, és megtalálja azt.)
Különbséget kell tenni a függvényen kívüli és a függvényen belüli nevek között.
Ezért még egy olyan szintaktikai elem kellett, amellyel egy függvény visszatérési
értékének típusa a paraméterei után adható meg. Ez az ún. trailing return type
szintaxis. Ebben a visszatérési típus megszokott helyére az auto
kulcsszót
kell írni (mert a függvény deklarációját egy típusnévvel kell kezdeni), a típust pedig
egy nyíl után a fejléc végére:
trailing return type
auto foo() -> type;
Az add()
függvénysablon deklarációja és definíciója, ha ezeket el akarjuk
választani, tehát így írható:
decltype
template <typename T1, typename T2>
auto add(T1 a, T2 b) -> decltype(a+b);
template <typename T1, typename T2>
auto add(T1 a, T2 b) -> decltype(a+b) {
return a+b;
}
És ez már a jó megoldás. Így minden típusra fog működni;
például add(std::string, char)
és add(char, std::string)
típusa is std::string
lesz, mivel a megfelelő operator+
függvényt
a hívás helyén is megkeresi a fordító.
A „trailing return type” szintaxis bárhol használható:
auto main() -> int {
/* ... */
}
Néha a nevek láthatósági köre miatti zűrzavar kivédésére is jól jön. Például az iterátoroknál, ahol az osztály tagfüggvény egy belső típussal tér vissza:
class MyVector {
using iterator = int *;
auto begin() -> iterator;
};
auto MyVector::begin() -> iterator { /* MyVector::iterator, nem pedig ::iterator */
/* ... */
}
Mi a helyzet akkor, ha a fordító nem látja a függvény törzsét, helyette csak a deklarációt ismeri,
és úgy kellene lefordítsa az add(1, 2.3)
függvényhívást?
template <typename T1, typename T2>
auto add(T1 a, T2 b); /* ??? */
Ez így nem jó, mert a visszatérési típus csak a függvény törzséből derül ki, ami nem látszik. Itt jön jól a decltype
kulcsszó.
- What is "placement new" and why would I use it? (C++ FAQ).
- Bjarne Stroustrup: C++11 FAQ: Range-for statement.
- Herb Sutter, and Andrei Alexandrescu. C++ coding standards: 101 rules, guidelines, and best practices. Pearson Education, 2004.
- Bjarne Stroustrup: The Design and Evolution of C++. Addison-Wesley, 1994.
- A kapacitás növeléséről: What is the ideal growth rate for a dynamically allocated array?.
- Is there a way to force new to allocate memory from a specific memory area? (C++ FAQ).
- Slab allocation (Wikipedia).
- Memory Slices — efficient way to allocate groups of equal-sized chunks of memory (GLib Reference Manual).