23.1 public/private/protected és invariánsvédelem

A korábbi részekben azt építettük fel, hogy egy osztály nem csupán adat és függvény együttese, hanem állapotgép, amelynek van megengedett állapothalmaza. Ezt az állapothalmazt nevezzük az osztály invariánsának (class invariant): olyan feltételrendszer, amelynek minden publikus művelet után teljesülnie kell, és amelyet minden publikus műveletnek meg kell őriznie. A mostani rész arra ad választ, hogyan kényszeríthető ki ez a megőrzés a nyelv eszközeivel, és miért nem azonos a hozzáférés-vezérlés a memóriavédelemmel. Az olvasónak érdemes felelevenítenie az osztály, konstruktor és invariáns rész definícióit, valamint a Rule of zero, three és five szabályát: a speciális tagfüggvények az invariáns fenntartásának a hordozói, nem pedig adminisztratív kellékek.

A hozzáférés-vezérlése nem biztonsági mechanizmus. Fordítási idejű névfeloldási szabály, amely azt mondja meg, ki nevezheti meg a tagot a forráskódban. Ha egy memóriablokk a folyamat címtartományában van, tulajdonképpen bárhonnan elérhető, ha a cím kiszivárgott; ezt a nyelv sem tiltja meg. A private a szerződést védi, nem a biteket.

23.1.1 A láthatóság mint invariáns-szerződés

Pontosítva: a hozzáférés-vezérlés (access control) szabályai fordítási idejű, statikus döntést hoznak arról, hogy egy adott tag neve egy adott fordítási ponton használható-e. A szabályok feltétele, hogy a tag létezzen, a hozzáférés vezérlése a fordítás során eldőljön, és a program jól formált legyen. Ha a hozzáférés megsértése miatt a program nem fordul le, az nem futásidejű hiba, hanem diagnosztika.

Az encapsulation ennél tágabb tervezési fogalom: az adat és a rá vonatkozó műveletek egy egységbe zárása úgy, hogy a belső reprezentáció a publikus interfész mögött szabadon változtatható legyen. A hozzáférés-vezérlés ennek az eszköze, nem maga a cél.

Egy CC osztály invariánsát jelöljük INVC(⋅)\mathrm{INV}_C(\cdot)-vel. A szerződés akkor tartható, ha:

  1. a konstruktor minden létrejött objektumra INVC\mathrm{INV}_C-t állít elő;
  2. minden publikus, nem const tagfüggvény bemenetére feltesszük INVC\mathrm{INV}_C-t, és kimenetére garantáljuk;
  3. a const tagfüggvényekre feltesszük INVC\mathrm{INV}_C-t, és nem módosíthatják azt;
  4. a destruktor csak érvényes állapotból fut le.

A hozzáférés-vezérlés akkor szolgálja ezt, ha a belső tagok kívülről nem nevezhetők meg, így a 2–3. pont megsértése fordítási hibát okoz.

23.1.2 class és struct alapértelmezett hozzáférése

A két kulcsszó között a szabvány szerint egyetlen különbség van: a class alapértelmezett taghozzáférése private, a struct-é public. Ugyanez érvényes az alapértelmezett öröklési módra is (class: private, struct: public), de az öröklés részletes taglalása a következő fejezet témája. A konvenció szerint a struct akkor hasznos, ha az adattagok együtt alkotnak egy érvényes értéket, és nincs mögötte rejtett állapot (például egy Point), a class pedig akkor, ha az invariáns fenntartásához tagfüggvények kellenek.

A lényeg: a különbség konvenció, nem nyelvi kényszer. Egy struct-ban is lehetnek private tagok, és egy class-ban is lehet minden tag public.

23.1.3 Tag-hozzáférés, this pointer és statikus tagok

A taghozzáférés három szintje:

Egy nem static tagfüggvényben a this egy mutató az aktuális objektumra. Nem const tagfüggvényben típusa C*C^*, const tagfüggvényben C* constC^*\text{ const} (az objektum const, a pointer nem). A this nem lvalue, nem módosítható, és nem lehet nullptr érvényes hívásnál. Statikus tagfüggvényben nincs this, ezért az nem érheti el a nem statikus adattagokat. Egy statikus adattag az osztály hatókörében él, egyetlen példányban; C++17-től inline static adattag fejlécben is definiálható.

23.1.4 Const tagfüggvény és ref qualifier

A const tagfüggvény ígéret: nem módosítja az objektum megfigyelhető állapotát. A mutable tag kivétel: az ilyen adattag const tagfüggvényből is írható (például cache vagy mutex). A const és nem const változat túlterhelhető, így a const objektumok csak az olvasó változatot látják.

A ref qualifier (&, &&) azt szabályozza, hogy egy tagfüggvény milyen értékkategóriájú objektumon hívható. Ez akkor fontos, ha a függvény referenciát ad vissza a belső állapotra: ha egy ilyen függvény ideiglenes objektumon is hívható, a visszaadott referencia azonnal dangling. A const & minősítés önmagában nem elég ehhez: a const lvalue-referencia ideiglenes objektumra (rvalue-ra) is köthető, ezért egy const & tagfüggvény rvalue-n is meghívható. (A nem const & minősítésű tagfüggvény valóban csak lvalue-n hívható, mert a nem const lvalue-referencia nem köt rvalue-ra; a két szabályt nem szabad összekeverni.) A referenciát visszaadó hozzáférőt ezért két overload alkotja: egy const & változat lvalue-ra, és egy const && = delete változat, amely a nem const és a const rvalue-n történő hívást is elkapja, mert rvalue-ra a && kötés a jobb. Alternatíva, hogy az rvalue-overload értéket ad vissza referencia helyett.

23.1.5 Friend: szűk együttműködés

A friend deklaráció egy konkrét függvénynek vagy osztálynak ad hozzáférést a nem publikus tagokhoz. Tulajdonságai: nem öröklődik, nem tranzitív, és nem adjuk „az egész világnak”. Indokolt eset: két szorosan együttműködő típus, vagy egy szabad operátor, amely az osztály reprezentációjára épül. A friend tehát egy tudatosan szűkített szerződéskiterjesztés, nem kényelmi eszköz.

23.1.6 Kidolgozott példa: BoundedBuffer és UniqueFd

Tekintsük a gyűrűpuffer invariánsát. Az INV(b)\mathrm{INV}(b) invariáns a következő három feltétel együttese:

  1. üres kapacitás esetén nincs tároló és nincs elem: capacity_=0⇒data_=nullptr,size_=0,read_=0;\text{capacity\_}=0 \;\Rightarrow\; \text{data\_}=\text{nullptr},\ \text{size\_}=0,\ \text{read\_}=0;
  2. az elemszám a kapacitáson belül van: 0≤size_≤capacity_;0 \le \text{size\_} \le \text{capacity\_};
  3. nem üres kapacitásnál az olvasási index érvényes pozíció: capacity_>0⇒0≤read_<capacity_.\text{capacity\_}>0 \;\Rightarrow\; 0 \le \text{read\_} < \text{capacity\_}.

Ha a size_, capacity_ és read_ adattagok publikusak lennének, egy hívó beállíthatná size_ = capacity_ + 1-re, és a következő push a data_[(read_ + size_) % capacity_] kifejezésben a tömbön kívülre írna. Ez a hozzáférés-vezérlés hiányának közvetlen következménye, nem a % operátoré.

// bounded_buffer.cpp — C++20, teljes program
#include <cassert>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <stdexcept>
#include <utility>

class BoundedBuffer {
public:
    explicit BoundedBuffer(std::size_t capacity)
        : data_{capacity ? new int[capacity] : nullptr},
          capacity_{capacity}, size_{0}, read_{0} {}

    ~BoundedBuffer() { delete[] data_; }

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

    BoundedBuffer(BoundedBuffer&& other) noexcept
        : data_{std::exchange(other.data_, nullptr)},
          capacity_{std::exchange(other.capacity_, 0)},
          size_{std::exchange(other.size_, 0)},
          read_{std::exchange(other.read_, 0)} {}

    BoundedBuffer& operator=(BoundedBuffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = std::exchange(other.data_, nullptr);
            capacity_ = std::exchange(other.capacity_, 0);
            size_ = std::exchange(other.size_, 0);
            read_ = std::exchange(other.read_, 0);
        }
        return *this;
    }

    std::size_t size() const noexcept { return size_; }
    std::size_t capacity() const noexcept { return capacity_; }
    bool empty() const noexcept { return size_ == 0; }
    bool full() const noexcept { return size_ == capacity_; }

    void push(int v) {
        if (full()) throw std::overflow_error{"push: full"};
        data_[(read_ + size_) % capacity_] = v;
        ++size_;
    }

    int pop() {
        if (empty()) throw std::underflow_error{"pop: empty"};
        const int v = data_[read_];
        read_ = (read_ + 1) % capacity_;
        --size_;
        return v;
    }

    // Kölcsönzött hozzáférés; élettartama a következő módosító műveletig tart.
    // Lvalue-n (const vagy nem const) hívható.
    const int& front() const & noexcept {
        assert(!empty());
        return data_[read_];
    }
    // Rvalue-n (const vagy nem const) tiltott: a const& változat is
    // köthetne ideiglenesre, és a visszaadott referencia dangling lenne.
    const int& front() const && = delete;

private:
    int* data_;            // tulajdonos: capacity_ darab int
    std::size_t capacity_;
    std::size_t size_;     // INV: size_ <= capacity_
    std::size_t read_;     // INV: capacity_ > 0 esetén read_ < capacity_
};

#define CHECK(condition) do {                                      \
    if (!(condition)) {                                            \
        std::fprintf(stderr, "CHECK failed at line %d\n", __LINE__); \
        std::exit(EXIT_FAILURE);                                   \
    }                                                              \
} while (0)

int main() {
    BoundedBuffer b{4};
    b.push(10);
    b.push(20);
    CHECK(b.front() == 10);           // lvalue: megengedett
    CHECK(b.pop() == 10);             // a pop NDEBUG mellett is lefut
    CHECK(b.size() == 1);

    BoundedBuffer moved = std::move(b);
    CHECK(moved.size() == 1);
    CHECK(b.size() == 0);
}

Fordítás és futtatás:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -o bounded_buffer bounded_buffer.cpp
./bounded_buffer

A tiltó overload hatását két negatív próba mutatja: mindkettő fordítási hiba, use of deleted function 'const int& BoundedBuffer::front() const &&' üzenettel.

BoundedBuffer make() { BoundedBuffer b{2}; b.push(7); return b; }
const int& r1 = make().front();             // nem const rvalue: hiba

const BoundedBuffer cb{2};
const int& r2 = std::move(cb).front();      // const rvalue: hiba

Buildjegyzőkönyv (lab-gcc profil). A program figyelmeztetés nélkül fordult, és 0 kóddal futott, -DNDEBUG mellett is; a két negatív próba a fenti hibával nem fordult. A const && = delete nélküli változatban az r1 olvasása ASan builddel heap-use-after-free hibát adott: a const & tagfüggvény az ideiglenes objektumon is lefutott.

A második példa egy FD-tulajdonos. Az invariáns: fd_ == -1 vagy fd_ >= 0, ahol a -1 a „nem birtokol” állapot. Ha a fd_ publikus lenne, egy hívó beállíthatná például -17-re, és a destruktor close(-17)-et hívna; vagy ami súlyosabb, beállíthatná egy másik objektum által birtokolt FD-re, és a program ugyanazt az FD-t kétszer zárná be. A hozzáférés-vezérlés itt a birtoklási szerződést védi.

// unique_fd.cpp — POSIX, illusztráció
#include <unistd.h>
#include <utility>

class UniqueFd {
public:
    explicit UniqueFd(int fd = -1) noexcept : fd_{fd} {}
    ~UniqueFd() { if (fd_ >= 0) ::close(fd_); }

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

    UniqueFd(UniqueFd&& o) noexcept : fd_{std::exchange(o.fd_, -1)} {}
    UniqueFd& operator=(UniqueFd&& o) noexcept {
        if (this != &o) { reset(); fd_ = std::exchange(o.fd_, -1); }
        return *this;
    }

    int get() const noexcept { return fd_; }
    explicit operator bool() const noexcept { return fd_ >= 0; }
    int release() noexcept { return std::exchange(fd_, -1); }

    void reset(int fd = -1) noexcept {
        if (fd_ >= 0) ::close(fd_);
        fd_ = fd;
    }

private:
    int fd_;   // INV: fd_ == -1 vagy fd_ >= 0
};

23.1.7 Az encapsulation nem memóriavédelmi határ

A private nem akadályozza meg, hogy egy mutatóval, reinterpret_cast-tal vagy memcpy-vel valaki hozzáférjen az objektum byte-jaihoz, és nem is garantál semmit a folyamatok vagy felhasználók között. Az osztály layoutja az ABI része: az offsetek ismertek, a sizeof és az alignment megfigyelhető. A hozzáférés-vezérlés azt szabályozza, milyen forráskód nevezheti meg a tagot, nem azt, hogy a futó program mit tehet. Ezért van az, hogy egy memóriahibás, UB-t tartalmazó programot a private nem ment meg, és egy public adattaggal rendelkező típus sem lesz önmagától hibás — csak az invariáns fenntartása kerül a hívó felelősségébe.

23.1.8 Tipikus hibák

23.1.9 Összefoglaló

A hozzáférés-vezérlés célja, hogy az osztály invariánsa a publikus interfészen keresztül kényszeríthető legyen. A class és struct között csak az alapértelmezett hozzáférés a különbség. A const tagfüggvények az olvasási szerződést, a ref qualifierek a referenciát visszaadó függvények élettartam-biztonságát, a friend pedig a szűk, indokolt együttműködést szolgálják. Az encapsulation nem memóriavédelem: a fordító a neveket ellenőrzi, a futó programot nem.

23.1.10 Feladatok

1. feladat (elemzés). Tegyük fel, hogy a BoundedBuffer-ben a size_ és read_ tagok protected-ek, és egy LoggedBuffer : public BoundedBuffer leszármazott közvetlenül írja őket. Mutassa meg egy konkrét értékadással, hogyan sérül az invariáns, és miért nem védi meg ezt a protected.

2. feladat (kódolás). Egészítse ki a BoundedBuffer-t std::size_t remaining() const noexcept és void clear() noexcept tagfüggvényekkel! Adja meg mindkettő előfeltételét és utófeltételét, és indokolja, hogy a clear() miért nem allokál.

3. feladat (diagnosztika). Egy kolléga a következő tagfüggvényt javasolja: int& front() noexcept { return data_[read_]; }. Döntse el, hogy megsérti-e a gyűrűpuffer számláló-invariánsát, és hogy milyen más szerződést sért meg. Javítsa a kódot!

23.1.11 Megoldások

1. megoldás. Az invariáns része: 0≤size_≤capacity_0 \le \text{size\_} \le \text{capacity\_} és read_<capacity_\text{read\_} < \text{capacity\_}. Ha a leszármazott size_ = capacity_ + 1-et ír, a következő push feltétele full() hamis lesz, és a data_[(read_ + size_) % capacity_] index a tömbön kívülre mutat. A protected ezt nem akadályozza meg, mert a leszármazott a saját típusán keresztül hozzáférhet. Helyes megoldás: az adattagok maradjanak private-ek, a leszármazott a publikus műveleteken keresztül dolgozzon, vagy kapjon az ős egy szűk, invariánst fenntartó védett műveletet (például pop_into), amelyet a leszármazott hívhat. Ellenőrzési feltétel: a leszármazott kódjában nem szerepel közvetlen írás a size_/read_ tagokra, és a push/pop továbbra is kivétellel jelzi a határállapotot.

2. megoldás. remaining() előfeltétele: INV(b)\mathrm{INV}(b); utófeltétele: capacity_ - size_, azaz a szabad helyek száma, és nem módosít állapotot, ezért const noexcept helyes. clear() előfeltétele: INV(b)\mathrm{INV}(b); utófeltétele: size_ == 0, read_ == 0, a capacity_ és a data_ változatlan. Mivel a tárhely mérete nem változik, elegendő a két számlálót nullázni; a delete[]/new[] pár felesleges lenne, és kivételt is dobhatna, ami noexcept mellett std::terminate-hez vezetne. Ellenőrzési feltétel: clear() után empty() igaz, capacity() változatlan, és a következő push az első helyre ír.

3. megoldás. A számláló-invariánst ez a függvény nem sérti meg: a size_, read_, capacity_ értékét nem módosítja, tehát 0≤size_≤capacity_0 \le \text{size\_} \le \text{capacity\_} továbbra is fennáll. Megsért viszont két másik szerződést. Egyrészt a const-ságot: nem const tagfüggvényről van szó, tehát const BoundedBuffer-en nem hívható, ami következetlen az olvasó műveletekkel. Másrészt a kölcsönzött hozzáférés élettartamát: a visszaadott int& a következő pop()-ig érvényes, utána egy másik elemre mutat, a hívó ezt könnyen elfelejtheti. Javítás: const int& front() const & noexcept és a tiltó const int& front() const && = delete; (ahogy a példában), ha pedig írható hozzáférés kell, akkor azt külön, dokumentált művelettel, például pop()-pal biztosítsuk. Ellenőrzési feltétel: a javított front() const objektumon is hívható, ideiglenes objektumon viszont nem fordul le, és a visszaadott referencia élettartama a dokumentációban szerepel.