A C struct a korábbi részekben elsősorban memórialayout
és értékmásolás volt (Struktúrák, tagok és
értékmásolás (9.1)); a C++ ugyanezt a szerkezetet felhasználói
típussá bővíti, amelynek saját érvényességi szerződése, konstrukciós
folyamata és élettartama van. Ez az alfejezet azt a kérdést járja körül,
mitől „érvényes” egy C++ objektum, hogyan kell a konstruktornak
létrehoznia és megőriznie az invariánst, és milyen csapdák vannak a
taginicializálás sorrendjében. Az inicializálási formák taxonómiáját (Az inicializálás különböző alakjai (20.1)) itt nem
ismételjük meg; a hangsúly a konstruktor és az invariáns kapcsolatán
van.
A class és a struct a C++ban ugyanaz a
nyelvi konstrukció. A különbség kizárólag az alapértelmezett
hozzáférésben és az alapértelmezett öröklési hozzáférésben van:
class esetén a tagok és az öröklés alapértelmezésben
private, struct esetén public.
Mindkettő lehet konstruktoros, privát tagú, virtuális függvényes vagy
sablonparaméterként használt. A gyakorlati konvenció az, hogy
struct-ot passzív adatköteghez, class-t pedig
olyan típushoz használunk, amelynek van fenntartandó invariánsa.
Definíció. Az osztály invariánsa olyan
állítás a nem statikus adattagok értékéről és kapcsolatáról, amelynek a
konstruktor sikeres befejezése után minden nyilvánosan megfigyelhető
állapotban igaznak kell lennie, és amelyet minden nyilvános tagfüggvény
köteles megőrizni. Például egy Counter esetében az
invariáns lehet
;
egy porttartománynál
.
Érvényes objektum alatt itt azt értjük, hogy a tároló létrejött, az objektum élettartama elkezdődött, a konstruktor sikeresen visszatért, és az invariáns teljesül. A C++ memóriamodelljében a teljes osztályobjektum élettartama az inicializálás befejezésével, azaz a konstruktor sikeres visszatérésével veszi kezdetét. Az alobjektumok élettartama korábban is elkezdődhet, amikor a saját inicializálásuk befejeződik, de a teljes objektumra vonatkozó szerződés csak a konstruktor után érvényes.
Az érvényes objektum létrehozásának szerződése röviden:
Az invariánst a kódban is dokumentálni kell, például a class
fejlécében egy // Invariáns: ... kommenttel, és azt is meg
kell mondani, mely műveletek tartják fenn. Ez nem esztétikai kérdés: a
későbbi karbantartó abból tudja meg, mit nem szabad „csak óvatosan”
megkerülni.
A nem statikus tagfüggvény minden hívásban kap egy implicit
this pointert az aktuális objektumra. Nem
const tagfüggvényben this típusa
X*, const tagfüggvényben
const X*. Ezért const objektumon csak const
tagfüggvény hívható, és egy const tagfüggvény nem
módosíthatja a nem mutable adattagokat.
class Gauge {
public:
int value() const noexcept { return value_; } // this: const Gauge*
void set(int v) noexcept { value_ = v; } // this: Gauge*
private:
int value_{0};
};A const itt nem pusztán formai jelölés: az interfész
része. Ha egy tagfüggvény logikailag csak olvas, jelöljük
const-nak, mert így const objektumon és const referencián
keresztül is használható. A mutable az a szűk kivétel,
amikor egy gyorsítótár vagy diagnosztikai számláló módosítása nem számít
logikai állapotváltozásnak; ezt indokolni kell, nem alapértelmezésként
használni.
A tagok inicializálása nem a member initializer list sorrendjében,
hanem a nem statikus adattagok deklarációs sorrendjében
történik. Az alaposztályok ezt megelőzik, de az öröklési részleteket itt
nem tárgyaljuk. Ha a listában más sorrend szerepel, a fordító
figyelmeztethet (-Wreorder), a viselkedés viszont a
deklarációs sorrend marad.
class Window {
public:
Window(int begin, int length)
: begin_{begin}, length_{length}, end_{begin_ + length_} {}
private:
int begin_;
int length_;
int end_;
};Itt a deklarációs sorrend begin_, length_,
end_, ezért az end_{begin_ + length_}
kifejezésben mindkét korábbi tag már inicializált. A helyes sorrend nem
stílus kérdése: ha egy inicializáló kifejezés egy később inicializált
tagot olvas, a program nem definiált viselkedésű.
struct Overlap { // tanítási példa: hibás
int end; // előbb deklarált
int begin;
Overlap(int b, int len)
: begin{b}, end{begin + len} {} // UB: end initjekor begin még nem inicializált
};A javítás vagy a deklarációs sorrend megcserélése, vagy az
end kiszámítása a konstruktortestben. Ugyanígy hiba, ha egy
skalár tagot kihagyunk a listából és nincs osztályon belüli
inicializálója: az ilyen tag alapértelmezett inicializálása skalár
esetén határozatlan értéket eredményez. Osztály típusú tag esetén a
kihagyott tag az alapértelmezett konstruktorával jön létre.
A default konstruktor az a konstruktor, amely nulla
argumentummal hívható. Lehet implicit, = default-tal kért,
vagy = delete-tel tiltott. A = default azt
kéri a fordítótól, hogy generálja a szokásos tagonkénti viselkedést; ez
akkor hasznos, ha például az osztályon belüli inicializálók már minden
tagot ellátnak. A = delete világossá teszi, hogy bizonyos
konstrukció nem megengedett.
A delegáló konstruktor a member initializer listjében egy másik konstruktort hív meg:
class TokenBucket {
public:
explicit TokenBucket(std::uint32_t capacity)
: TokenBucket{capacity, capacity} {} // delegálás
explicit TokenBucket(std::uint32_t capacity, std::uint32_t initial)
: capacity_{capacity}, tokens_{initial} {
if (capacity_ == 0)
throw std::invalid_argument{"capacity must be > 0"};
if (tokens_ > capacity_)
throw std::invalid_argument{"initial > capacity"};
}
private:
const std::uint32_t capacity_;
std::uint32_t tokens_;
};Delegáláskor a célkonstruktor fut le először teljesen, utána a delegáló konstruktor teste. A delegáló lista csak magát a delegálást tartalmazhatja; más tagot ott nem lehet inicializálni. Ha a célkonstruktor kivételt dob, a delegáló konstrukció is meghiúsul, és a teljes objektum nem jön létre.
Az explicit konstruktor megakadályozza a nem kívánt
implicit konverziót. Egyargumentumú konstruktornál például az
explicit TokenBucket(std::uint32_t) tiltja a
TokenBucket b = 16; alakot, miközben a
TokenBucket b{16}; és a
static_cast<TokenBucket>(16) továbbra is működik.
Többargumentumú konstruktornál az explicit a
másoló-listainicializálást (TokenBucket b = {16, 8};) zárja
ki. Fő szabály: ha a konstruktor nem szándékolt típuskonverziót
valósítana meg, tegyük explicit-té.
Az osztályon belüli taginicializáló (in-class member initializer, gyakran NSDMI) a tag deklarációjánál megadott alapérték. Akkor használódik, ha az adott konstruktor member initializer listje nem említi a tagot; ha említi, a lista nyer.
class Sampler {
public:
explicit Sampler(Logger& log, unsigned period_ms = 1000)
: log_{log}, period_ms_{period_ms} {}
private:
Logger& log_; // nem tulajdonol; a Loggernek túl kell élnie
const unsigned period_ms_;
unsigned failures_{0}; // NSDMI, a listában nem szerepel
};A const tagot inicializálni kell; a konstruktortestben
már nem lehet értéket adni neki. A referencia tagot szintén a
konstrukciókor kell kötni, és később nem lehet átkötni. Mindkettő
következménye, hogy az implicit másoló értékadó operátor törlődik, mert
a const tag nem írható felül, a referencia tag pedig nem
köthető át. Ha az osztálynak értékadhatónak kell lennie, vagy nem
const/referencia tagot használunk, vagy saját értékadót
írunk.
A referencia tag élettartam-szerződése külön figyelmet érdemel. A
Sampler nem tulajdonosa a Logger-nek: a
Logger objektumnak túl kell élnie a Sampler-t.
Ha a referenciát ideigleneshez kötjük, az objektum élettartama után
dangling referenciánk marad. A member initializer listben egy
ideigleneshez kötött referencia tag esetében a temporary élettartama nem
hosszabbodik meg a teljes objektum élettartamára; ez tipikus, nehezen
észrevehető hiba. Ilyen esetben vagy érték szerint tároljunk, vagy
dokumentált tulajdonosi viszonyt és hosszabb élettartamú forrást
használjunk.
Ha a konstruktor teste kivételt dob, a teljes objektum destruktora nem fut le, mert a teljes objektum soha nem jött létre. A már teljesen felépített tagok és alaposztályok destruktorai viszont lefutnak, fordított sorrendben. Ha egy tag konstruktora dob, az addig felépített tagok destruálódnak, majd a kivétel továbbterjed. Ezért a konstruktorban szerzett nyers erőforrást azonnal RAII-tagba kell tenni, különben a kivétel utáni részleges lebontás nem fogja felszabadítani.
class TwoFiles { // részlet: a File egy RAII wrapper
public:
TwoFiles(const std::string& a, const std::string& b)
: first_{a}, second_{b} {
if (!first_.ok() || !second_.ok())
throw std::runtime_error{"open failed"};
}
private:
File first_;
File second_;
};Ha a second_ konstruktora dob, a first_
destruálódik. Ha a test dob, a second_, majd a
first_ destruálódik; a ~TwoFiles() nem fut.
Dinamikus létrehozásnál a new kifejezés a megfelelő
felszabadítást is elvégzi, de a konstruktorban nyitott nyers file
descriptor vagy malloc-olt blokk továbbra is elveszhet, ha
nem tag destruktora kezeli.
A konstruktor további fontos korlátja, hogy a this még
nem teljes objektumra mutat. Virtuális hívás a konstruktorban nem a
legderivedebb osztály verzióját választja, és a this
másoknak való átadása az invariáns felállítása előtt hibás publikálás. A
részletes öröklési és virtuális dispatch szabályokat a későbbi rész
tárgyalja; itt az a mérvadó, hogy a konstruktor ne tegye megfigyelhetővé
a félig felépített objektumot.
Az alábbi program önállóan fordítható. A TokenBucket
invariánsa: capacity_ > 0 és
tokens_ <= capacity_; a rejected_ számláló
nem része a szűkebb invariánsnak, de mindig nemnegatív.
#include <cstdint>
#include <iostream>
#include <stdexcept>
class TokenBucket {
public:
explicit TokenBucket(std::uint32_t capacity)
: TokenBucket{capacity, capacity} {}
explicit TokenBucket(std::uint32_t capacity, std::uint32_t initial)
: capacity_{capacity}, tokens_{initial} {
// Invariáns: capacity_ > 0 és tokens_ <= capacity_.
if (capacity_ == 0)
throw std::invalid_argument{"TokenBucket: capacity must be > 0"};
if (tokens_ > capacity_)
throw std::invalid_argument{"TokenBucket: initial > capacity"};
}
std::uint32_t capacity() const noexcept { return capacity_; }
std::uint32_t tokens() const noexcept { return tokens_; }
std::uint64_t rejected() const noexcept { return rejected_; }
bool take() noexcept {
if (tokens_ == 0) {
++rejected_;
return false;
}
--tokens_;
return true;
}
private:
const std::uint32_t capacity_;
std::uint32_t tokens_;
std::uint64_t rejected_{0};
};
int main() {
TokenBucket bucket{2};
const bool a = bucket.take();
const bool b = bucket.take();
const bool c = bucket.take();
std::cout << a << ' ' << b << ' ' << c
<< " rejected=" << bucket.rejected() << '\n';
}Fordítás a laborprofilban rögzített GCC vagy Clang verzióval:
g++ -std=c++20 -Wall -Wextra -Wpedantic -Wreorder token_bucket.cpp -o token_bucketA tagok deklarációs sorrendje capacity_,
tokens_, rejected_; a member initializer list
csak az első kettőt említi, a rejected_ az osztályon belüli
inicializálóból kap nullát. A konstruktor először inicializálja a
tagokat, majd ellenőrzi az invariánst; ha az ellenőrzés bukik, kivételt
dob, és a teljes TokenBucket nem jön létre. A forráskódból
levezetve az első két take() igazat, a harmadik hamisat ad,
a rejected_ értéke 1 lesz; ez nem mért futtatási eredmény,
hanem a kódból következő várt viselkedés, amelyet a laborban ellenőrizni
kell.
| Hiba | Következmény | Javítás |
|---|---|---|
| A member initializer list sorrendjét tekintjük tényleges sorrendnek | Egy később inicializált tag olvasása UB; -Wreorder
figyelmeztetés |
A deklarációs sorrendhez igazítani a listát, vagy a számítást a testbe tenni |
| Skalár tag kihagyása inicializálásból | Határozatlan érték | NSDMI vagy explicit inicializálás |
const vagy referencia tag mellett implicit értékadásra
számítunk |
Az értékadó operátor törölt | Saját értékadó, vagy pointer/érték tag |
| Referencia tag ideigleneshez kötése | Dangling a konstrukció után | Érték szerinti tárolás vagy hosszabb élettartamú owner |
| Nyers erőforrás a konstruktortestben | Kivétel esetén leak | RAII tag a member initializer listben |
Hiányzó explicit |
Nem kívánt implicit konverzió | explicit a konstruktoron |
this publikálása az invariáns előtt |
Félig ép objektum megfigyelése | Csak sikeres konstrukció után publikálni |
A C++ osztály érvényességi szerződése a konstruktorban jön létre: a
tagok deklarációs sorrendben inicializálódnak, a member initializer list
csak ezt a sorrendet írja le, nem módosítja. Az osztályon belüli
inicializáló akkor lép életbe, ha a konstruktor nem említi a tagot. A
const és referencia tagok kötelező inicializálást és
dokumentált élettartam-viszonyt jelentenek, valamint törlik az implicit
értékadást. A delegáló konstruktor centralizálja az invariáns
ellenőrzését; az explicit megakadályozza a nem kívánt
konverziót. Ha a konstruktor dob, a teljes objektum soha nem jön létre,
ezért a teljes objektum destruktora nem fut le, csak a már felépített
tagok destruktorai.
1. feladat (diagnózis). Az Overlap
példában miért nem definiált a viselkedés, és hogyan javítanád? Add meg
a javított deklarációs sorrendet vagy a javított konstruktort, és
indokold, hogy a javítás miért szünteti meg a hibát.
2. feladat (kódolás). Készíts Counter
osztályt, amelynek invariánsa
.
A limit legyen const int tag, a
value induljon nulláról osztályon belüli inicializálóval.
Az explicit Counter(int limit) dobjon
std::invalid_argument-ot negatív limitre. Legyen
bool increment(), amely a limit elérésékor
false-t ad, egyébként növel és true-t ad. Írd
ki az invariánst kommentben, és add meg a fordítási parancsot.
3. feladat (diagnosztika és javítás). Az alábbi
osztály nem fordul le. Magyarázd meg, miért, majd javítsd úgy, hogy a
Job csak érvényes id-vel és élő
Logger-rel jöhessen létre. Mi történne, ha a
Logger& helyett const Logger& tagot
használnánk, és a konstruktornak egy ideiglenes Logger-t
adnánk?
class Job {
public:
Job() = default;
int id() const { return id_; }
private:
const int id_;
Logger& log_;
};1. megoldás. A deklarációban end
megelőzi begin-t, ezért az inicializálás sorrendje is
end, majd begin, függetlenül attól, hogy a
lista begin-t írja előbb. Az end{begin + len}
inicializáló kifejezés még azelőtt olvassa a begin tagot,
hogy az inicializálódott volna; egy automatikus tárolójú
int határozatlan értékének olvasása nem definiált
viselkedés. Javítás: a begin legyen előbb deklarálva, vagy
az end értékét a konstruktortestben számítsuk ki:
struct Overlap {
int begin;
int end;
Overlap(int b, int len) : begin{b}, end{b + len} {}
};Ellenőrzési feltétel: a javított változat fordítható
-Wall -Wextra -Wpedantic -Wreorder kapcsolókkal, és nincs
olyan inicializáló kifejezés, amely korábban deklarált tagnál később
deklarálttól függ.
2. megoldás.
#include <stdexcept>
class Counter {
public:
explicit Counter(int limit) : limit_{limit} {
// Invariáns: 0 <= value_ <= limit_.
if (limit_ < 0)
throw std::invalid_argument{"Counter: negative limit"};
}
int value() const noexcept { return value_; }
int limit() const noexcept { return limit_; }
bool increment() {
if (value_ == limit_)
return false;
++value_;
return true;
}
private:
const int limit_;
int value_{0};
};A limit_ a member initializer listből, a
value_ az osztályon belüli inicializálóból kap értéket. A
konstruktor teste a tagok inicializálása után ellenőriz, és hiba esetén
dob, így érvénytelen Counter nem jön létre. Ellenőrzési
feltételek: Counter c{-1}; dobjon
std::invalid_argument-ot; Counter c{0}; esetén
c.increment() adjon false-t;
Counter c{1}; esetén az első hívás true, a
második false legyen, és c.value() maradjon 1.
Fordítás:
g++ -std=c++20 -Wall -Wextra -Wpedantic -c counter.cpp -o counter.oMegjegyzés: a const tag miatt a Counter
implicit értékadó operátora törlődik; ha értékadás kell, saját operátort
kell írni, vagy nem const tagot kell használni.
3. megoldás. A Job() = default olyan
alapértelmezett konstruktort kér, amelynek inicializálnia kellene a
const int id_ és Logger& log_ tagokat, de
erre nincs inicializáló. Ezért a defaulted konstruktor deleted lesz, és
a Job nem hozható létre. A javítás:
class Job {
public:
Job(int id, Logger& log) : id_{id}, log_{log} {}
int id() const noexcept { return id_; }
private:
const int id_;
Logger& log_;
};Így a Job csak akkor jön létre, ha a hívó átad egy
id-t és egy létező Logger-t; a
Logger tulajdonosa a hívó, és a Loggernek túl
kell élnie a Job-ot. Ha const Logger&
tagot használnánk, a Job j{1, Logger{}}; lefordulhatna, de
a tag egy olyan ideigleneshez kötődne, amely a teljes kifejezés végén
megszűnik, így a Job élettartama alatt dangling referenciát
tartana. Ellenőrzési feltétel: a javított osztály fordítható, a
Job j{1, logger}; érvényes, a Job j{}; pedig
nem fordul le.