19.1 C++ programmodell és zero-overhead elv

A C++ nem „C osztályokkal”: a nyelv saját absztrakt géppel, objektum-élettartammal, értékszemantikával és típusokba zárt szerződésekkel dolgozik. Ez a rész azt mutatja meg, hogyan épül erre a C-ben megszerzett memória- és erőforrás-szemlélet, és miért nem lehet a C++ viselkedését pusztán a C szabályainak kiterjesztéseként megérteni. A C hordozhatósági és freestanding kérdéseit itt csak előfeltételként idézzük fel: C hordozhatósági audit és freestanding kitekintés.

19.1.1 A C++20 programmodell: objektum, érték, invariáns

A C++20 alapú program ugyanúgy fordítási egységekből épül fel, mint a C program, és a linkelés, a nyelvi kapcsolás (linkage) valamint az ODR szabályai határozzák meg, mi kerül a futtatható állományba. A futásidejű modell azonban több ponton eltér. A C++ban egy objektum élettartamának kezdete és vége kódot futtathat: a konstruktor felépíti az objektumot és az invariánsát, a destruktor felszabadítja az erőforrásait. Emellett a nyelv ismer referenciákat, túlterhelést, value categorykat, sablonokat és kivételeket. Ezek részletei külön részekben következnek; itt az érték, az invariáns és a tulajdonlás hármasa a lényeg.

Értékszemantika (value semantics). Egy típus értékszemantikájú, ha a példányai önálló értékként viselkednek: a másolás független példányt hoz létre, amely ugyanazt az absztrakt értéket képviseli; az értékadás felülírja a cél értékét; az egyenlőség az absztrakt értéken értelmezett. A beépített típusoknál ez természetes. Osztályoknál az alapértelmezett másolás tagról tagra történik, ami akkor helyes, ha a típusnak nincs közvetlenül kezelt, megosztott erőforrása. Egy nyitott file descriptorot vagy egy new-val szerzett puffert tartalmazó típus esetében a sekély másolás két tulajdonost hozna létre ugyanarra az erőforrásra, ami dupla felszabadításhoz vezet. Az ilyen típust vagy nem másolhatóvá kell tenni, vagy dokumentált, megosztott tulajdonlással kell ellátni.

Invariáns. Az invariáns olyan állítás a típus belső reprezentációjáról, amelynek minden nyilvános művelet előtt és után, valamint minden tagfüggvény belépésekor fenn kell állnia. A konstruktor feladata létrehozni; a destruktor csak érvényes állapotból futhat, de nem köteles az invariánst „továbbvinni”. Például egy fd_owner invariánsa lehet az, hogy a tárolt descriptor vagy -1, vagy egy nyitott, a folyamat által birtokolt file descriptor. Ha a descriptor értéke véletlenül egy másik objektum által már lezárt erőforrásra mutat, az invariáns sérül, és a további műveletek viselkedése nem definiált.

Tulajdonlás (ownership). A tulajdonos az az objektum, amelynek joga és kötelezettsége az erőforrás felszabadítása. Egy erőforrásnak egy adott időpontban egyértelmű tulajdonosa legyen, kivéve, ha a megosztott tulajdonlás szabályai dokumentáltak. A C-ben a tulajdonlás kézi: minden hibás kimeneti ágon free-re, fclose-ra vagy close-ra van szükség. A C++ban a tulajdonlást tipikusan egy objektumhoz kötjük, és a destruktorára bízzuk a felszabadítást.

Szempont C C++20
Erőforrás felszabadítása kézi, minden kimeneti ágon owner destruktorában, scope végén és unwind során
Másolás jellemzően byte-másolás vagy kézi mély másolás típus által meghatározott; owner gyakran nem másolható
Inicializálás értékadás, memset, függvényhívás konstruktor, taginicializálás, invariáns
Hibajelzés visszatérési érték, errno visszatérési érték, errno, kivétel, később expected
ABI-határ stabil C ABI névtorzítás, C++ ABI; extern "C" határ szükséges

19.1.2 A zero-overhead elv: cél, nem garancia

A C++ tervezési hagyományában gyakran így fogalmazzák meg a zero-overhead elvet: „amit nem használsz, azért ne fizess; amit használsz, azt ne tudd kézzel jobban megírni”. Ez kettős követelmény. Egyrészt a nem használt nyelvi képességek ne okozzanak kötelező futásidejű költséget. Másrészt egy használt absztrakció forduljon legalább olyan jó gépi kódra, mint egy egyenértékű, gondosan kézzel írt megoldás.

Ez tervezési cél és minőségi elv, nem univerzális teljesítménygarancia. A szabvány a program megfigyelhető viselkedését írja le, nem azt, hogy egy adott konstrukció hány utasításból vagy memóriahozzáférésből áll. A tényleges költség függ a fordítótól, az optimalizálási szinttől, a cél-ABI-tól, a standard library implementációjától, a beágyazástól és attól, hogy a fordító látja-e a függvény definícióját. Ugyanaz a forrás -O0 és -O2 mellett, illetve GCC és Clang alatt eltérő lehet.

A zero-overhead nem azt jelenti, hogy „nulla költség”. A std::vector növekedéskor allokálhat; a std::function típus-erasure miatt allokálhat; egy kivétel dobása drága lehet; a virtuális hívás indirekt. Azt jelenti, hogy ezek a költségek a használt funkcióhoz tartoznak, és nem feltétlenül nagyobbak, mint egy egyenértékű kézi megoldásé. A fordító például egy kis wrapper függvényt be tud inline-olni, egy std::sort hívást ugyanolyan hatékony gépi kódra tud fordítani, mint egy kézi rendezést, és a nem használt kivételkezelés nem feltétlenül lassítja a normál utat.

A C++ absztrakció akkor hasznos, ha az invariánst típusba zárja, a hibát korai fázisba viszi, a cleanupot determinisztikussá teszi, és a hot pathon a fordító ki tudja iktatni a burkolást. Akkor érdemes visszafogni, ha a mérés azt mutatja, hogy a költség nem elfogadható, ha az ABI-határ miatt nem lehet inline-olni, ha a fordítási idő vagy a kódoméret kritikus, vagy ha a csapat számára a karbantartási kockázat nagyobb, mint a nyereség. A döntést mérésnek, generated assemblynek és profile-nak kell alátámasztania, nem a „C++ úgyis gyors” feltételezésnek.

19.1.3 RAII: az erőforrás az objektum élettartamához kötve

A RAII (Resource Acquisition Is Initialization) magyarul leggyakrabban „erőforrás-életciklus kötése objektum-élettartamhoz”. A név félrevezető: nem az inicializálás a lényeg, hanem az, hogy az erőforrás megszerzése a konstruktorban, felszabadítása pedig ugyanazon objektum destruktorában történik. Az automatikus tárolási idejű objektumok a blokk elhagyásakor megszűnnek, normál return esetén és kivétel miatti stack unwinding során is. Így a felszabadítás nem függ attól, melyik return ágon jutunk ki.

A RAII helyes használatának feltételei: a konstruktor vagy teljesen felépíti az invariánst, vagy dob; a destruktornak nem szabad dobnia; az owner vagy nem másolható, vagy a másolása nem duplikálja a tulajdonjogot; és a vezérlés nem kerüli meg a destruktorokat. A std::exit nem futtatja az automatikus objektumok destruktorait, az abort szintén nem, a longjmp pedig nem definiált viselkedés, ha olyan automatikus objektumot hagyna ki, amelynek nem triviális destruktora van. A longjmp és a C++ kivételek keverése különösen veszélyes.

Bizonyításvázlat (RAII determinizmusa). Tegyük fel, hogy minden erőforrás-akvizíció egy automatikus objektum konstruktorában történik, és minden felszabadítás ugyanazon objektum destruktorában. A nyelv szabályai szerint az automatikus objektumok a blokk elhagyásakor megszűnnek, a konstrukció fordított sorrendjében, akár normál kimenet, akár kivétel miatti unwinding történik. Ezért a sikeresen felépült owner objektumok destruktorai lefutnak. Ha egy konstruktor dob, a már felépült tagok és bázisok destruktorai lefutnak, a teljes objektum destruktora nem; így a részlegesen felépült állapot nem szivárog. A garancia azonban csak a fenti feltételek mellett áll: nem másolható owner, nem dobó destruktor, és nincs longjmp/abort/std::exit általi megkerülés.

Az alábbi C változat minden hibás ágon kézi takarítást végez. A példa szándékosan egyszerű, és nem kezeli a fclose hibáit; a lényeg a cleanup-ágak számának növekedése.

#include <stdio.h>

int copy_file(const char *src, const char *dst) {
    FILE *in = fopen(src, "rb");
    if (!in) return -1;
    FILE *out = fopen(dst, "wb");
    if (!out) { fclose(in); return -1; }

    char buf[4096];
    size_t n;
    while ((n = fread(buf, 1, sizeof buf, in)) > 0) {
        if (fwrite(buf, 1, n, out) != n) {
            fclose(in);
            fclose(out);
            return -1;
        }
    }
    int rc = ferror(in) ? -1 : 0;
    fclose(in);
    fclose(out);
    return rc;
}

A C++ változatban a két FILE* egy-egy owner objektumba kerül. Ha a második fopen sikertelen, az első owner destruktora lefut; ha írás közben kivétel keletkezik, mindkét owner destruktora lefut.

#include <cstddef>
#include <cstdio>
#include <stdexcept>

class file {
public:
    explicit file(const char* path, const char* mode)
        : f_{std::fopen(path, mode)} {
        if (!f_) throw std::runtime_error{"fopen failed"};
    }
    ~file() { if (f_) std::fclose(f_); }

    file(const file&) = delete;
    file& operator=(const file&) = delete;

    std::FILE* get() const noexcept { return f_; }

private:
    std::FILE* f_;
};

void copy_file(const char* src, const char* dst) {
    file in{src, "rb"};
    file out{dst, "wb"};

    char buf[4096];
    while (std::size_t n = std::fread(buf, 1, sizeof buf, in.get())) {
        if (std::fwrite(buf, 1, n, out.get()) != n)
            throw std::runtime_error{"write failed"};
    }
    if (std::ferror(in.get()))
        throw std::runtime_error{"read failed"};
}

A példa nem lett lefuttatva; a következő parancsok a tanuló laborjában ellenőrizhetik, hogy a fordítási egység a választott compilerrel és standard libraryvel elfogadható:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -c copy_file.cpp -o copy_file.o
clang++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -c copy_file.cpp -o copy_file.o

Itt érdemes megjegyezni egy fontos árnyalatot: a RAII determinisztikus felszabadítást ad, de nem feltétlenül hibajelzést. A fclose hibát jelezhet, például flush közben; a destruktor nem dobhat, ezért a zárás hibáját nem tudja a hívó felé továbbítani. Ilyenkor a helyes szerződés gyakran egy explicit close() tagfüggvény, amely jelzi a hibát, a destruktor pedig csak best-effort fallback. Ez a minta később a file descriptor, a lock és a socket wrapperénél is visszatér.

19.1.4 Ökoszisztéma, implementációk és a C-ből át nem vihető szabályok

A könyv alapja a C++20, explicit -std=c++20 kapcsolóval. A C++23 használatát külön jelölni kell, és nem elég a nyelvi támogatást ellenőrizni: a compiler és a standard library támogatása két külön dolog. A feature-test makrók (__cpp_..., __cpp_lib_...) és egy kis compile probe segítenek, de a konkrét értékek és elérhetőség a laborban rögzített compiler- és library-verziótól függenek. A C++23-as kódot tehát jelölt, ellenőrzött kiterjesztésként kezeljük, nem a C++20 alap részeként.

A g++ és a clang++ fordítási driver, nem maga a standard library. GCC esetén jellemzően a libstdc++, Clang esetén Linuxon gyakran szintén a libstdc++, de konfigurálható libc++ használatára is; a -stdlib=libc++ Clang-specifikus kapcsoló. A standard library verziója és a compiler verziója együtt határozza meg, milyen fejlécek, feature-ök és ABI-k érhetők el. A laborban ezért rögzíteni kell a compilerverziót, a library implementációját és a fontos buildkapcsolókat; a -v és a feature-test külön ellenőrzés lehet.

A standard library a nyelvhez tartozó, hordozható alapot adja, de nem független a implementációtól. Harmadik féltől származó könyvtárak, például Boost, fmt vagy Abseil, újabb funkciókat, más ABI-t és külön dependency-kezelést hozhatnak. Használatukkor nem csak a funkcionalitást kell értékelni, hanem a licencet, a supply chain kockázatot, a bináris kompatibilitást és a build-időt is. A standard library nem feltétlenül „ingyenes” a bináris méret vagy az ABI szempontjából, a third-party pedig nem feltétlenül rosszabb; a döntés mérnöki trade-off.

A C-ből C++ba átlépve néhány szabály nem vihető át változtatás nélkül. A void* nem konvertálódik implicit módon T*-ra, explicit cast kell. A string literál C++ban const char[N], ezért char*-ra konvertálni nem lehet. A változó hosszúságú tömb (VLA) nem standard C++; helyette dinamikus tárolót vagy standard tárolót kell használni. A malloc/free nem helyettesíti a new/delete-et olyan típusnál, amelynek nem triviális konstruktora vagy destruktora van: a tároló megszerzése és az objektum élettartamának megkezdése C++ban két külön dolog. Nem triviális típuson memcpy használata nem definiált viselkedés; a C++ ABI-határon pedig extern "C" szükséges a névtorzítás elkerüléséhez. Ezek részletei a referenciák, az inicializálás és az ABI-fejezetekben következnek.

19.1.5 Gyakori hibák

Gyakori tévedés a „C++ = C + osztályok” modell: ebből következik, hogy valaki memcpy-vel másol nem triviális objektumot, vagy malloc-kal próbál életet adni egy std::string-nek. Ugyanilyen hiba a zero-overhead elvet teljesítménygaranciának tekinteni, és mérés nélkül azt állítani, hogy egy absztrakció „ingyen van”. A RAII-t sokan azonosítják a smart pointerekkel, holott a lock, a file descriptor és a socket is RAII-val kezelhető. Végül: a destruktor nem univerzális hibakezelő; a zárás hibáját külön szerződésben kell jelenteni, ha a hívónak tudnia kell róla.

19.1.6 Feladatok

  1. Érvelő feladat. Egy kolléga azt állítja, hogy a std::vector<int> push_back művelete a zero-overhead elv miatt garantáltan gyorsabb, mint egy kézzel kezelt dinamikus tömb. Fogalmazd meg, milyen feltételek mellett lehet igaz vagy hamis az állítás, és milyen mérést végeznél.

  2. Kódolási feladat. Egészítsd ki a fejezet file osztályát egy close() tagfüggvénnyel, amely a fclose hibáját jelzi, és biztosítja, hogy a destruktor ne zárja le másodszor ugyanazt a streame-t. Írd le a szerződést és a lehetséges kivételt.

  3. Diagnosztikai feladat. Az alábbi tanítási példa szándékosan hibás; ne futtasd. Magyarázd meg, miért nem definiált a viselkedése, és javítsd úgy, hogy egyértelmű tulajdonos legyen.

struct owner {
    int* p;
    explicit owner(int* q) : p{q} {}
    ~owner() { delete p; }
};

void f() {
    int x = 0;
    owner a{&x};
    owner b = a;
}

19.1.7 Megoldások

  1. Megoldás. A zero-overhead elv nem mondja ki, hogy a std::vector minden körülmények között gyorsabb. Azt mondja, hogy a használt absztrakció ne legyen rosszabb egy egyenértékű kézi megoldásnál, és a nem használt funkciókért ne fizessünk. A vector amortizált növekedést, allokációt és invalidációt végez; a kézi tömb is ugyanezt teheti. A helyes összehasonlítás azonos műveletsor, azonos optimalizálási szint és azonos allocator mellett történik, és méri a futásidőt, az allokációk számát, a csúcsmemóriát és a generált kódot. A válasz tehát: az állítás feltételek nélkül hamis; mérés és a vector szerződésének ismerete nélkül nem dönthető el.

  2. Megoldás. A close() végezze el az explicit zárást, állítsa nullptr-ra a pointert, és hiba esetén dobjon vagy jelezzen visszatérési értékkel. A destruktor csak akkor zárjon, ha a pointer még nem nullptr; így a sikeres close() után nem lesz második fclose. A szerződés: close() után az objektum „nem birtokol streamet”, a destruktor no-throw marad. Ha a fclose hibát jelez, a stream a gyakorlatban már le van zárva; a pointert ettől függetlenül nullázni kell a dupla zárás elkerülésére. A hiba továbbítására például std::runtime_error vagy egy külön close_result használható; ezt a későbbi hibakezelési rész részletezi.

  3. Megoldás. A hiba kettős: a delete p egy stacken lévő int címére mutat, ami nem definiált viselkedés; ráadásul a b = a sekély másolással ugyanazt a pointert másolja, így a két destruktor ugyanazt az erőforrást próbálja felszabadítani. A javítás egyik változata a másolás törlése és dinamikus allocating használata:

class owner {
public:
    explicit owner(int* p) : p_{p} {}
    ~owner() { delete p_; }
    owner(const owner&) = delete;
    owner& operator=(const owner&) = delete;

private:
    int* p_;
};

void f() {
    owner a{new int{42}};
}

Az ellenőrzési feltétel: a tulajdonos egyértelmű, a másolás nem duplikálja a tulajdonjogot, és a destruktor csak egyszer szabadít fel. A hosszú távú megoldás később a std::unique_ptr, amely ugyanezt a szerződést fejezi ki.

19.1.8 Összefoglaló

A C++ programmodellje a C objektum- és memóriamodelljére épül, de az élettartamhoz konstruktort és destruktort rendel. Az értékszemantika önálló példányokat, az invariáns típusba zárt szerződést, a tulajdonlás pedig egyértelmű felszabadítási felelőst jelent. A RAII ezt úgy valósítja meg, hogy az erőforrást egy automatikus objektum élettartamához köti; ez determinisztikus cleanupot ad, de nem csodaszer: a destruktor nem dobhat, és a zárás hibáját külön kell jelenteni. A zero-overhead elv hasznos tervezési irány, amely szerint a nem használt funkciókért nem fizetünk, a használt absztrakció pedig közel azonos kódot érhet el a kézi megoldással; ez azonban nem garancia, hanem feltételekkel és méréssel ellenőrizhető cél.