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.
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 osztály invariánsát jelöljük -vel. A szerződés akkor tartható, ha:
const tagfüggvény bemenetére
feltesszük
-t,
és kimenetére garantáljuk;const tagfüggvényekre feltesszük
-t,
és nem módosíthatják azt;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.
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.
A taghozzáférés három szintje:
private: csak a saját osztály tagjai és a barátok
(friend) férnek hozzá.protected: a saját osztály tagjai, a barátok, valamint
a leszármazott osztályok tagjai és barátai. Fontos megszorítás: a
leszármazott protected taghoz csak a saját típusán (vagy
abból származó típuson) keresztül fér hozzá, egy
Base&-en keresztül nem. Ez a szabály megakadályozza,
hogy egy leszármazott tetszőleges Base-objektum belsejébe
belenyúljon.public: bárki.Egy nem static tagfüggvényben a this egy
mutató az aktuális objektumra. Nem const tagfüggvényben
típusa
,
const tagfüggvényben
(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ó.
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.
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.
Tekintsük a gyűrűpuffer invariánsát. Az invariáns a következő három feltétel együttese:
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_bufferA 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: hibaBuildjegyző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
};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.
protected adattag használata tesztelhetőség ürügyén: a
leszármazottak közvetlenül írhatják, az invariáns kettétörik, és a
töréspont nehezen lokalizálható.const tagfüggvény, amely nem const
referenciát vagy pointert ad vissza a belső állapotra; ezzel a
const ígéret formálisan teljesül, tartalmilag nem.front() ref qualifier nélkül, vagy csak
const & minősítéssel: ideiglenes objektumon is hívható,
a visszaadott referencia azonnal dangling. Javítás: a
const && overload törlése.friend kiterjesztése egész osztályra, amikor egyetlen
szabad operátorra lett volna szükség.access control összekeverése a biztonsággal: a
„privát” adat nem titkos, csak nem nevezhető meg a forrásban.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.
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!
1. megoldás. Az invariáns része:
és
.
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:
;
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:
;
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
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.