Tekintsük az alábbi, egyszerű, matematikai függvényt.
f(x) = x * y
Ez egy egyváltozós függvény, azaz egy paramétere
van. A függvény értéke egy kifejezés értéke, amely az x
paramétertől
és az y
változó értékétől függ.
A két név, x
és y
szerepe a függvényben nagyon különbözik.
Az x
egy ún. kötött változó (bound variable), amely egy olyan nevet ad meg,
amely a függvényhívás pillanatában kötődik valamilyen értékhez.
Az x
nevet a teljes függvénydefinícióban kicserélhetjük bármilyen másik névre
anélkül, hogy a definíció jelentése megváltozna:
f(w) = w * y
A kötött változó által reprezentált érték teljesen független a környezettől, minden egyéb definíciótól – természetesen amíg nincs névütközés. A kötés viszont csak a függvény belsejében érvényes; a névnek csak abban a kódrészletben van meg ez az értelme (bound within that context).
Nincs ez így az y
esetében, amely egy szabad változó
(free variable). Ha azt kicseréljük egy másik névre, egy egészen más függvényt
kapunk:
f(x) = x * a
A szabad változók, mint itt az y
vagy az a
, értéke is
kell valahonnan származzon. Legegyszerűbben egy másik függvényből, ha mondjuk az f()
függvényt egy külső függvény belsejében definiáljuk. Jelöljük ezt valahogy
így:
g(y) = { f(x) = x * y, ... }
A ...
-tal jelölt helyen kiértékelve pl. az f(2)
kifejezést,
x
értéke kötött lesz f()
miatt, y
értéke pedig
g()
miatt.
A függvény definíciója tehát nem csinál mást, mint megmondja, hogy az adott kifejezés kiértékelésekor mely változónevek a kötöttek, és melyek a szabad változók. A C programozáshoz szokott gondolkodásunknak ez furcsa, de a különféle élettartamú és láthatóságú változókra (automatikus, statikus, dinamikus, lokális, globális) elméletben semmi szükség nincsen. A létezésüknek csak gyakorlati okai vannak, amik mind-mind implementációs kérdések, nem pedig elméleti jelentőségűek.
f(x) = x * y
x
: kötött változó (bound variable).
y
: szabad változó (free variable).
A gépünk memóriája véges, ezért szeretnénk tudni, mikor, melyik változókra nincsen már szükség a függvények kiértékelése során. Alan Turing jött rá arra, hogy a kötött változók egy lineáris verem adatszerkezettel kezelhetők. Amikor belép a függvény törzsébe a végrehajtás, létrejön a változó (kötött név); a függvény hívójához pedig nem lehet úgy visszatérni, hogy ne szűnne meg a változó (újra szabaddá válik a név). Ez pontosan megfelel egy verembe történő beszúrásnak (push) és az abból való törlésnek (pop). Turing először ezeket a művelteket BURY-nek és UNBURY-nek nevezte (eltemetni, kiásni).
A helyzet azonban nem ilyen egyszerű. Gondolhatnánk, hogy mindig a verem legtetején találjuk meg a változókat, de ez nincs így.
A „downwards funarg” probléma
Tekintsük az alábbi, Hérón-féle módszerrel gyökvonást végző algoritmus JavaScript kódját. A módszer lényege: addig finomítjuk a tippelt gyököt, amíg elég jó nem lesz. A finomítás egy átlagolással végezhető el, mert az igazi gyök a tipp és szám/tipp között van.
function heron(x) {
function good_enough(guess) {
return Math.abs(guess*guess - x) < 0.001;
}
function improve(guess) {
return (guess + x/guess)/2.0;
}
var guess = 1.0;
while (!good_enough(guess))
guess = improve(guess);
return guess;
}
Amikor meghívunk egy függvényt, a veremben keletkezik egy keret (stack frame,
vagy más néven activation record). Ez kerül a verem tetejére, és ez tartalmazza a
kötött nevekhez tartozó értékeket. Elsősorban a változókat itt keressük. A fenti
kódban azonban a heron()
függvénynek van két belső függvénye is.
Ezek látják a külső függvény változóit, mert azon belül lettek
definiálva (lexical scoping). Ha pl. meghívjuk az improve()
függvényt, ahhoz is létrejön majd egy keret. Így már összesen két keretünk van.
Az improve()
függvényen belüli guess
a legfelső
keretben keresendő, az x
viszont nem, az eggyel lentebb.
Egy elérni kívánt változó a veremben bármilyen mélyre kerülhet.
Akár úgy, hogy nagyobb mélységben ágyazzuk egymásba a függvényeket, vagy úgy, hogy
egymást hívják. Extrém esetben „végtelen” távol, sőt ez akár futási időben derülhet
csak ki: egy rekurzív függvényben. A fabejárás jó példája annak, hogy a külső függvény nodes
változója egyre
messzebb kerül a verem tetejétől:
function count_tree_nodes(root) {
var nodes = 0;
function do_count(root) {
if (!root)
return;
++nodes;
do_count(root.left);
do_count(root.right);
}
do_count(root);
return nodes;
}
A probléma neve: „downwards funarg problem”. A „funarg” szóösszevonás a paraméterekre utal: function argument. Viszonylag egyszerűen megoldható, szokás szerint indirekcióval. A Pascal nyelvben például a belső függvények egy rejtett paraméterrel rendelkeznek. Ezen keresztül, indirekten érik el a külső függvény változóit. Ez az ún. „static link”, vagy „uplevel reference”. Lényegében egy pointer a külső függvény változóit tartalmazó keretre.
A C nyelvben egy egyszerűbb megoldást választottak: megtiltották a függvények egymáson belül történő definiálását. Így szintaktikailag nem képes semelyik függvény a másik függvény változóira hivatkozni, tehát nem állhat elő ilyen helyzet. Így születtek meg a globális és lokális változók is: a C-ben nem tetszőlegesen sok szinten lehetnek változók (a belső függvény számára az ő külső függvényének „lokális” változója „globálisnak” látszik), hanem mindössze két szinten: a mindenki által látható globális, és a senki más által nem látható lokális változók. Mivel a globálisok nem a veremben vannak, ezért mindig lehet tudni a címüket, amely állandó.
C++-ban hasonló a helyzet, bár ott tagfüggvények is vannak. A tagfüggvények
egy adott objektum változóit úgy látják, mintha azok globális változók lennének,
a lokális változóként értelmezhető paramétereik mellett. Ilyen szempontból az
objektumok olyanok, mintha a globális változókat csoportosítanánk, a this
pointer pedig mintha egy statikus link lenne az adott változócsoportra (objektumra).
Tagfüggvény lehet akár rekurzív is, olyankor a this
pointer változatlanul
adódik át, hogy a tetszőleges mélység ellenére mégis ugyanazok a változók legyenek
rajta keresztül elérhetőek.
Az „upwards funarg” probléma
Tekintsünk egy másik JavaScript függvényt. Ez megint olyan lesz, amit
ilyen formában C/C++-ban nem lehet leírni. Ez a függvény egy neki paraméterként
adott egyváltozós, matematikai f()
függvény numerikus deriváltjával
tér vissza, a differenciálhányadost 0,001 lépésközű differenciahányadossal
közelítve. Itt a paraméter és a visszatérési érték is egy függvény, de ez ne
zavarjon meg minket: a függvény is csak egy típus, amihez adatok (a benne lévő matematikai kifejezés)
és műveletek (kiértékelés adott helyen) tartoznak.
function deriv(f) {
var dx = 0.001;
function derived(x) {
return (f(x+dx) - f(x)) / dx;
}
return derived;
}
var my_cos = deriv(Math.sin);
var x = my_cos(1.234);
Az előzőek alapján itt látunk egy „downwards funarg” problémát: a belső,
derived()
nevű függvény el kell érje a külső függvény f
és dx
nevű változóját. De van itt egy másik probléma is, egy ún.
„upwards funarg problem”. Nevezetesen az, hogy miután a derived()
függvényből visszatértünk, és visszaadtuk a derivált függvényt, előbb-utóbb
azt valaki meg akarja majd hívni. És az említett változóknak, f
-nek
és dx
-nek a híváskor még léteznie kell majd!
Ez egy nehezebb probléma, mint az előző, mert ez nem csak a változók láthatóságával kapcsolatos, hanem már az élettartamával is. Ha visszatérünk egy függvényből, törölni kell a hozzá tartozó keretet. Itt pedig azt látjuk, hogy ennek ellenére a változók értékét meg kellene tartani. Legalább addig, amíg valaki a visszatérési értékként adott függvényre (vagy annak másolatára) hivatkozni tud. A verem itt már nem lehet jó.
Az egyes nyelvek megint eltérőképpen oldják meg ezt a problémát. Némelyik nyelv úgy, hogy ezeket a változókat nem szabadítja föl, hanem automatikus szemétgyűjtést biztosít (garbage collection). A szemétgyűjtő algoritmus megvizsgálja a program összes változóját, hogy melyik melyikre tud hivatkozni, és így találja meg a már szükségtelen példányokat. Más nyelvek pedig egyszerűen nem foglalkoznak a problémával. Legalábbis annyiban, hogy ilyenkor elvárják a programozótól, hogy maga oldja meg a problémát valahogyan, egyértelműen jelezve a kódban, hogy mikor foglal memóriát, és mikor szabadítja föl. A visszatérési értékek ezekben a nyelvekben lemásolódnak, de a belül lévő változók élettartamát a programozónak kell kezelnie. Így született meg a dinamikus, azaz kézi memóriakezelés.
Meddig létezik a változó, és hogy érhető el? (élettartam, láthatóság)
A probléma neve: „downwards funarg problem”. A „funarg” szóösszevonás a paraméterekre utal: function argument.
function count_tree_nodes(root) {
var nodes = 0;
function do_count(root) {
if (!root)
return;
++nodes;
do_count(root.left);
do_count(root.right);
}
do_count(root);
return nodes;
}
C++: globális változók, tagváltozók (this)
A dinamikus memóriakezeléssel nem csak az a baj, hogy a változók felszabadításáért nekünk kell felelni. Hanem az is, mert hogy az emiatt behozott indirekció által a változók viselkedése szemantikailag megváltozik.
Az indirekcióval elválasztjuk egymástól a változó identitását és a nevét. Érték szemantika (value type) esetén a változó lemásolása egy új identitást jelent. Pointer vagy referencia szemantika (reference type) esetén pedig nem:
int i = 6;
int j = i; /* új int */
i = 7; /* j változatlan */
j = 5; /* i változatlan */
int i = 6;
int *j = &i; /* nincs új int */
i = 7; /* a *j is változik */
*j = 5; /* i is változik */
A programunk objektumokat kezel, amelyek hivatkozásának módját viszont elsősorban az objektum jellege, szerepe kellene meghatározza, nem pedig az, hogy a nyelv és a fordító mire képes technikailag. Van, ahol csak az objektumok értéke számít, máskor pedig entitásként viselkednek. Például a valós számokból álló mátrixok érték típusúak, és nem szeretnénk őket pointer szemantikával kezelni csak azért, mert a fordítási időben nem ismert méretű tömbhöz pointert kell használnunk. Bár két egyforma számokból álló mátrix egyformának tekinthető a kifejezésekben, ha értéket szeretnénk adni egy mátrix objektumnak, meg szeretnénk változtatni a benne lévő számokat, akkor saját identitással is kell rendelkezzen. Így ha lemásoljuk, akkor egy új, az eredetitől független mátrixnak kell létrejönnie. Röviden: örülünk az indirekciónak, hasznos, de csak akkor, ha mi választottuk. Máskor nagyon zavaró tud lenni.
Mindig problémát jelent, ha egy változónál nem választhatunk
egyértelműen az érték és a referencia szemantika között. A C-ben és a C++-ban a
tömbök problémásak. Létrehozáskor választhatunk, hogy értéket (új tömböt, számokkal) vagy
referenciát (pointert egy meglévő tömbre) szeretnénk. Paraméterátadáskor azonban már kötelező a
referencia szemantikát használnunk, és tudjuk is, mennyi gond van ebből. Más
nyelvekben az objektumok viselkednek így: PHP-ban, Javaban, JavaScriptben,
C#-ban a beépített típusok mind érték, az objektumok viszont kötelezően mind referencia
szemantikájúak, ami újfajta problémákat jelent. Pl. Javaban a java.util.Date
osztálynál: bár a dátum, mint fogalom, érték jellegű, a java.util.Date
osztálynak
mégis vannak setter függvényei. És mivel összetett típus, egy d1 = d2
formájú értékadás csak a
referenciát másolja. Ha nem lenne mutábilis a dátum objektum, ez nem lenne gond, hogy csak a referencia másolódik. Így
viszont gond, és immutábilisnak kellene lennie, mint a java.lang.String
-nek.
Az indirekció elrejtése
A C++ operátorai, és különösen konstruktorai, destruktorai lehetővé teszik azt,
hogy a legtöbb helyen a technikai kényszerűségből alkalmazott indirekciót elfedjük.
Például a mátrixunk számait tároló tömbre hivatkozó pointert becsomagolhatjuk
egy mátrix objektumba. A mátrix objektum indexelhető tud lenni, mintha igazi tömb lenne.
Ugyanakkor a másoló konstruktora és a destruktora által ugyanúgy tud viselkedni,
mint egy beépített, érték szemantikával rendelkező típus. Másoláskor mély másolat
(deep copy) jön létre, a számok is másolódnak; a megszűntekor pedig a hozzá tartozó
memóriaterület megszűnik. C++-ban ezeket a típusokat ugyanolyan könnyű kezelni,
mint a beépítetteket. A külső erőforrásokat kezelő objektumokból ugyanolyan egyszerű
temporális, névtelen objektumokat is létrehozni, mint egy egyszerű int
-ből.
Gondoljunk bele, egy ehhez hasonló kód mennyire egyszerű lenne int
-ekkel,
és mennyire bonyolult lenne C-ben Matrix
-okkal:
Matrix a, b, c, d;
d = a * b + c * 3 - d * 2;
Ugyanez a lényege az std::string
-nek is. Kényszerűségből a sztringhez
dinamikus tömböt kell használnunk. A sztring osztály másoló konstruktorának és destruktorának
megfelelő módon történő definiálásával viszont a sztring értékként képes viselkedni.
Ez fölvet néhány problémát a temporális objektumok élettartamával, és a generált kód hatékonyságával kapcsolatban. Ezekről egy külön írásban lehet olvasni.
Érték szemantika (value type) esetén a változó lemásolása egy új identitást jelent. Pointer vagy referencia szemantika (reference type) esetén pedig nem:
int i = 6;
int j = i;/* új int */
i = 7; /* j változatlan */
j = 5; /* i változatlan */
int i = 6;
int *j = &i;/*nincs új int*/
i = 7;/* a *j is változik */
*j = 5;/* i is változik */
Pl. mátrix vagy sztring: értékként szeretnénk kezelni, de a din. tömböt pointerrel érjük el: a technikai kényszerűségből alkalmazott indirekciót elrejtjük => C++ operátorok, konstruktorok, destruktor
A RAII (resource acquisition is initialization) elv azon alapszik, hogy az objektumok destruktoraira feladatokat bízhatunk. A dinamikus memóriakezelés kapcsán a legkézenfekvőbb, amit várhatunk, az az, hogy egy objektum feleljen egy dinamikusan foglalt, másik objektum felszabadításáért. A terv a következő:
- Foglalunk dinamikusan egy
T
típusú objektumot. Anew
kifejezésből kapunk egyT *
pointert. Ezt szeretnénk automatikusan felszabadítani majd, ha már nem kell: ha már nem hivatkozik rá pointer. - Ezt a pointert betesszük egy másik, automatikus memóriakezelésű objektumba, egy
ún. okos pointerbe (smart pointer), pl.
UniquePtr<T>
. - Az okos pointert a verembe tesszük, esetleg másik objektumba. Így automatikusan
fut a destruktora, amely felszabadíthatja a
T
-t is. - Az okos pointert osztálysablonból generáljuk, hogy minden típushelyes legyen
(nem
void *
-ozunk!), és olyan operátorokat adunk neki, amelyek által pointerként tud viselkedni.
Az automatikus felszabadítás sok helyen egyszerűsíti a kódot, pl. függvények belsejében, vagy objektumok adattagjaként:
void func() {
UniquePtr<SomeObj> x(new SomeObj{});
if (/* ... */)
return;
if (/* ... */)
throw /* ... */;
/* ... */
return;
} // sehol nem kell delete
class MyClass {
private:
UniquePtr<SomeObj> x_;
public:
/* ... */
MyClass() : x_(new SomeObj{}) {
}
~MyClass() = default; // nem kell
};
Egyértelművé teszi a memóriakezelést ott is, ahol függvény pointer típusú visszatérési értékével kell dolgozni. Nyers pointernél sose tudjuk, mi a helyzet. Ha a függvény okos pointerrel tér vissza, akkor viszont egyértelmű: ha eltároljuk az okos pointert, megmarad az objektum, ha nem, magától megszűnik.
T * foo(); // fel kell szabadítani? nem kell? free? delete? delete[]?
UniquePtr<T> foo(); // nincs kérdés
Az alapok
Az UniquePtr
osztály konstruktora, destruktora, és a pointer
viselkedést biztosító tagfüggvényei egyszerűek:
template <typename T>
class UniquePtr {
public:
explicit UniquePtr(T *ptr = nullptr): ptr_(ptr) {}
~UniquePtr() { delete ptr_; }
T & operator*() const { return *ptr_; }
T * operator->() const { return ptr_; }
/* ... */
private:
T *ptr_;
}
A nyíl operátor működésében különleges. Annak egy pointert kell visszaadnia, mivel az operátor alkalmazása után a fordító a kapott értéken újra a nyíl operátort próbálja meg alkalmazni (és ezt egyébként addig teszi, amíg szükséges). Ha van egy okos pointerünk, azon a nyíl operátor ezt jelenti:
ptr->x
(ptr.operator->())->x
Másoló konstruktor?
Az izgalmak a másoló konstruktornál kezdődnek. A másoló konstruktor
nem lehet a fordító által implicit megírt változat, mert az lemásolja a nyers
pointert. A pointer lemásolása által már két UniquePtr
mutatna
ugyanarra az objektumra, és amikor megszűnnek, mindketten megpróbálnák
felszabadítani ugyanazt az objektumot: delete ptr_
.
Ugyanakkor azt sem tehetjük meg, hogy a másoló konstruktorban lemásoljuk az objektumot:
template <typename T>
class UniquePtr {
public:
UniquePtr(UniquePtr const &the_other) {
ptr_ = new T(*the_other.ptr_); // HELYTELEN
}
};
Egyrészt mert nem ez a célunk (pointer szemantikát szeretnénk megvalósítani,
nem érték szemantikát), másrészt pedig nem tudhatjuk, hogy a mutatott objektum
micsoda. Lehet T
, de lehet T
valamely leszármazottja is.
Ezért a lemásolt pointertől el kell venni az objektumot. Az új
pointer fog mutatni rá, a régi pedig üres lesz, null értékű. Ha másik pointer jön
létre, ugyanoda mutatva, akkor nem lemásolódik, hanem átadódik a hivatkozás az
újnak. Így ezek az okos pointerek tulajdonos szemantikát (ownership semantics)
valósítanak meg: mindig pontosan egy UniquePtr
hivatkozhat a
dinamikusan foglalt objektumra, és azt az objektum tulajdonosának nevezzük.
Mindez nem valósítható meg a másoló konstruktorral, hiszen a másolás azt jelentené, hogy az eredetivel egyező viselkedésű
objektumot hozunk létre, amely azonban az eredetit nem változtatta meg. Más problémák is vannak itt, amikre egy külön írás tér ki – és amik miatt a C++98-ból ismert std::auto_ptr
osztályt
törölték a szabványból. A lényeg azonban ez: meg kell különböztetni egymástól a másolást és az áthelyezést. A C++11-ben van erre
megoldás, amit később fogunk megvizsgálni.
A C++11
std::unique_ptr<T>
osztályAz
std::unique_ptr
osztálysablon (#include <memory>
) a fentiekhez hasonlóan működik: tulajdonos szemantikát valósít meg egy dinamikusan foglaltT
objektumra, vagyT
leszármazottjára.
void func() {
UniquePtr<SomeObj>
x(new SomeObj{});
if (/* ... */)
return;
if (/* ... */)
throw /* ... */;
/* ... */
return;
} // sehol nem kell delete
class MyClass {
private:
UniquePtr<SomeObj> x_;
public:
/* ... */
MyClass()
: x_(new SomeObj{}) {
}
~MyClass()
= default;// nem kell
};
T * foo(); // fel kell szabadítani? nem kell? free? delete? delete[]?
UniquePtr<T> foo(); // nincs kérdés
Létrehozhatunk olyan okos pointert is, amely másolódáskor a hivatkozott értéket is lemásolja. Ezzel a pointer szemantika érték szemantikává alakul. Akkor lehet ez hasznos, ha az érték pontos típusát nem ismerjük, például heterogén kollekciónál.
Tegyük fel, hogy van egy osztályhierarchiánk, pl. egy alakzat ősosztály, háromszög és téglalap leszármazottal. Tudjuk, ha ezekből heterogén kollekciót szeretnénk építeni, akkor a tárolónak pointereket kell tárolnia. (Több okból: mert az alakzatok mérete eltérő, mert amúgy szeletelődnének, és mert az ősosztály lehet, hogy absztrakt.) Az indirekció miatt az alakzat objektumok a tárolón kívülre kerülnek. Ezért külön változóként hozzuk létre őket, vagy esetleg dinamikus memóriakezelést használunk:
class Shape { /* ... */ };
class Triangle: public Shape { /* ... */ };
class Rectangle: public Shape { /* ... */ };
std::vector<Shape*> shapes;
Rectangle r;
shapes.push_back(&r); // 1
std::vector<Shape*> shapes;
shapes.push_back(new Rectangle); // 2
A probléma mindezzel az, hogy így a ténylegesen tárolt objektumok (pointerek) nem ugyanazok, mint a tárolandó objektumok (alakzatok), és ettől teljesen megváltozik a tároló viselkedése.
- A tároló és a tárolandó objektumok élettartama nincs összekötve. Ha megszűnik egy objektum, a címe benne maradhat a tárolóban (1-es változatnál könnyű ilyen hibát elkövetni). Ha megszűnik a tároló, nem szűnnek meg az objektumok (2-es változatnál potenciális kódolási hiba).
- Ezen felül, a tárolt objektumokat referencia szemantikával érjük el, pedig a tárolóval egyező módon identitással kellene rendelkezzenek. Ha lemásolódik a tároló, nem másolódnak le az objektumok, és így a másolatok változtatása (pl. az alakzatok átszínezése) visszahat az eredetiekre.
A megoldást itt is egy helyettesítő objektum jelenti, amely lemásolódáskor lemásolja a mutatott
értéket is. Ilyen szempontból talán nem is nevezhető pointernek. Annyiban viszont igen, legalábbis
a lenti megvalósítás, hogy a benne lévő nyers pointer lehet nullptr
értékű, és így a
helyettesítő objektum lehet üres is. Sablonként megfogalmazva, általánosságban:
template <typename T>
class PolymorphicValue {
private:
T *ptr_;
public:
explicit PolymorphicValue(T *ptr = nullptr): ptr_{ptr} {
}
~PolymorphicValue() {
delete ptr_;
}
PolymorphicValue(PolymorphicValue const & masik) {
if (masik.ptr_ != nullptr)
ptr_ = masik.ptr->clone(); // itt a lényeg
else
ptr_ = nullptr;
}
/* ... */
};
Az alap konstruktor és a destruktor nem igényelnek magyarázatot. A lényeg a másoló konstruktorban van. Ez feltételezi, hogy a
sablonparaméter típusnak van egy clone()
nevű virtuális függvénye, amely a hierarchiának megfelelő típusú másolatot
(téglalap, háromszög, ...) készíti a mutatott objektumról. Ha van objektum, lemásolja, ha nincs, akkor a másolat
ugyanúgy üres, mint az eredeti. A szokásos *
és ->
operátorokat itt is megírjuk,
hogy a tárolt objektumot el tudjuk érni:
std::vector<PolymorphicValue<Shape>> shapes;
shapes.push_back(PolymorphicValue<Shape>{new Rectangle{ /* ... */ }});
shapes.push_back(PolymorphicValue<Shape>{new Triangle{ /* ... */ }});
for (size_t i = 0; i != shapes.size(); ++i)
shapes[i]->draw();
std::vector<PolymorphicValue<Shape>> shapes_copy = shapes; // 2 × clone
A fenti osztály csak egy részlete a teljesnek. Az értékadó operátort a szokásos módon meg kellene írni. Az öröklés miatt
néhány sablon konstruktorra is szükség lenne, hiszen ha U*
→T*
konverzió létezik (U
leszármazottja T
-nek), akkor a PolymorphicValue<U>
→PolymorphicValue<T>
konverzióra is
szükség lehet.
C++11-ben már nem kell szóközt tenni a
sablont bezáró két >>
közé. Ezért lehetett
„vector<PolymorphicValue<Shape>>
”-t írni, a végén
„> >
” helyett.
Kicsit zavaró lehet, hogy a helyettesítő objektumban tárolt alakzatot
a *
vagy a ->
operátorral lehet elérni, ennek ellenére maga a helyettesítő
objektum nem pointerként, hanem értékként viselkedik. Régóta tervben van az operator.()
bevezetése a C++-ba, de az a projekt eddig mindig elakadt valahol. Itt nagyon kényelmes
lenne, hiszen shapes[i]->draw()
helyett shapes[i].draw()
-t írhatnánk.
A clone()
függvény egyébként kiszervezhető külön osztályba is.
Talán egyszer majd lesz ehhez hasonló viselkedésű std::polymorphic
segédosztály.
Okos pointer, amely másolódáskor a hivatkozott értéket is lemásolja.
(A Tipus r; tarolo.push_back(&r);
vs. tarolo.push_back(new Tipus);
probléma megoldása.)
template <typename T>
class PolymorphicValue {
private: T *ptr_;
public:
explicit PolymorphicValue(T *ptr = nullptr): ptr_{ptr} { }
~PolymorphicValue() { delete ptr_; }
PolymorphicValue(PolymorphicValue const & masik) {
if (masik.ptr_ != nullptr)
ptr_ = masik.ptr->clone(); // itt a lényeg
else
ptr_ = nullptr;
}
};
Ha igazi, referencia szemantikával viselkedő okos pointereket szeretnénk – olyanokat, amelyek másolhatóak is, azaz többen is mutathatnak ugyanarra az objektumra –, arra is van megoldás. Számon kell tartani azt minden így kezelt objektum esetében, hogy hány okos pointer mutat rá. Ez a legtöbb programban szükséges lehet, mert sokszor több programrész is hivatkozik ugyanarra az objektumra, és nem lehet minden objektumhoz egyértelmű tulajdonost hozzárendelni.
A referenciaszám nem a pointerekhez tartozik, hanem a kezelt objektumhoz. Több okos pointer is hivatkozhat ugyanarra az objektumra (ők az ún. sharing group), és mindegyik ugyanazt a referenciaszámot kell lássa. Ezért az objektum mellett a számlálót is pointer szemantikával kell látni, vagyis arra is pointer kell mutasson.
A referenciaszámláló egyszerűen egy egész szám:
template <typename T>
class SharedPtr {
private:
T *ptr_;
int *refcount_;
};
Ha van objektum, kell számláló is. Ha nincs objektum, nem kell számláló sem. Egy okos pointer létrehozásakor a referenciaszámlálót 1-re kell állítani. Ez abból következik, hogy amikor az objektumot épp lefoglaltuk, az új okos pointerünk az első, aki mutatni kezdett az objektumra:
template <typename T>
class SharedPtr {
public:
explicit SharedPtr(T *ptr = nullptr) : ptr_{ptr} {
if (ptr_)
refcount_ = new int{1};
else
refcount_ = nullptr;
}
};
Másoláskor a lemásolt SharedPtr
itt már lehet a szokásos módon konstans. Ha a mutatott objektumra, akkor meg
kell növelni a referenciaszámlálót, mivel ilyenkor az új okos pointer bekerül abba a csoportba (sharing group), akik közösen
kezelik az objektumot:
template <typename T>
class SharedPtr {
public:
SharedPtr(SharedPtr const &the_other)
: ptr_{the_other.ptr_}, refcount_{the_other.refcount_} {
if (ptr_)
++ *refcount_;
}
};
A destruktor pedig, ha van objektum, csökkenti a referenciaszámlálót. Ilyenkor a számláló 0-ra is csökkenhet, ami azt jelenti, hogy ez volt az utolsó olyan okos pointer, amelyik arra az objektumra mutatott:
template <typename T>
class SharedPtr {
public:
~SharedPtr() {
if (ptr_) {
-- *refcount_;
if (*refcount_ == 0) {
delete refcount_;
delete ptr_;
}
}
}
};
A többi függvény (operator*
, operator=
stb.) a szokásos módon implementálható.
int main() {
SharedPtr<int> p{new int};
std::cout << p.get_refcount() << std::endl;
{
SharedPtr<int> p2{p};
std::cout << p.get_refcount() << std::endl;
std::cout << p2.get_refcount() << std::endl;
}
std::cout << p.get_refcount() << std::endl;
}
1 p1 2 p2 létezése alatt 2 1 újra csak p1 deleting
A C++11
std::shared_ptr<T>
osztályAz
std::shared_ptr
osztálysablon (#include <memory>
) így működik: a referenciaszámlálós okos pointer '11 óta már része a szabványnak.
Igazi, referencia szemantikával viselkedő okos pointer: másolható is, azaz többen is mutathatnak ugyanarra az objektumra
Számolni kell, hogy hány okos pointer mutat egy objektumra.
A referenciaszám nem a pointerekhez tartozik, hanem a kezelt objektumhoz => az objektumot is és a számlálót is pointer szemantikával kell látni => arra is pointer mutat.
Képzeljük el a következő helyzetet. A programunkban játékosok vannak: Player
, amelyek csapatokba szerveződnek:
Team
. A csapat pointerekkel hivatkozik a játékosokra, és a játékosok pointerrel hivatkoznak vissza a csapatukra. Ha ezeket a pointereket okos pointerrel adjuk meg, memóriaszivárgást kapunk. A
játékos nem fog felszabadulni, mert okos pointer mutat rá a csapatból; a csapat nem fog felszabadulni, mert okos pointer mutat
rá a játékosból.
Hasonló lenne a helyzet, ha megpróbálnánk egy duplán láncolt listát építeni okos pointerekből, mondván hogy akkor nem kell majd bajlódnunk a destruktorokkal. Valahogy így:
/* HIBÁS */
struct ListElem {
std::shared_ptr<ListElem> prev;
std::shared_ptr<ListElem> next;
};
Ezek az elemek sem szabadulnának föl soha. Hiába szűnne meg a lista eleje mutató: az első listaelem nem szabadulna föl, mert a második még hivatkozik rá; a második pedig nem szabadulna föl, mert az első még hivatkozik rá. És ugyanígy a többinél.
A probléma technikailag körkörös (cyclic) hivatkozásként jelenik meg. De nem önmagában a körkörösséggel van baj, hanem azzal, hogy az okos pointerek birtoklást fejeznek ki. Meg kell különböztetnünk egymástól a birtokos és a nem birtokos hivatkozást. Az utóbbit „gyenge” hivatkozásnak vagy pointernek szokták nevezni (weak pointer vagy weak reference). Mit jelent ez? Ha egy objektumra gyenge hivatkozásunk van, attól még az objektum bármikor megszűnhet. Viszont ellenőrizni tudjuk majd, létezik-e még vagy már nem.
A C++11-ben van ilyen osztály is. Az std::shared_ptr
birtokos hivatkozást fejez ki, az
std::weak_ptr
pedig nem birtokos hivatkozást. A fenti példában, tulajdonosban std::shared_ptr
kell, a birtokolt objektum pedig
std::weak_ptr
-rel tud visszahivatkozni a tulajdonosára:
class Team {
std::shared_ptr<Player> p1, p2, p3;
};
class Player {
std::weak_ptr<Team> team;
};
Az osztályokba épített konverzió lehetővé teszi azt, hogy egy shared_ptr
-ből weak_ptr
keletkezzen:
shared_ptr<X> sp{new X};
weak_ptr<X> wp = sp;
A weak_ptr
nem használható önmagában, hanem előbb vissza kell alakítani shared_ptr
-ré. A
weak_ptr
objektum lock()
tagfüggvényének visszatérési értéke ugyanarra az objektumra mutató
shared_ptr
. Ez a művelet lehet, hogy null pointert ad, mert a weak_ptr
nem fejez ki tulajdonjogot,
ezért a hivatkozott objektum időközben megszűnhetett. Ha nem nullt adott, akkor viszont az új shared_ptr
egy
számlált referenciát jelent az objektumra, tehát amíg az megvan, addig az objektum is marad.
A weak_ptr
-ek bevezetésével megszűnik a dangling pointer-ek, azaz már nem létező objektumokra
mutató pointerek problémája. Az alábbi programkód bemutatja ezt, a szükséges tagfüggvényekkel együtt, amelyeket érdemes
ismerni:
/* null értékű pointer */
std::shared_ptr<int> sp;
/* az új integer tulajdonosa lesz */
sp.reset(new int);
*sp = 10;
/* wp1 is arra az integerre mutat, de nem tulajdonosa */
std::weak_ptr<int> wp1 = sp;
/* a régi int törlése, új objektum birtoklása */
sp.reset(new int);
*sp = 5;
/* wp2 az új intre mutat, de nem tulajdonos */
std::weak_ptr<int> wp2 = sp;
/* a wp1 már üres lesz! (expired) */
if (std::shared_ptr<int> tmp = wp1.lock()) {
std::cout << *tmp << '\n';
}
else {
std::cout << "wp1 üres\n";
}
/* a wp2 a második integerre mutat (5) */
if (std::shared_ptr<int> tmp = wp2.lock()) {
std::cout << *tmp << '\n';
}
else {
std::cout << "wp2 üres\n";
}
A weak_ptr
objektumok helyes működéséhez már nem elég az, ha a referenciaszámláló egy sima int
. De
nem is sokkal bonyolultabb a működés. Az int
és a nyers pointer helyett itt egy menedzser objektum van, amely
tárolja a dinamikus objektum címét, a rá mutató birtokos shared_ptr
és nem birtokos weak_ptr
okos
pointerek számát:
A menedzser objektum az első shared_ptr
objektummal együtt jön létre, amikor az megkapja a dinamikusan foglalt,
kezelt objektum pointerét. Ilyenkor a birtokosok száma 1, a nem birtokos pointerek száma pedig 0. Amikor a
shared_ptr
lemásolódik egy másik shared_ptr
-be, másoló konstruktorral vagy értékadó operátorral, akkor
a birtokosok száma 2-re nő. Amikor pedig egy weak_ptr
objektum jön létre belőle, akkor a nem birtokos hivatkozások
száma nő meg 1-re. Ezt a pillanatot mutatja az ábra.
Bármikor, ha egy shared_ptr
már nem ide mutat, a birtokosok száma eggyel csökken. A
weak_ptr
objektumok megszűnte a másik számlálót csökkenti. Amikor a birtokos számláló 0-ra csökken, a kezelt
objektumot felszabadítja az utolsó megszűnő shared_ptr
. Ha ebben a pillanatban a másik számláló is 0, akkor a
menedzser objektum is megszűnik, és így minden memóriaterület felszabadult. Ha viszont a weak_ptr
-ek száma még nem
0, akkor a menedzser objektum megmarad, csak a kezelt objektumra mutató pointere nullptr lesz. Amikor mindkét
számláló nullára csökken, akkor szüntethető meg a menedzser objektum.
Összefoglalva, a kezelt objektum addig marad életben, amíg
shared_ptr
mutat rá, a menedzser objektum pedig addig, amíg akár shared_ptr
, akár
weak_ptr
hivatkozik rá.
1. példa: játékosok (Player
), amelyek csapatokba szerveződnek: Team
.
Csapat <=> játékosok: egymásra mutatnak.
Ha shared_ptr
, memóriaszivárgás:
- játékos nem fog felszabadulni, mert ptr mutat rá a csapatból,
- a csapat nem fog felszabadulni, mert ptr mutat rá a játékosból.
- CppCon 2018: Herb Sutter “Thoughts on a more powerful and simpler C++ (5 of N)”
- JavaScript Module Pattern: In-Depth – érdekesség a JavaScript modulokról, a függvények és láthatóságok használatáról.
- Generic C Reference Counting – referenciaszámlálás C-ben.
- Bjarne Stroustrup: The Design and Evolution of C++. Addison-Wesley, 1994.
- Using C++11’s Smart Pointers, David Kieras, EECS Department, University of Michigan.
- Skillcast: A polymorphic value-type for C++.