Tároló osztályok
Czirkos Zoltán, Pohl László, Dobra Gábor · 2026.10.29.
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, azintparamé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 deletevoid * 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 ptrmű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);
};
A korábban megismert "range based for" úgy működik, hogy a a fordító egy globális
std::begin() és std::end() függvényt használó változatra alakítja:
for (auto it = std::begin(v); it != std::end(v); ++it) {
auto &i = *it;
std::cout << i << std::endl;
}
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 azivá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 referenciát és konstans referenciát is.
Tehát ha C++-ban a tömb típusú paraméter helyett tömb referenciája típust használunk, akkor nem lép életbe a tömböt pointerre konvertáló szabály:
void func(int (&arr)[100]);
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[SIZE] 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.
A C-s tömböket C++-ban is használjuk, az std::vector nem váltotta ki teljes mértékben. Előnye a sima tömbnek, hogy nem kell hozzá dinamikus memória, és néha praktikus, hogy a mérete állandó, és fordítási időben ismert.
Azonban a C kompatibilitás miatt egy kellemetlen tulajdonsága megmaradt: nem kezelhető érték szerint, hanem ahelyett mindig az első elemére mutató pointerré konvertálódik.
void func(int arr[3]);
Ez a függvény például sehogyan sem tud másolatot kapni a tömbből, és érték szerint sem tud visszaadni például egy float[3] tömböt.
Ha mégis erre volt szükségünk C-ben, megkerültük a problémát azzal, hogy struct-ba tettük, bevállalva az ezzel járó kényelmetlenségeket. A C/C++ ugyanis tud érték szerint kezelni tömböt, de csak struktúra belsejében.
typedef struct Int3 {
int arr[3];
} Int3;
typedef struct Float3 {
float arr[3];
} Float3;
Float3 func2(Int3 arr);
C++11 óta van erre dedikált megoldás, az std::array.
#include <array>
std::array<float, 3> func2(std::array<int, 3> arr);
Ez viselkedését tekintve nem sokban különbözik a C-s tömböktől: indexelhető, iterálható, stb. Viszont érték szerint kezelhető, és lekérdezhető a mérete (bár ez csak a template paraméterben megadott méretet adja vissza).
C-ben, C++-ban sokszor a fix méretű tömböket is változó méretűként kezeljük: csak az első n elemét használjuk, és n-t a tömb mellett tároljuk. Így sokféle módon juthatunk változó méretű tömb adatszerkezethez:
- C-s tömb
- std::array
- std::vector memóriaterülete
- C-s függvények által adott pointer+méret
Ha egy ilyet szeretnénk (referencia szerinti) paraméterként átadni egy függvénynek, akkor általában két lehetőségünk van:
- a konkrét típus szerint adjuk át, pl.
std::vector const& - tömb első elemére mutató pointert + méretet adunk át.
Ha azt szeretnénk, hogy a függvény bármilyen változó méretű tömbön működjön, csak a második opció marad:
void print_arr(double* arr, size_t siz);
Ez ugyan hívható vektorral, malloc() által adott tömbbel, C-s tömbbel is, de rendkívül kényelmetlen, sőt, egységbe zárást is sért: a pointert és a méretet illene együtt tárolnunk.
Ezt az űrt tölti be a C++20-ban bevezetett std::span típus. A szerepe ugyanaz, mint a std::string_view-é, memóriafoglalás nélküli tömböt. (Érdekesség: majdnem array_view néven került be a szabványba, később nevezték át span-re.) Első elemre mutató pointert és méretet kezel, és elvárja, hogy a memóriaterületet más kezelje. Lehet rajta iterálni, méretet lekérdezni, indexelni, stb.
#include <span>
void print_arr(std::span<double> arr) {
for (size_t i = 0; i < arr.size(); ++i)
std::cout << arr[i] << std::endl;
}
Úgy is lehetne mondani, hogy az ObserverPtr tömbökre való párja.
A C++98 óta létező STL algoritmusokat körülményes használni. Az egyik nehézsége, hogy az algoritmusok kimenetét valahova tenni kell.
Nézzük például az std::transform függvényt, ami paraméterként két intervallumot kap, a bemeneti intervallum minden elemére meghív egy függvényt, és a kimeneti intervallumba írja:
double duplaz(double x) {
return 2 * x;
}
int main() {
std::vector<double> be = {1.0, 2.0, 3.0};
std::vector<double> ki;
ki.resize(be.size());
std::transform(be.begin(), be.end(),
ki.begin(),
duplaz);
}
A függvény hívása előtt meg kell oldanunk, hogy legyen hova tenni eredményt, ezt csinálja itt a .resize(). Ezt viszont nem mindig tudjuk megtenni, például ha nem tömbbe szeretnénk tenni az eredményt, vagy nem tudjuk előre, hány eleme lesz a végeredménynek.
Erre példa az std::set_intersection függvény, ami két (rendezett) halmaz metszetét állítja elő, és a végeredményét egy másik halmazba teszi. Az erre való adatstruktúra az std::set, abban viszont nem lehet előre elemeket foglalni.
Erre a problémára azt a megoldást adta a C++, hogy az STL-be került egy trükkös iterátor osztály, az std::insert_iterator, ami az iterátoron keresztüli írásnál igazából .insert()-et hív. Az std::inserter() segédfüggvény egy ilyet hoz létre nekünk, és levezeti a sablonparamétereket.
int main() {
std::set<double> eredmeny;
auto it = std::inserter(eredmeny, eredmeny.begin());
*it = 3.14; // háttérben .insert()
/* ezzel ekvivalens: */
eredmeny.insert(eredmeny.begin(), 3.14);
}
Ezzel már könnyen használható az std::set_intersection is:
int main() {
std::set<double> a = {1.0, 2.0, 3.0};
std::set<double> b = {2.0, 3.0, 4.0};
std::set<double> metszet;
std::set_intersection(
a.begin(), a.end(),
b.begin(), b.end(),
std::inserter(metszet, metszet.begin())
);
}
Az insert_iterator-nak van két párja, az std::back_insert_iterator illetve a std::front_insert_iterator, amiket vektorral, queue-val érdemes használni, ugyanis ezek a push_back() illetve push_front() függvényeket hívják.
int main() {
std::set<double> a = {1.0, 2.0, 3.0};
std::set<double> b = {2.0, 3.0, 4.0};
std::vector<double> metszet;
std::set_intersection(
a.begin(), a.end(),
b.begin(), b.end(),
std::back_inserter(metszet)
);
}
Mindez C++98 óta létezik.
A fenti std::inserter függvény csak azért létezik, mert a fordító képes levezetni egy függvény sablonparamétereit, és osztályok
esetén sajnos ez nincs így. Pedig gyakran egy sablon osztály konstruktorparamétereiből következtetni
tudnánk, és következtetni szeretnék az osztály sablonparamétereire. Legegyszerűbb példa erre az
std::pair osztály, amelyik két tetszőleges típusú objektumot tárol:
#include <utility>
template <typename F, typename S>
struct Pair {
F first;
S second;
Pair(F first, S second)
: first(std::move(first))
, second(std::move(second))
{}
};
template <typename F, typename S>
Pair<F, S> make_pair(F first, S second) {
return Pair<F, S>{std::move(first), std::move(second)};
}
int main() {
/* error: missing template arguments before ‘(’ token */
auto p1 = Pair(5.6, "string"); // error
auto p2 = Pair<double, char const*>(5.6, "string"); // ok, de :(
auto p3 = make_pair(5.6, "string"); // ok, de :(
}
A p1 változót nem tudjuk létrehozni így, fordítási hibához vezet. Nincsenek megadva az osztály sablonparaméterei. A
p2-t létrehozó sor műr működik, de kényelmetlen. Ki kell írni a sablonparamétereket. A p3-as sor mutatja
a megoldást: egy olyan függvényt kell írni, amely tulajdonképpen semmit nem csinál; csak azért van, mert függvényeknél levezethető
a sablonparaméter, osztálykonál nem. Az STL nagyon sok ilyen gyártófüggvényt tartalmaz: make_pair,
make_tuple, ...
A gyártófüggvényeket általában kioptimalizálja a fordító, de jobb lenne, ha inkább a konstruktorhívásból levezetné a sablonparamétereket. A C++17 a sablonparaméterek levezetését kiterjeszti a sablon osztályok konstruktoraira is. Tehát az alábbi definíció már helyesnek számít:
std::pair p(1, 2.3); // std::pair<int, double>
Ehhez vezettek be C++17-től egy új nyelvi elemet, amellyel megadhatjuk, hogy a konstruktorparaméterekből hogyan következtetünk az osztály sablonparamétereire. Ezek az ún. levezetési útmutatók (class template argument deduction guide, CTAD). Ilyeneket a programozó is definiálhat. A szintaxis a következő:
#include <utility>
template <typename F, typename S>
struct Pair {
F first;
S second;
Pair(F first, S second)
: first(std::move(first))
, second(std::move(second))
{}
};
template <typename F, typename S>
Pair(F first, S second) -> Pair<F, S>; // C++17
int main() {
auto p = Pair(5.6, "string");
}
Itt a jelölt, középső rész a lényeg. Ennek első fele egy függvénydeklarációhoz hasonló rész: Pair(F first, S
second), mintha a konstruktort hívnánk meg. Ezt egy nyíl követi. A végén pedig egy típus található: Pair<F,
S>, ilyen sablonparaméterekkel kell példányosítani az osztályt.
Néhány levezetési szabályt a fordító maga is előállít; egyparaméteres konstruktoroknál tudjuk ezt kihasználni. Például ha egy tárolót szeretnénk írni, amely inicializáló listát is kaphat a konstruktorában, akkor az inicializáló elemek típusából is levezethető a sablonparaméter:
template <typename T>
class MyVector {
public:
MyVector(std::initializer_list<T> l) {}
/* ... */
};
int main() {
MyVector v = { 1, 2, 3 }; // ok, MyVector<int>
}
Ez olyan, mintha megírtuk volna az alábbi szabályt:
template <typename T>
MyVector(std::initializer_list<T> l) -> MyVector<T>;
Visszatérve az std::set_intersection() példára, a kódot olvasva felmerül, hogy hiányoznak erős típusok a kódból: a .begin() és a .end() iterátorok összetartoznak, egy intervallum elejét és végét reprezentálják, de az intervallumra nincs dedikált típus.
int main() {
std::set<double> a = {1.0, 2.0, 3.0};
std::set<double> b = {2.0, 3.0, 4.0};
std::set<double> metszet;
std::set_intersection(
a.begin(), a.end(), // a.range()?
b.begin(), b.end(), // b.range()?
std::inserter(metszet, metszet.begin())
);
}
A range fogalma mindenféle tárolónál és algoritmusnál hiányzott, egészen C++20-ig. Sőt sokszor tárolókat használunk csak azért, hogy legyen min iterálni, egy range igazából nem is kell hogy közvetlenül tárolót takarjon. Az ilyen range-et view-nak hívja a szabvány.
C++20-ban átvették a boost projektben lévő range-v3 könyvtárat, ami tartalmaz egyrészt range-nek látszó iterátor-párt, valamint rengeteg algoritmust és segédfüggvényt, ami range-ekkel dolgozik.
A ranges nem úgy oldotta meg a problémát, hogy a tárolóknak lett .range() tagfüggvénye (körülményes lett volna, főleg a const_iterator és hasonlók miatt). Ehelyett azt vezették be, hogy minden tároló egy range, azaz a tárolók eleget tesznek a range fogalmának: van eleje, vége, és mindkettő egy iterátor.
A range-et váró függvények pedig mind template-esek, és elvárják hogy, a range fogalmat teljesítő objektumot kapjanak paraméterként. Ezek a függvények az std::ranges névtérbe, illetve az std::views névtérbe kerültek:
int main() {
std::set<double> a = {1.0, 2.0, 3.0};
std::set<double> b = {2.0, 3.0, 4.0};
std::set<double> metszet;
std::ranges::set_intersection(
a, b,
std::inserter(metszet, metszet.begin())
);
}
C++-ban egészen 2020-ig nem volt szabványos függvény arra, hogy egy sztringet darabokra vágjunk valamilyen elválasztó mentén.
A probléma oka a durva hatékonytalanság volt: az STL-be nem kerülnek be olyan függvények, amik sokszorosan lassabban működnek, mint ahogyan hatékonyan megoldható.
Ennek a hatékonytalanságnak az egyik fele az volt, hogy a rész-sztringekről egyesével dinamikus másolatot kellett készíteni, amit a C++17 óta létező std::string_view osztály segítségével tudtunk megoldani.
A probléma másik fele pedig: a megoldásunk a rész-sztringekent std::vector-ban tárolja, ehhez szintén dinamikus memóriát foglal (bár általában jóval kevesebbet, mint a sztringmásolás volt), de igazából eltárolni is felesleges. A legtöbb esetben ugyanis valahogy így szeretnénk használni:
#include <string_view>
std::vector<std::string_view> split(std::string_view str, std::string_view delimiter) {
std::string_view remainder = str;
std::vector<std::string_view> tokens;
size_t pos = 0;
while ((pos = remainder.find(delimiter)) != std::string::npos) {
tokens.push_back(remainder.substr(0, pos));
remainder = remainder.substr(pos + delimiter.length());
}
tokens.push_back(remainder);
return tokens;
}
int main() {
std::string text;
// kiírja a nagybetűvel kezdődő sorokat
for (std::string_view token : split(text, "\n")) {
if (std::isupper(token[0]))
std::cout << token << std::endl;
}
}
Ehhez pedig elég egy view, nem kell tároló.
A C-s megoldás a split-re az volt, hogy mindig egyszerre csak egy rész-sztringet keressünk meg, dolgozzuk fel, és csak azután keressük meg a következőt. Erre volt a lábbalhajtós strtok illetve strtok_s függvény.
Ezt az ötletet követve azonban írhatnánk olyan iterátort, ami:
- konstruktorában megkeresi az első rész-sztring végét,
- léptetésnél (
operator++) megkeresi a következő rész-sztringet, - dereferálásnál (
operator*) visszaadja az aktuális rész-sztringet, - a legvégén pedig az aktuális rész-sztringet beállítja az eredeti sztring legvégére.
class TokenIterator {
std::string_view whole_;
std::string_view delim_;
std::string_view current_;
public:
TokenIterator() = default;
using difference_type = std::ptrdiff_t;
TokenIterator(std::string_view whole, std::string_view delim)
: whole_{whole}
, delim_{delim}
, current_{whole_.substr(0, whole_.find(delim_))}
{ }
TokenIterator& operator++() {
if (whole_.end() == current_.end()) {
current_ = std::string_view{whole_.data() + whole_.size(), 0};
return *this;
}
auto current_pos = current_.data() - whole_.data();
auto pos_start = current_pos + current_.size() + delim_.size();
auto pos_end = whole_.find(delim_, pos_start);
if (pos_end == std::string_view::npos)
pos_end = whole_.size();
current_ = std::string_view{whole_.data() + pos_start, pos_end - pos_start};
return *this;
}
TokenIterator operator++(int) {
TokenIterator old = *this;
++(*this);
return old;
}
const std::string_view& operator*() { return current_; }
const std::string_view* operator->() { return ¤t_; }
friend bool operator==(const TokenIterator& lhs, const TokenIterator& rhs) {
return lhs.current_.data() == rhs.current_.data()
&& lhs.current_.size() == rhs.current_.size();
}
static TokenIterator end_iter(std::string_view whole) {
return TokenIterator{
std::string_view{whole.data() + whole.size(), 0},
std::string_view{}
};
}
};
Ezzel már tudunk rendes range-nek látszó objektumot, pl. TokenRange-et visszaadni, ami:
- tárol egy
begin_ésend_adattagot, - van
begin()ésend()tagfüggvénye, - ezek a tagfüggvények iterátorokat adnak vissza.
Ez pedig már elég ahhoz, hogy tudjunk rajta iterálni.
Egy kézzel írt TokenRange osztály helyett használhatunk std::ranges::subrange osztályt:
auto split(std::string_view str, std::string_view sep) {
return std::ranges::subrange{
TokenIterator{str, sep},
TokenIterator::end_iter(str) };
}
Hasonlóan működik az std::ranges::split_view függvény.
int main() {
std::string text;
// kiírja a nagybetűvel kezdődő sorokat
for (std::string_view token : std::ranges::split_view(text, "\n")) {
if (std::isupper(token[0]))
std::cout << token << std::endl;
}
}
További előnye ennek a megoldásnak, hogy ha mégis szükség van rá, tehetjük bármilyen tárolóba az így előállt range-et:
int main() {
std::string text;
auto tokens = std::ranges::split_view(text, "\n");
std::vector<std::string_view> lines{tokens.begin(), tokens.end()};
}
A tárolók pedig kaptak egy új konstruktort, amivel bármilyen range-ből létre lehet őket hozni, a begin()/end() nélkül:
int main() {
std::string text;
std::vector<std::string_view> lines{
std::from_range,
std::ranges::split_view(text, "\n")
};
}
Az extra std::from_range paraméterre az overload resolution miatt van szükség, a hogy a fordító meg tudja különböztetni a többi sablon konstruktortól a range-et átvevő konstruktort.
- 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).
- Mike Spertus et al.: Template argument deduction for class templates.
- Arne Mertz Modern C++ Features – Class Template Argument Deduction.