19.3 Reference, pointer és const C++-ban

A C-ben megszokott pointeres API-k után a C++ referencia első ránézésre kényelmesebb szintaxist ad ugyanarra: egy másik objektumra hivatkozó név. A kényelem azonban nem jelent automatikus biztonságot. A referencia ugyanúgy élettartam-szerződést hordoz, mint a pointer, csak a szerződés egy részét a nyelv kényszeríti ki: a referencia létrejöttekor kötelező egy létező objektumhoz kötődnie, és később nem lehet másikhoz kötni. Azt viszont, hogy a hivatkozott objektum meddig él, a referencia nem tudja és nem is garantálja. Ez a fejezet ezt a kettősséget járja körül; a C++ programmodell és a zero-overhead elv C++ programmodell és zero-overhead elv (19.1), valamint a C pointeres const-szabályai const pointerek, több szintű indirection és void* (6.3) már ismertek.

19.3.1 A referencia szemantikája: T&, const T& és T&&

A referencia a nyelvi modellben nem önálló objektum, hanem egy már létező objektum alternatív neve. Nincs saját értéke, nincs „null értéke”, és a sizeof a referált típust adja vissza. Az, hogy a fordító a gépi szinten pointerként ábrázolja-e, implementációfüggő; a szerződés szempontjából a referencia névalias.

A három alapalak:

A referencia deklarációjában a & és && nem halmozható egymásra, és referencia-referencia nem létezik; a template-paramétereknél érvényes „referenciacollapse” külön szabály. Referenciatömb és referencia-pointer sem képezhető, de pointerre mutató referencia igen: int*&.

19.3.2 Referenciakötés és az újrakötés hiánya

A referenciakötés (reference binding) nem azonos az értékadással. A legfontosabb félreértés, hogy a referencia „átállítható”:

int a = 1, b = 2;
int& r = a;
r = b;        // a értéke lesz 2; r továbbra is az a objektumot nevezi

int* p = &a;
p = &b;       // p most b-re mutat; a változatlan

A r = b sor nem újrakötés, hanem a = b. A referencia a deklaráció után minden előfordulásában ugyanazt az objektumot jelöli. Ez a különbség adja az API-szerződés egyik fő választási szempontját: ha a függvénynek egy másik objektumra kell „átállnia”, pointer kell; ha a név jelentése rögzített, referencia.

Függvényparaméterként a void f(int& x) a hívó objektumát nevezi, nem másolatot készít. A void g(int x) másolatot kap, a void h(const int& x) nem másol, de nem is módosíthat. A referencia tag esetében a kötést a konstruktor inicializáló listájában kell elvégezni; az implicit copy assignment emiatt törölt lesz, mert a referenciatag nem assignolható:

struct Alias {
    int& r;
    explicit Alias(int& x) : r(x) {}
};
// Alias x(a), y(b); x = y;  // fordítási hiba: operator= törölt

Ez nem hiba, hanem szerződés: a referenciát tartalmazó típus alapból nem újraköthető. Ha mégis újraköthetőség kell, pointer tagot vagy std::reference_wrapper-t használunk, és dokumentáljuk az élettartamot.

19.3.3 Null és hiányzó érték: pointer, optional, szerződés

A T& szerződése: „kötelező, érvényes objektum”. A hívó nem adhat át nullptr-t, és a függvény nem is tesztelheti, hogy van-e érték. Ahol a hiányzó állapot megengedett, három reprezentáció jön szóba:

Szempont T& T* std::optional<T>
Lehet „nincs érték”? nem igen (nullptr) igen
Újraköthető? nem igen értékadással igen
Tulajdonol? nem nem (nyers pointer) a tárolt értéket igen
Élettartamot garantál? nem nem a saját példányára igen
Tipikus API-jelentés kötelező érvényes opcionális, nem tulajdonló opcionális érték

A std::optional<T> (C++17) akkor természetes, ha a „nincs” állapot értékszerű, és az érték másolása nem túl drága. A pointer akkor, ha a hívó tulajdonában lévő objektumra mutatunk, és a nullptr a hiányt jelzi. A std::reference_wrapper<T> opcionális referenciát ad, de élettartamot ez sem garantál. Egyik megoldás sem mentesít az alól, hogy a szerződésben rögzítsük: ki meddig él, és mely művelet invalidál.

19.3.4 Const correctness és logikai const

A const T& a hívó felé azt ígéri, hogy a függvény ezen a csatornán nem módosítja az objektumot. Ez nem azt jelenti, hogy az objektum örökre változatlan, és nem is azt, hogy más szálon nem módosítja valaki. A const csak a mi hozzáférési csatornánkat zárja le; a mögöttes objektum lehet nem-const, és más néven át módosítható. A const_cast a const eltávolítására csak akkor lenne „legális”, ha az eredeti objektum nem const; ha const, a módosítás nem definiált viselkedés. A gyakorlatban a const_cast írásra szinte mindig tervezési hiba.

A tagfüggvények const jelölése a logikai const (logical const) szerződése: a hívás nem változtatja meg a kívülről megfigyelhető állapotot. Cache, számláló vagy mutex miatt mégis módosulhat belső állapot; erre való a mutable:

class RequestCounter {
public:
    std::size_t count() const {
        ++calls_;          // mutable: a megfigyelhető állapot nem változik
        return calls_;
    }
private:
    mutable std::size_t calls_ = 0;
};

A mutable nem mentesít a szinkronizáció alól: ha több szál hívja a count()-ot, a calls_ módosítása adatverseny lehet. A const tehát nem szálbiztonsági garancia; erről RAII lockok, condition variable és wait predicate (34.2) és C++ atomics, atomic_ref és publication (34.3) szól részletesen.

19.3.5 Temporary binding és az élettartam-hosszabbítás előképe

A const T& és a T&& rvalue-ra is köthető. Ilyenkor a kifejezésből egy temporary objektum jön létre, és a referencia élettartamához igazodva hosszabbodhat meg a temporary élettartama (lifetime extension):

const std::string& ok = std::string("abc");   // a temporary élettartama megnyúlik
const int& number = 42;                        // ugyanígy

A hosszabbítás azonban nem minden esetre érvényes. Nem hosszabbít, ha a referencia egy függvény visszatérési értékeként érkezik, és a függvény már visszaadta a temporary-t; nem hosszabbít, ha a temporary egy tag-referenciához kötődik a konstruktorban; és nem hosszabbít, ha a temporary egy másik kifejezés része, amelynek eredménye referencia. A klasszikus csapda:

struct Holder {
    const std::string& ref;
    Holder() : ref(std::string("temp")) {}   // NEM hosszabbít
};

A Holder példány ref tagja a konstruktor után dangling (érvénytelen): a temporary a konstruktor végén megszűnik. A javítás értéktag (std::string ref;), vagy olyan tervezés, ahol a referált objektum élettartama igazoltan túléli a Holder-t. A temporary-k és value category-k részletes tárgyalása Temporary, value category és dangling (20.4).

19.3.6 A referencia nem tulajdonlás és nem élettartam-garancia

A referencia nem tulajdonol. Nem zárja le a fájlt, nem szabadítja fel a memóriát, nem hosszabbítja meg egy tároló élettartamát, és nem akadályozza meg a tároló invalidációját. A szerződés mindössze annyi: a referált objektumnak élnie kell a referencia használatáig. Ezt a szerződést a nyelv nem ellenőrzi.

std::vector<std::string> v{"a"};
const std::string& r = v[0];
v.push_back("b");        // újraallokálhat; r invalidálódhat
// std::cout << r;       // nem definiált viselkedés lehet

Ugyanígy dangling lesz a referencia, ha lokális objektumra mutat, ha a scope-ból kilépő objektumra hivatkozik, vagy ha egy std::move után „moved-from” állapotú objektumot használunk (utóbbi nem feltétlenül dangling, de az értéke nem specifikált; lásd Move constructor, moved-from állapot és std::move (22.3)). Az ownership kérdését a RAII és a smart pointerek tárgyalják RAII wrapper file descriptorhoz és lockhoz (21.2), unique_ptr és egyedi tulajdonlás (21.3); a referencia ezekhez képest mindig nem tulajdonló nézet.

19.3.7 Kidolgozott példa: egy API élettartam-szerződése

Az alábbi program egy fordítási egységben mutatja a helyes és a tanítási célból hibás eseteket. A bad_name() hívása szándékosan ki van kommentelve: a visszaadott referencia dangling, a viselkedés nem definiált, ezért nincs garantált kimenet.

#include <iostream>
#include <string>
#include <vector>

// Helyes: értéket adunk vissza, a hívó tulajdonol.
std::string make_name() { return "worker-01"; }

// Helyes: a referált objektum a hívó tulajdonában van.
const std::string& name_of(const std::string& owner) { return owner; }

// Tanítási példa: lokális objektumra visszaadott referencia.
[[maybe_unused]] const std::string& bad_name() {
    std::string local = "worker-02";
    return local;   // UB: local a visszatéréskor megszűnik
}

struct Endpoint {
    const std::string& host;   // nem tulajdonol
    explicit Endpoint(const std::string& h) : host(h) {}
};

int main() {
    std::string owner = "db-01";
    const std::string& alias = owner;   // ugyanaz az objektum
    owner += ":5432";                   // az alias ezt látja
    std::cout << alias << '\n';         // db-01:5432

    const std::string& tmp = make_name();  // élettartam-hosszabbítás
    std::cout << tmp << '\n';              // worker-01

    Endpoint ep(owner);                    // helyes: owner túlél
    std::cout << ep.host << '\n';          // db-01:5432

    std::vector<std::string> v{"a"};
    const std::string& r = v[0];
    v.push_back("b");                      // r invalidálódhat
    // std::cout << r;                     // UB lehet
    return 0;
}

A buildparancs és a futtatás a tanuló laborjában:

g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 ref_demo.cpp -o ref_demo
./ref_demo

A definiált rész várható kimenete három sor: db-01:5432, worker-01, db-01:5432. Ez nem mért eredmény, hanem a definiált viselkedésből következő illusztráció; a kikommentelt sorok futtatása nem definiált viselkedés, ezért ott kimenetről nem beszélhetünk. A fordító a bad_name-re figyelmeztethet (-Wreturn-local-addr), de a figyelmeztetés hiánya nem bizonyíték a helyességre.

19.3.8 Tipikus hibák és javításuk

19.3.9 Összefoglaló

A referencia névalias: létrejöttekor kötelező létező objektumhoz kötni, később nem lehet újrakötni, és nem lehet null. A T& a kötelező érvényes objektum, a const T& a nem módosító hozzáférés, a T&& az rvalue-kötés jelölése. A const T& és a T&& képes temporary élettartamát hosszabbítani, de csak közvetlen kötés esetén. A referencia nem tulajdonol, és nem garantál élettartamot: a dangling továbbra is a programozó szerződése. A pointer és a referencia közötti választás ezért API-szerződés kérdése: kötelező érvényes objektum esetén referencia, opcionális vagy újraköthető esetben pointer, értékszerű hiányzó állapot esetén optional.

19.3.10 Feladatok

  1. Magyarázó feladat. Magyarázd el, miért nem fordul le az int& r; deklaráció inicializálás nélkül, és mi a különbség az int& r = a; r = b; valamint az int* p = &a; p = &b; szemantikája között. Ellenőrizd a választ egy kis fordítható programmal.

  2. Kódolási feladat. Írj std::string join(const std::vector<std::string>& parts, const std::string& sep) függvényt, amely értéket ad vissza. Ezután tervezz egy Config osztályt, amelynek raw() tagfüggvénye const std::string&-t ad vissza. Dokumentáld, mi invalidálja a visszaadott referenciát.

  3. Diagnosztikai feladat. Az alábbi kódban hol keletkezik dangling, és miért? Javítsd a lehető legrobusztusabb módon.

struct Session {
    const std::string& user;
    Session() : user("anonymous") {}
};
  1. Tervezési feladat. Az Endpoint példában a host kötelező. Alakítsd át úgy, hogy a host hiányozhasson, és indokold, miért std::optional<std::string>-et, const std::string*-t vagy más reprezentációt választottál. Ne feledd az élettartam-szerződést.

19.3.11 Megoldások

19.3.11.1 1. megoldás

Az int& r; azért nem fordul le, mert a referencia deklarációjakor kötelező inicializálni: nincs „null referencia”, és nincs későbbi kötés. A r = b értékadás, nem újrakötés: az a értéke lesz b értéke, a referencia továbbra is a-t nevezi. A pointer ezzel szemben újraköthető: p = &b után p a b címét tartalmazza, az a változatlan. Ellenőrzés: a két programban írd ki a, b, r/*p értékét; a referencia esetén a megváltozik, a pointer esetén nem. A fordítási feltétel: g++ -std=c++20 -Wall -Wextra.

19.3.11.2 2. megoldás

#include <string>
#include <vector>

std::string join(const std::vector<std::string>& parts, const std::string& sep) {
    std::string out;
    for (std::size_t i = 0; i < parts.size(); ++i) {
        if (i != 0) out += sep;
        out += parts[i];
    }
    return out;
}

A join értéket ad vissza, a hívó tulajdonolja az eredményt. A Config::raw() const std::string&-je csak addig érvényes, amíg a Config példány él, és amíg a belső stringet nem módosítja vagy nem mozgatja el a Config destruktora vagy egy nem-const tagfüggvény. Ezt a dokumentációban rögzíteni kell; ha a hívó a Config megszűnése után is használja, dangling lesz. Ellenőrzés: a join eredményét egy blokk után is használva helyes; a Config referenciáját a Config megszűnése után használva nem definiált viselkedés.

19.3.11.3 3. megoldás

A "anonymous" string literálból a std::string temporary a konstruktor inicializáló listájában jön létre, és a konstruktor végén megszűnik. A user referencia ezért dangling lesz. A legrobusztusabb javítás értéktag:

struct Session {
    std::string user;
    Session() : user("anonymous") {}
};

Ekkor a Session tulajdonolja a stringet, és a user élettartama a Session élettartamához kötődik. Alternatíva egy static const std::string default_user = "anonymous"; és const std::string& user; kötése: ez nem dangling, de megosztott, és a Session példányok ugyanarra a statikus objektumra hivatkoznak, ami más élettartam-szerződést jelent. Ellenőrzés: Session s; std::cout << s.user; az értéktag változatban definiált; a referencia változatban nem definiált viselkedés.

19.3.11.4 4. megoldás

Ha a hiányzó host értékszerű, és a példány tulajdonolhatja a nevet, std::optional<std::string> host; a legegyszerűbb: kifejezi a „van/nincs” állapotot, és a Session élettartamához köti a stringet. Ha a hostot nem akarjuk másolni, és a hívó garantálja, hogy a referált objektum túléli az Endpoint-ot, akkor const std::string* host = nullptr; is működik; ekkor a nullptr a hiány, de az élettartamot továbbra is dokumentálni kell. A T& azért nem alkalmas, mert nem tud „nincs” állapotot képviselni. A választás indoklása a tulajdonlási igény és a másolási költség alapján történik; mindkét esetben rögzíteni kell, mi invalidálja a hozzáférést.