A C dinamikus tömb mintáját——T *buf,
size_t len, size_t cap és a hozzá tartozó kézi
free——a Dinamikus tömb és string
builder részben már felépítettük. A standard tárolók ugyanezt a
hármast kínálják, de a mérethatárokat, az elemek élettartamát és a
hibakezelést típusba kódolják. Ez a rész azt tanítja meg, hogyan
válasszunk std::array és std::vector között,
és hogyan döntsük el minden mutáció előtt, hogy mely pointer, referencia
és iterator marad érvényes. A folytonos tárolás nem esztétikai kérdés:
ez teszi lehetővé, hogy egy std::vector tartalmát
változtatás nélkül adjuk át C API-nak,
read/write rendszerhívásnak vagy egy bináris
protokoll szerializálójának. A szerződés másik oldala viszont az, hogy a
folytonosság és az újraelosztás együtt mozog: amint a vektor kinő egy
allokációt, a korábbi data() pointer nem követi a
tulajdonost.
A std::array<T, N> pontosan N darab
T típusú elemet tartalmazó aggregate. Az N
fordítási idejű konstans, a méret a típus része, és az objektum nem
konvertálódik pointerré, ahogyan a C tömb függvényparaméterként
elsöprűnik pointerré. Az std::array értékszemantikát ad:
másoláskor minden elem másolódik, értékadáskor minden elem felülíródik.
Ez a C tömbbel szemben a legfontosabb különbség, mert így a fix méretű
puffer átadható érték szerint, visszaadható függvényből, és beágyazható
más struktúrába anélkül, hogy elveszne a hossz.
A méretgarancia azonban nem jelent bitre pontos layout-garanciát. A
sizeof(std::array<T, N>) tipikusan
N * sizeof(T), de a szabvány ezt nem írja elő; padding és
belső elrendezés implementációfüggő lehet. A data()
pointert ad az első elemre, a size() pedig fordítási idejű
konstans, így az std::array kitűnő választás fix
protokollfejlécekhez, hardverregiszter-tükrökhöz és olyan bufferokhoz,
amelyek mérete a típusban él. Az N = 0 megengedett
speciális eset: ilyenkor size() == 0,
begin() == end(), és a data() értéke nem
használható dereferálásra.
Egy inicializálási csapda rendszeresen visszatér. A
std::array<int, 3> a; default-inicializálja az
aggregate-et, de az int elemek így indeterminált értékűek
lesznek; olvasásuk nem definiált viselkedés. A
std::array<int, 3> a{}; value-initializál, tehát
minden elem nulla lesz. Ugyanez a különbség a
std::vector-nál is megjelenik, csak ott a
resize és a push_back teszi láthatóvá. Ha a
fix méretű tömböt függvénynek adjuk át, referenciát használjunk,
különben a teljes tömb másolódik:
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
void dump(const std::array<std::uint8_t, 4>& magic) {
for (std::uint8_t b : magic) {
std::printf("%02x ", static_cast<unsigned>(b));
}
}
int main() {
std::array<std::uint8_t, 4> magic{0x7f, 'E', 'L', 'F'};
dump(magic);
std::printf("size=%zu\n", magic.size());
}A magic.size() típusa std::size_t, értéke
fordítási idejű konstans, és a dump belsejében a
magic.size() ugyanúgy 4, mint a hívó oldalon.
Ez az a tulajdonság, amit a C tömb elveszít, amikor paraméterként
pointerré alakul.
A std::vector<T> három számot tart nyilván, és a
programozási hibák nagy része abból fakad, hogy ezeket összekeverjük. A
size() a konstruált elemek száma: a
[0, size()) indexű elemek léteznek, és mindegyikre lefutott
a konstruktor. A capacity() a jelenlegi allokációban elférő
elemek száma; a capacity() >= size() mindig teljesül, és
a size() és capacity() közötti tartomány nyers
tároló, ahol nem él T objektum. A max_size()
elméleti felső korlát, a data() pedig az első elemre mutató
pointer, amely üres vektornál lehet null is, de semmiképpen nem
dereferálható. A folytonosság garantálja, hogy
&v[i] == v.data() + i, és C++20-ban a
vector::iterator contiguous iterator kategóriát
modellez.
A C-s buf/len/cap hármashoz képest a döntő különbség a
len és cap közötti rész státusza. C-ben a
lefoglalt byte-ok mindegyike használható tároló, csak a
len-en túli rész tartalma definiálatlan. C++-ban a
size() és capacity() közötti memória nem
tartalmaz T objektumot, ezért ott T-t olvasni
vagy írni nem lehet. Ezért nem helyes a v.capacity()-t
„kihasználható elemek számaként” kezelni, és ezért nem lehet a
reserve után indexelni.
A reserve(n) csak a kapacitást érinti: ha
n > capacity(), új tárolót foglal legalább
n elemre, áthelyezi a meglévő elemeket, és minden pointert,
referenciát és iterátort invalidál. Ha n <= capacity(),
nem csökkent, nem konstruál elemeket, és a size()
változatlan. A reserve tehát nem ad elemeket; a
v.reserve(8); v[0] = 1; nem definiált viselkedés, mert a
size() még nulla. A resize(n) ezzel szemben
tényleg elemeket hoz létre vagy szüntet meg: növeléskor
value-initializált (vagy megadott értékű) elemekkel bővül a vektor,
csökkentéskor a végső elemek destruálódnak. A resize
kisebbre állítás után sem csökken a capacity(); a
shrink_to_fit() erre tesz kísérletet, de nem kötelező, hogy
ténylegesen csökkentsen.
A push_back és az emplace_back a
size()-t növeli. A push_back(const T&)
másol, a push_back(T&&) mozgat, az
emplace_back(args...) pedig helyben konstruálja az új
elemet. Az emplace_back nem automatikusan jobb: ha már van
egy T objektumunk, a push_back gyakran
ugyanannyiba kerül, az emplace_back viszont nem tudja
kiváltani a {}- inicializáló listát. A
std::vector<std::vector<int>> v; v.emplace_back(3, 4);
egy háromelemű belső vektort hoz létre, amelynek minden eleme
4, mert a (3, 4) konstruktorargumentumnak
számít; a v.push_back({3, 4}) ezzel szemben két elemet ad.
Ez a különbség gyakori félreértés forrása.
Ha a size() == capacity(), a push_back
újraeloszt: új tárolót foglal, az elemeket mozgatással vagy másolással
áthelyezi, a régi elemeket destruálja, a régi tárolót felszabadítja. A
mozgatás akkor választandó, ha a T move konstruktora
noexcept, vagy ha a másolás nem elérhető; ezt a noexcept, swap és container-reallocation részben
tárgyaltuk részletesen, és a type trait-ek (Type
traits, tag dispatch és if constexpr) teszik hozzáférhetővé a
döntést. A push_back erős kivételgaranciát ad, ha a
T másolása vagy a nem dobó mozgatása rendelkezésre áll:
kivétel esetén a vektor állapota változatlan marad.
A geometriai növekedés amortizált konstans költséget ad. Ha a
növekedési faktor k > 1 (a konkrét érték
implementációfüggő, tipikusan 1,5 vagy 2), akkor n egymást
követő push_back után az áthelyezett elemek száma
legfeljebb 1 + k + k^2 + ... + n < n * k / (k - 1), ami
k = 2 esetén 2n. Egyetlen
push_back worst-case költsége tehát O(n), de
n műveletre átlagosan O(1). Ez a különbség a
Big-O-s táblázatokban elvész, és éppen ezért nem elegendő a
tárolóválasztást csak aszimptotikára alapozni.
A következő táblázat a std::vector mutációinak hatását
foglalja össze. A „mind” azt jelenti, hogy a korábbi data()
pointer, referencia és iterator érvénytelenné válik; az „end()” a
past-the-end iterátorra utal, amely nem dereferálható, de
összehasonlítható.
| Művelet | size / capacity |
Pointer, referencia, iterator |
|---|---|---|
push_back, emplace_back |
size nő; ha size == capacity, a capacity
nő |
újraelosztás esetén mind; egyébként az end() és az új
elem |
insert |
size nő, a capacity csak szükség esetén |
újraelosztás esetén mind; egyébként a beszúrás helyétől |
erase |
size csökken, a capacity nem |
a törölt elemek és az utána következők; a visszaadott iterator érvényes |
reserve(n), ha n > capacity |
capacity nő, size változatlan |
mind |
resize(n) |
size lesz n |
újraelosztás esetén mind; csökkentésnél a törölt elemek |
clear() |
size nulla, capacity marad |
mind |
shrink_to_fit() |
capacity csökkenhet | mind, ha ténylegesen csökkent |
swap |
nem változik | az elemekre mutató pointer/reference érvényes marad, de a másik konténer elemeire mutat |
A swap különleges: az elemek nem mozdulnak el a
memóriában, ezért a rájuk mutató pointer és referencia érvényben marad,
csak most a másik vektor elemeire mutat. Az end() iterator
ezt nem éli túl, és az iterátorok is a másik konténerhez tartoznak. A
std::array esetében nincs újraelosztás, ezért nincs
invalidáció sem; de ha az egész std::array-t értékül adjuk
át vagy értékadással felülírjuk, a korábbi referenciák továbbra is az
eredeti objektumra mutatnak, amelynek tartalma közben megváltozott.
A std::vector<bool> a standard könyvtár egyetlen
olyan tárolója, amely nem adja ugyanazt a szerződést, mint a többi
vector. A szabvány specializációt enged meg, és a tipikus
implementáció bitenként tárolja az értékeket. Emiatt a
data() tagfüggvény nem létezik, a &v[0]
nem ad bool*-t, és a v[i] típusa nem
bool&, hanem egy proxy osztály
(std::vector<bool>::reference), amely
bool-lá konvertálható. A bool& r = v[0];
nem fordul le, az auto x = v[0]; pedig a proxyt másolja,
nem bool-t. A vector<bool> tartalma nem
adható át C API-nak bool*-ként; ha erre szükség van,
std::vector<char>,
std::vector<std::uint8_t> vagy fix méretre
std::bitset<N> a megfelelő választás. A tömör tárolás
előnye valós, de a folytonosság és a data() elvesztése
tudatos döntést igényel.
A következő példa egy protokollüzenetet modellez: a fejléc fix négy
bájtos mágikus száma std::array, a változó hosszú tartalom
std::vector. A C API-határ csak pointert és hosszt fogad,
ezért a vektor data()-ját használjuk, de minden mutáció
után újra lekérjük.
// cont_demo.cpp
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <vector>
struct Header {
std::array<std::uint8_t, 4> magic{0x7f, 'E', 'L', 'F'};
std::uint16_t version{1};
};
void write_bytes(const std::uint8_t* p, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
std::printf("%02x", static_cast<unsigned>(p[i]));
std::printf(i + 1 == n ? "\n" : " ");
}
}
void show(const char* what, const std::vector<std::uint8_t>& v) {
std::printf("%-14s size=%zu capacity=%zu\n", what, v.size(), v.capacity());
}
int main() {
Header h;
write_bytes(h.magic.data(), h.magic.size());
std::vector<std::uint8_t> payload;
payload.reserve(4); // capacity >= 4 (lehet több is)
for (std::uint8_t b : {0xde, 0xad, 0xbe, 0xef}) {
payload.push_back(b); // size <= capacity: nincs újraallokálás
}
show("4 push_back:", payload);
const std::uint8_t* raw = payload.data();
write_bytes(raw, payload.size()); // érvényes: közben nincs mutáció
// Feltöltés a MÉRT kapacitásig: az új méret nem lépi túl a kapacitást,
// ezért ez még nem allokál újra, a raw érvényes marad.
payload.resize(payload.capacity(), 0x00);
show("resize(cap):", payload);
payload.push_back(0x00); // size == capacity volt: a szabvány szerint
show("push_back:", payload); // biztosan újraallokál
// write_bytes(raw, payload.size()); // HIBA: raw dangling pointer
write_bytes(payload.data(), payload.size()); // helyes: friss data()
payload.erase(payload.begin()); // erase sosem allokál újra
show("erase:", payload);
write_bytes(payload.data(), payload.size());
}A buildparancs rögzített nyelvi módot használ; a
-fsanitize=address,undefined kapcsoló a tanuló laborjában
opcionális, és a példa helyességét nem helyettesíti:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o cont_demo cont_demo.cpp
./cont_demoBuildjegyzőkönyv (lab-gcc profil). A
kimenet:
7f 45 4c 46
4 push_back: size=4 capacity=4
de ad be ef
resize(cap): size=4 capacity=4
push_back: size=5 capacity=8
de ad be ef 00
erase: size=4 capacity=8
ad be ef 00
A kapacitásértékek a lab-gcc profil libstdc++-ának mért
értékei, nem szabványos garanciák. A példa tanulsága nem a kimenet,
hanem a raw élettartama. A reserve(4) csak
capacity() >= 4-et garantál, pontosan 4-et nem; ezért a
demonstráció nem erre a számra épít, hanem a mért kapacitásig
tölti fel a vektort (resize(capacity()) — ez nem lépi túl a
kapacitást, tehát nem allokál újra). Ezután a
size() == capacity() állapotban a push_back a
szabvány szerint biztosan újraallokál, és a raw a régi
tárolóra mutat. Az erase soha nem allokál újra, a kapacitás
megmarad. Ha a raw-t használnánk, a viselkedés nem
definiált; a helyes minta az, hogy a mutáció után a
data()-t újra lekérjük. Ugyanez a szabály érvényes akkor
is, ha a vektort mozgatjuk vagy másoljuk: a korábbi pointer nem követi
az új tulajdonost.
A leggyakoribb hiba a reserve és a resize
összekeverése: a reserve után a size() még
nulla, ezért az indexelés nem definiált. A második a data()
pointer megőrzése olyan művelet után, amely újraelosztást okozhat; a
javítás az, hogy a pointer élettartamát a mutációhoz kötjük, vagy a
mutációt a pointer használata utánra tesszük. A harmadik az
erase ciklusban: a
for (auto it = v.begin(); it != v.end(); ++it) v.erase(it);
a törlés után érvénytelen iterátort léptet; helyes az
it = v.erase(it) vagy az erase–remove minta. A negyedik a
vector<bool> bool*-ként való átadása;
itt a proxy és a hiányzó data() a hiba forrása. Az ötödik a
std::array default-inicializálása és az elemek olvasása; a
{} használata value-initializál. A hatodik a
capacity() mint „használható elemek száma”; a
capacity() felső korlát, nem hossz. Végül a
std::array méretének sizeof-ból való
visszafejtése: a size() a mértékadó, a sizeof
paddingot is tartalmazhat.
A std::array fix méretű, értékszemantikájú aggregate,
amely megőrzi a hosszát, nem csúszik pointerré, és nem invalidálódik
újraelosztás miatt. A std::vector folytonos tároló,
amelynek size()-a a konstruált elemek száma,
capacity()-a az allokált hely, és a kettő között nem élnek
T objektumok. A reserve csak kapacitást ad, a
resize elemeket hoz létre vagy szüntet meg. A
push_back és az emplace_back újraelosztáskor
minden pointert, referenciát és iterátort invalidál; az
erase a törölt elemektől kezdve invalidál; a
clear mindent invalidál, de a capacity-t megtartja. A
vector<bool> speciális, proxy alapú tárolás, amely
nem ad folytonos bool tömböt. A tárolóválasztás tehát nem
Big-O-táblázat, hanem élettartam-, invalidációs és ownership-szerződés
kérdése.
1. feladat (diagnosztikai). Az alábbi kódrészletben
jelölje meg, mely pontokon nem definiált a viselkedés, és magyarázza el,
miért nem lehet a későbbi sorokra „visszamenőleg” következtetni. Tegyük
fel, hogy a reserve(8) ténylegesen legalább 8 elemre
foglal.
std::vector<int> v;
v.reserve(8);
v[0] = 42; // (1)
int* p = v.data();
v.push_back(1); // (2)
std::printf("%d\n", *p); // (3)2. feladat (kódolási). Írjon függvényt, amely egy
std::vector<int>-ből eltávolítja a negatív elemeket,
megtartja a többi elem sorrendjét, és a végén a vektort C API-nak adja
át. A megoldás ne okozzon invalidációs hibát, és ne növelje feleslegesen
a capacity-t. Adja meg a buildparancsot is.
3. feladat (tervezési). Tervezze meg egy
protokollüzenet adattagjait úgy, hogy a négybájtos mágikus fejléc
std::array, a változó hosszú tartalom
std::vector legyen. Írjon függvényt, amely a fejlécet és a
tartalmat egy C API-nak adja át, és sorolja fel, mely műveletek után
kell a pointereket újra lekérni. Térjen ki arra is, mi történik, ha az
üzenetet mozgatjuk.
1. megoldás. Az (1) pont nem definiált viselkedés: a
reserve(8) nem növelte a size()-t, az érték
nulla, ezért a v[0] olyan elemre hivatkozik, amely nem
létezik. A (2) pont önmagában nem okoz újraelosztást, mert a
size() 0-ról 1-re nő, és a capacity legalább 8; a
p tehát érvényes marad. A (3) pont ebben a konkrét
végrehajtásban az 1-es értéket olvasná, de a program már az (1) pontnál
nem definiált, ezért a további viselkedésére nincs garancia. Az
ellenőrzési feltétel: a válasz nevezze meg a size() és a
capacity() különbségét, és ne állítsa, hogy a (3) önmagában
UB, ha a (2) nem osztott újra.
2. megoldás. C++20-ban a legegyszerűbb helyes
megoldás a std::erase_if, amely az erase–remove mintát
csomagolja:
#include <cstddef>
#include <cstdio>
#include <vector>
void write_ints(const int* p, std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
std::printf("%d%c", p[i], i + 1 == n ? '\n' : ' ');
}
}
void remove_negatives(std::vector<int>& v) {
std::erase_if(v, [](int x) { return x < 0; });
}
int main() {
std::vector<int> v{3, -1, 4, -1, 5};
remove_negatives(v);
write_ints(v.data(), v.size());
}A std::erase_if az erase-et használja,
amely nem oszt újra, csak a törölt elemektől kezdve invalidál; a
v.data()-t a törlés után kérjük le, ezért a C API-hívás
érvényes. A capacity változatlan marad, a sorrend megmarad. Ellenőrzési
feltétel: a függvény után a vektor csak nem negatív elemeket tartalmaz,
a size() csökkent, a capacity() nem nőtt, és a
data() pointer csak a törlés után kerül felhasználásra.
C++20 előtti fallback:
v.erase(std::remove_if(v.begin(), v.end(), [](int x){ return x < 0; }), v.end());.
3. megoldás. Az üzenet típusa:
struct Message {
std::array<std::uint8_t, 4> magic{0x7f, 'E', 'L', 'F'};
std::vector<std::uint8_t> payload;
};A fejléc fix méretű, ezért magic.data() és
magic.size() stabil; a payload data() pointere
viszont minden olyan művelet után újra lekérdezendő, amely újraelosztást
okozhat: push_back, emplace_back,
insert, reserve (ha a capacity-t növeli),
resize (növeléskor), valamint shrink_to_fit.
Az erase és a clear szintén invalidál, de nem
oszt újra. Ha a Message-t mozgatjuk, a payload
belső puffere tipikusan átkerül az új objektumba, de a korábbi
payload.data() pointer a forrás objektum tárolójára mutat,
amely moved-from állapotba került; a pointer nem követi a tulajdonost.
Ezért a C API-hívást mindig a mozgatás után, az új objektumon kell
elvégezni. Ellenőrzési feltétel: a válasz nevezze meg a tulajdonost (a
Message és a benne lévő vector), a borrowed
pointert (data()), az invalidáló műveletet és a mozgatás
utáni új lekérdezést.