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ő.

1. A kiabálós osztály

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:

  1. A push_back() hívás előtt létrejön egy ideiglenes Noisy objektum, az int paraméterű konstruktorával.
  2. 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.
  3. Á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.)
  4. A régi memóriaterületet fel lehet szabadítani, ezért a lemásolt három Noisy eredeti példánya megszűnik.
  5. 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

2. Hányféle new operátor van?

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ű:

A 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:

A 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;

3. Az std::vector megvalósítása

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:

  1. Egy pointerre, amely a lefoglalt memóriaterület elejére mutat.
  2. Egy egészre, amely az elemek számát mutatja. Ez a méret.
  3. 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:

Memóriakezelés: a semmi, a memóriaszemét és az objektum

Három tagváltozóra van szükségünk:

  1. Egy pointer a lefoglalt memóriaterület elejére.
  2. Egy egész, az elemek száma. Ez a méret.
  3. 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_;
};

4. Az inicializáló listák: std::initializer_list

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:

C++11 initializer list
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);
};

5. Az iterátorok és az auto kulcsszó

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:

C++11 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+doubledouble-t ad, és fordítva, double+intdouble eredmény áll elő. Ha elképzeljük ugyanezt kivonással, ott például létezik egy pointer-pointeregé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!

C++14
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:

C++17
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.

C++11 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;

6. A „range-based for” ciklus

A tárolók bejárását még egyszerűbbé teszi az ún. range-based for szintaxis. Ez ennyire egyszerű:

C++11
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:

C++11
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;

7. A tömbök std::begin() és std::end() függvényei

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 a sin(i) kifejezésben az i változó értéke kiolvasódik: 2, aztán valóssá konvertálódik: 2.0, és ezt kapja a sin() 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ó; a T[] típusú értéket automatikusan T* 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.

8. Mindenhova lehet auto-t írni? A decltype kulcsszó

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ó:

Még mindig
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:

C++11
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ó:

C++11 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ó.

9. Irodalom