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.
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 |
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.
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.oItt é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.
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.
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.
É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.
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.
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;
}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.
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.
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.
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.