A C-ben a dinamikus élettartam két külön döntés volt: a malloc, calloc, free és allokációs szerződés (8.1)
szerinti allokációs szerződés (malloc/free) és
a programozó fejében tartott konstrukciós szabály. A C++ ezt a kettőt
egyetlen nyelvi kifejezésbe, a new expressionbe zárja, de nem
olvasztja össze: az allokáció és az objektum létrehozása továbbra is két
külön lépés, csak egy kifejezés hajtja végre mindkettőt a megfelelő
sorrendben és a megfelelő hibakezeléssel. Ez a rész pontosan ezt a
szétválasztást tárja fel. Azért érdemes vele komolyan foglalkozni, mert
a C++ leggyakoribb erőforrás-hibái nem a delete
elfelejtéséből, hanem a nem megfelelő pár megválasztásából és a
konstruktorhiba félreértéséből fakadnak. A cél nem az, hogy jól
írjunk kézi new/delete-et, hanem hogy pontosan
értsük, mit vállal helyettünk a standard owner, amit a fejezet második
felében használni fogunk.
Három fogalmat kell élesen elkülöníteni, mert a köznyelv mindhármat „new”-nak hívja.
Az allokátorfüggvény (allocation function) egy
közönséges függvény, amelynek deklarációja
void* operator new(std::size_t). A feladata kizárólag az,
hogy nyers tárolót (storage) szerezzen, és visszaadjon egy pointert a
tároló kezdetére. Nem hoz létre objektumot, nem futtat konstruktort, és
nem tud semmit a majdani objektum típusáról – csak a kért méretről. A
párja a felszabadító függvény (deallocation function),
void operator delete(void*) noexcept, amely a tárolót adja
vissza; az élettartam lezárása nem a dolga.
A new expression a szintaktikai alak:
new T, new T(args...), new T[n].
Ez az, ami a kettőt összeköti, és amit a fordító állít elő. A
konstrukció pedig az az objektum-létrehozási lépés,
amelyet a new expression végez el a megszerzett tárolón – pontosan úgy,
ahogy a Tároló újrafelhasználása és C++
objektumszabályok (20.6) tároló-újrafelhasználási szabályai
leírják.
A gyakorlati következmény: operator new(sizeof(T))
önmagában nem hoz létre T-t. Nyers tárolót
ad, benne T élettartama nem kezdődött el, a pointeren
keresztüli T-ként való használat nem definiált
viselkedés.
Egy new T(args...) kiértékelése a következő sorrendben
történik:
sizeof(T)), tömb
esetén a darabszám figyelembevételével.nullptr-t adott vissza, a new expression eredménye
nullptr, és nem történik konstrukció.try blokkja elfelejtkezik róla.Egy delete p ennek a tükörképe: nullptr
esetén nincs hatás; egyébként lefut a destruktor, majd a felszabadító
függvény. Fontos korlát: a standard megengedi, hogy a fordító egy
replaceable globális allokátorfüggvény hívását elhagyja
(allocation elision), ha a tárolót máshonnan tudja biztosítani. Ezért
„az operator new minden new-ra lefut” nem
univerzális állítás; osztályhatókörű (static)
operator new esetén viszont nincs ilyen elhagyás, ezért a
diagnosztikai példákban azt használjuk.
A new T és a new T[n] két külön
allokátorfüggvényt hív (operator new és
operator new[]), és két külön felszabadítót vár
(operator delete és operator delete[]). A
keverés nem definiált viselkedés – nem „csak memóriaszivárgás”. A
tömb-alaknál a fordító a destruktorokat a konstrukció fordított
sorrendjében futtatja, és az implementáció jogosult arra, hogy az elemek
darabszámát a visszaadott pointer elé rejtett fejlécben tárolja. Az
ilyen könyvelés nem specifikált, tehát sem a fejléc meglétére, sem a
new T[n] és new T azonos címére nem
építhetünk.
| Tárolószerzés | Helyes felszabadítás | Destruktor fut? |
|---|---|---|
new T |
delete p |
igen |
new T[n] |
delete[] p |
igen, fordított sorrendben |
new (std::nothrow) T |
delete p |
igen |
new (buf) T (placement) |
p->~T() |
kézzel, a tároló nem szabadul fel |
std::malloc(n) |
std::free(p) |
nem |
Ha egy osztály saját operator new-t deklarál, de
operator new[]-t nem, akkor az new T[n] forma
fordítási hibát ad – ez szándékos védelem, nem hiányosság.
A dobó operator new szerződése: vagy érvényes,
megfelelően igazított tárolót ad vissza, vagy
std::bad_alloc-ot dob. Nem adhat vissza
nullptr-t; ha mégis megteszi, az szerződésszegés. Hibánál a
standard által beállított new-handler lánc fut le, amíg
valamelyik kezelő nem szabadít fel memóriát vagy nem dob.
A new (std::nothrow) T alak a
std::nothrow_t paraméterű allokátorfüggvényt hívja, amely
elkapja a bad_alloc-ot és nullptr-t ad. Ez a
védelem kizárólag az allokációra vonatkozik. Ha az
allokáció sikerül, de a konstruktor dob, akkor a new expression
felszabadítja a tárolót, és a kivétel továbbterjed – a
nothrow alak nem nyeli el. Ez a leggyakoribb félreértés
ezen a téren.
A példa osztályhatókörű allokátorral teszi láthatóvá a két lépést, és megmutatja, hogy a konstruktorhiba után a felszabadítás megtörténik.
// new_delete_demo.cpp — ISO C++20, GCC/Clang
#include <cstdio>
#include <cstdlib>
#include <new>
#include <stdexcept>
class Probe {
public:
static int allokacio;
static int deallokacio;
static int konstrukcio;
static void* operator new(std::size_t n) {
++allokacio;
std::printf(" operator new(%zu)\n", n);
if (void* p = std::malloc(n)) return p;
throw std::bad_alloc();
}
static void operator delete(void* p) noexcept {
++deallokacio;
std::printf(" operator delete\n");
std::free(p);
}
explicit Probe(int id) : id_(id) {
if (id < 0) throw std::runtime_error("negativ id");
++konstrukcio;
}
~Probe() = default;
int id() const noexcept { return id_; }
private:
int id_;
};
int Probe::allokacio = 0;
int Probe::deallokacio = 0;
int Probe::konstrukcio = 0;
int main() {
Probe* a = new Probe(1);
std::printf("a->id() = %d\n", a->id());
delete a;
try {
Probe* b = new Probe(-1); // allokacio sikeres, konstrukcio dob
(void)b;
} catch (const std::runtime_error& e) {
std::printf("kivetel: %s\n", e.what());
}
std::printf("allokacio=%d deallokacio=%d konstrukcio=%d\n",
Probe::allokacio, Probe::deallokacio, Probe::konstrukcio);
}Fordítás és futtatás a laborban:
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 \
new_delete_demo.cpp -o new_delete_demo
./new_delete_demoA forrás logikája alapján a várt kimenet (a tanuló laborjában ellenőrizendő, mérés nem történt):
operator new(4)
a->id() = 1
operator delete
operator new(4)
kivetel: negativ id
operator delete
allokacio=2 deallokacio=2 konstrukcio=1
Az utolsó sor a lényeg: két allokáció, két felszabadítás, de csak egy
konstrukció. A második operator delete nem a felhasználó
catch blokkjából jön, hanem a new expression
hibakezeléséből.
A klasszikus operator new(std::size_t) csak addig
garantál igazítást, amíg a típus igazítása nem nagyobb, mint a
__STDCPP_DEFAULT_NEW_ALIGNMENT__ makró értéke
(implementáció által meghatározott, x86-64 Linuxon jellemzően 16). C++17
óta a standard explicit, igazítást is átvevő alakokat vezetett be:
operator new(std::size_t, std::align_val_t) és a párja,
operator delete(void*, std::align_val_t), mindkettő a
<new> fejlécben. Ha egy típus alignof
értéke meghaladja a fenti korlátot
(struct alignas(64) CacheLine { std::byte b[64]; };), a
fordító automatikusan ezt az alakot választja, és a
felszabadításnál is a megfelelő párját hívja. Nekünk akkor van dolgunk,
ha kézzel hívunk allokátorfüggvényt, vagy ha malloc-alapú
pufferbe helyezünk over-aligned objektumot: a malloc
garantált igazítása a max_align_t-ig terjed, ami nem
feltétlenül éri el a 64-et, így a konstrukció nem teljesülő igazítási
feltétel mellett nem definiált. Ilyenkor std::aligned_alloc
vagy – ami egyszerűbb – közvetlen new CacheLine a helyes
választás.
A malloc, calloc, free és allokációs szerződés
(8.1) allokációs szerződése változatlanul érvényes: a
std::malloc nyers tárolót ad, a std::free
pedig felszabadítja azt. Egyik sem futtat konstruktort vagy destruktort,
és nem is tudna: nem ismeri a típust. A malloc-mal szerzett
memória delete-tel való felszabadítása és a
new-val szerzett pointer free-elése egyaránt
nem definiált viselkedés, még akkor is, ha egy adott implementáción
látszólag működik.
A két világ hídja a placement new:
new (ptr) T(args...), ahol a zárójelben egy
void* áll. Ez a forma nem hív allokátorfüggvényt, hanem a
megadott tárolón konstruál. A párja nem delete, hanem az
explicit destruktorhívás (p->~T()), mert a tároló
tulajdonosa valaki más. C++17-től a könyvtárban van erre
std::launder, C++20-tól pedig a
std::construct_at és std::destroy_at, amelyek
ezt a mintát biztonságosabban és constexpr-barát módon
fejezik ki.
// placement_demo.cpp — ISO C++20
#include <cstddef>
#include <cstdio>
#include <new>
struct Header { int version; int length; };
int main() {
alignas(Header) unsigned char buf[sizeof(Header)]; // nyers tároló
Header* h = ::new (static_cast<void*>(buf)) Header{1, 42};
std::printf("version=%d length=%d\n", h->version, h->length);
h->~Header(); // élettartam vége; a buf tároló továbbra is él
}Itt a buf a tároló tulajdonosa, a
h csak kölcsönzött hozzáférés egy ideig
létező objektumhoz; a buf élettartama nem ér véget a
destruktorhíváskor.
A fenti gépezet megértése szükséges, de a napi kódban nem kézzel
használjuk. A szabály egyszerű: ha egy T* érték a
new expressionből származik, akkor az
owner, és valakinek pontosan egyszer felelősséget kell
vállalnia érte. A legrövidebb út, hogy ez a „valaki” egy objektum
legyen, amelynek destruktora a megfelelő felszabadítást végzi – ez az
RAII, amelyet a RAII wrapper file descriptorhoz és
lockhoz (21.2) részletez nem memóriajellegű erőforrásokra.
auto p = std::make_unique<Probe>(1); – egyedi
tulajdonlás, delete a destruktorban.auto a = std::make_unique<Probe[]>(n); –
unique_ptr<Probe[]> típus, delete[]-t
hív. Figyelem: a make_unique a tömb elemeit
érték-inicializálja, ami eltér a new Probe[n]
default-inicializálásától.std::vector<Probe> v; – dinamikus tömbhöz ez az
első választás; növekedésnél mozgat, nem new[]-t hív
kézzel.Egy függvényparaméter Probe* alakja viszont jellemzően
borrowed view: nem tulajdonol, nem szabadít fel, és a
hívó felelőssége, hogy az objektum a hívás idejére érvényes legyen. Ezt
a szerződést a fejlécben és a nevekben is érdemes kimondani.
A leggyakoribb hiba a new T[n] és a
delete p párosítása, illetve a new T és a
delete[] p párosítása. Tünetük gyakran nem azonnali
összeomlás, hanem késői memóriakorrupció, ezért a sanitizerek (az ASan, UBSan, MSan, TSan és Valgrind (16.2) laborja)
nélkül nehéz tetten érni őket. Második gyakori hiba, hogy a szerző a
konstruktor kivételét „letakarja” egy
try/catch-csel, és a new
expression által már felszabadított tárolót kézzel próbálja
delete-elni – ez dupla felszabadítás. Harmadik: a
nothrow alakot úgy kezelni, mintha a konstruktorhibát is
elnyelné; nem nyeli el. Negyedik: over-aligned típushoz kézzel
malloc-olt puffert használni placement new-val, holott az
igazítás nem teljesül.
A new expression három dolgot köt össze: allokátorfüggvényt hív,
konstruál, és hiba esetén felszabadít. Az allokátorfüggvény és a
felszabadító függvény önmagukban csak nyers tárolót kezelnek, objektumot
nem. A skalár és tömb alak nem cserélhető fel, mert külön függvénypárt
és eltérő destruktor-sorrendet jelent. A dobó allokáció
bad_alloc-ot dob, a nothrow alak
nullptr-t ad, de a konstruktor kivételét egyik sem nyeli
el. C++17-től az over-aligned típusokhoz külön,
std::align_val_t-t átvevő allokátorfüggvény tartozik. A
malloc/free továbbra sem konstruál és nem
destruál; a hidat a placement new és az explicit destruktorhívás adja.
Éles kódban a kézi new/delete helyett
make_unique, make_unique<T[]> vagy
std::vector a választás, és minden T* esetében
expliciten meg kell mondani, hogy owner-e vagy borrowed view.
1. feladat (magyarázó). Írja le lépésről lépésre,
milyen műveletekből áll a new Probe(5) new expression, és
pontosan melyik lépés mit tesz, ha a Probe konstruktora
std::runtime_error-t dob. Hasonlítsa össze a
std::malloc(sizeof(Probe)) hívással: mely lépések maradnak
el, és hol kezdődik a Probe élettartama az egyes
esetekben?
2. feladat (kódolási). Adott N elemre
dynamikusan kell Sample tömböt létrehozni
(struct Sample { double v[8]; unsigned seq; };),
feltölteni, majd felszabadítani. Írja meg három változatban: (a) nyers
new[]/delete[], (b)
std::unique_ptr<Sample[]>, (c)
std::vector<Sample>. Mindháromhoz adja meg a pontos
felszabadítási pontot, és mondja meg, melyik változat mit garantál, ha a
feltöltés közben kivétel keletkezik.
3. feladat (diagnosztikai). Az alábbi két részlet hibás vagy félrevezető. Jelölje meg a hibát, indokolja, és adja meg a javítást.
void f(int n) {
Probe* p = new Probe[n];
g(p);
delete p; // (a)
}
struct alignas(64) CacheLine { std::byte b[64]; };
void h() {
void* raw = std::malloc(sizeof(CacheLine));
CacheLine* c = new (raw) CacheLine{}; // (b)
}1. megoldás. A new Probe(5) lépései:
(i) a fordító kiszámítja a sizeof(Probe) méretet; (ii)
kiválasztja a Probe::operator new(std::size_t)
allokátorfüggvényt, vagy annak hiányában a globálisat, és meghívja;
(iii) ha az allokátor dob, a new expression nem folytatódik, a kivétel
továbbterjed, tároló nem keletkezett; (iv) siker esetén a megszerzett
tárolón lefut a Probe(int) konstruktor, és ezzel
megkezdődik a Probe élettartama; (v) ha a konstruktor dob,
a fordító meghívja a megfelelő felszabadító függvényt, majd továbbengedi
a kivételt. A malloc esetén az (i) és (ii) lépés megvan, de
(iv) elmarad: nyers tárolót kapunk, Probe élettartama nem
kezdődik el, és a pointeren Probe-ként való hozzáférés nem
definiált. Ellenőrzési feltétel: a válasz nevezze meg, hogy a
konstruktorhiba esetén a felszabadítás a new expression része, nem a
hívó feladata.
2. megoldás. (a)
Sample* p = new Sample[n]; – a felszabadítás
delete[] p;. Ha a feltöltő ciklus kivételt dob, a
p elveszik, memóriaszivárgás keletkezik, hacsak a hívó
try/catch-csel nem felszabadít. (b)
auto p = std::make_unique<Sample[]>(n); – a
unique_ptr<Sample[]> destruktora
delete[]-t hív, tehát kivétel esetén is felszabadít; a
Sample-ek érték-inicializáltak lesznek. (c)
std::vector<Sample> v(n); – szintén kivételbiztos, és
a növekedést is kezeli. Mindhárom esetben a Sample-ek
destruktora lefut, mert a Sample triviálisan destruálható,
ez itt nem figyelhető meg; a lényeg a tároló felszabadítása. Ellenőrzési
feltétel: a válasz mindhárom változatnál nevezze meg az owner objektumot
és a felszabadítás pontos helyét.
3. megoldás. (a) A new Probe[n]
allokátorfüggvénye operator new[], a felszabadítója
operator delete[]; a delete p skalár
felszabadítót hív, ami nem definiált viselkedés, és a destruktorok sem
feltétlenül futnak le minden elemen. Javítás: delete[] p; –
vagy, ami jobb,
auto p = std::make_unique<Probe[]>(n);, illetve
std::vector<Probe>. (b) A std::malloc
csak max_align_t-ig garantál igazítást, ami jellemzően 16,
nem 64; a CacheLine igénye 64, ezért a placement new nem
teljesülő igazítási feltétel mellett hozza létre az objektumot, ami nem
definiált viselkedés. Javítás C++17-től:
CacheLine* c = new CacheLine{}; – a fordító automatikusan
az operator new(std::size_t, std::align_val_t) alakot hívja
–, vagy C API felé:
std::aligned_alloc(64, sizeof(CacheLine)) megfelelő
felszabadítással. Ellenőrzési feltétel: a válasz mindkét pontban nevezze
meg a konkrét szabálysértést (nem megfelelő felszabadító pár, illetve
nem teljesülő igazítás), és a javítás is tartalmazza a felszabadítás
helyes párját.