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.
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:
T&: nem-const lvalue referencia. Csak olyan
kifejezéshez köthető, amelynek van azonosított objektuma (lvalue), és
amelynek típusa T vagy T-vé konvertálható.
Temporaryhez nem köthető.const T&: const lvalue referencia. Lvalue-hez és
rvalue-hoz is köthető; rvalue esetén egy temporary jön létre, amelynek
élettartama a referencia élettartamáig hosszabbodhat.T&&: rvalue referencia. Nem-const rvalue-hoz
köt; lvalue-hoz nem. A nevezett T&& változó maga is
lvalue kifejezés, ezért kell a továbbadásához std::move;
ennek részletei a mozgatás témájában Move
constructor, moved-from állapot és std::move (22.3) következnek.
Template-paraméterként a T&& forwarding reference
is lehet, amit Forwarding reference és perfect
forwarding (26.3) tárgyal.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*&.
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áltozatlanA 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öltEz 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.
optional, szerződésA 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.
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.
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ígyA 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).
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 lehetUgyaní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.
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_demoA 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.
Holder-hez hasonló típusnál a temporary a konstruktor végén
megszűnik. Javítás: értéktag, vagy a referált objektum élettartamának
dokumentált garantálása.const T&, miközben
a tároló invalidálható. A hívó nem tudja, meddig érvényes a
referencia. Javítás: érték visszaadása, vagy a szerződés és az
invalidáló műveletek dokumentálása.const_cast írásra. Ha az eredeti
objektum const, a módosítás nem definiált viselkedés. Javítás: a const
helyes áttervezése, nem eltávolítása.const T& „biztonságos” feltevése szálak
között. A const csak a csatornát zárja; a mögöttes objektum más
szálon módosulhat. Javítás: szinkronizáció vagy atomikus műveletek.T& nem lehet null; ha a hiányzó érték megengedett,
pointer vagy std::optional<T> kell.auto és auto&
keverése. Az auto x = r; másolatot készít, az
auto& x = r; referenciát; a különbség a típuslevezetés
témája auto, decltype és típuslevezetés
(19.4).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.
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.
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.
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") {}
};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.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.
#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.
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.
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.