20.2 Osztály, konstruktor és invariáns

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.

20.2.1 class és struct: alapértelmezett hozzáférés és szerződés

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 0≤value≤limit0 \le \text{value} \le \text{limit}; egy porttartománynál 0≤lo≤hi≤655350 \le \text{lo} \le \text{hi} \le 65535.

É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:

  1. a tároló megszerzése történhet automatikus, statikus vagy dinamikus úton;
  2. a konstruktornak inicializálnia kell minden tagot, majd vagy létre kell hoznia az invariánst, vagy kivételt kell dobnia;
  3. sikeres visszatérés után a nyilvános műveletek feltételezhetik az invariánst;
  4. ha a konstruktor kivétellel kilép, a teljes objektum soha nem jön létre, ezért a teljes objektum destruktora nem fut le.

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.

20.2.2 Tagfüggvény, this és cv-qualifier

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.

20.2.3 Member initializer list és a deklarációs sorrend

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.

20.2.4 Default, delegating és explicit konstruktorok

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

20.2.5 In-class member initializer, const és reference tagok

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.

20.2.6 A konstruktor hibája és a létre nem jött objektum

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.

20.2.7 Kidolgozott példa: TokenBucket teljes kóddal

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_bucket

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

20.2.8 Gyakori hibák és javításuk

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

20.2.9 Összefoglaló

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.

20.2.10 Feladatok

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 0≤value≤limit0 \le \text{value} \le \text{limit}. 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_;
};

20.2.11 Megoldások

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

Megjegyzé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.