21.1 new/delete és az allokáció-konstrukció kapcsolat

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.

21.1.1 A new expression anatómiája

21.1.1.1 Allokátorfüggvény, konstrukció, new expression

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.

21.1.1.2 A new és a delete expression lépései

Egy new T(args...) kiértékelése a következő sorrendben történik:

  1. A fordító kiszámítja a kért méretet (sizeof(T)), tömb esetén a darabszám figyelembevételével.
  2. Kiválasztja és meghívja az illeszkedő allokátorfüggvényt. Over-aligned típus esetén ez az igazítást is átvevő alak lesz (lásd később).
  3. Ha az allokátorfüggvény nem dobó alakú és nullptr-t adott vissza, a new expression eredménye nullptr, és nem történik konstrukció.
  4. A megszerzett tárolón lefut a konstruktor.
  5. Ha a konstruktor kivételt dob, a new expression meghívja az allokátorfüggvényhez tartozó felszabadító függvényt, majd továbbengedi a kivételt. A tároló tehát nem szivárog, akkor sem, ha a felhasználó 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.

21.1.2 Skalár és tömb alak, helyes párosítás

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.

21.1.3 Allokációs hiba, nothrow és konstruktor-kivétel

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.

21.1.3.1 Kidolgozott példa: allokáció és konstrukció számlálása

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_demo

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

21.1.4 Igazítás tudatos allokáció C++17-tő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.

21.1.5 A malloc/free nem végez C++ konstrukciót

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.

21.1.6 Kézi kezelés helyett tulajdonos

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.

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.

21.1.7 Tipikus hibák és javításuk

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.

21.1.8 Összefoglaló

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.

21.1.9 Feladatok

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)
}

21.1.10 Megoldások

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.