Objektumok élettartama

Czirkos Zoltán, Pohl László · 2024.08.20.

Szabad és kötött változók. Funarg problémák. Pointer és referencia szemantika. Temporális változók és destruktorok. Okos pointerek, referenciaszámlálás.

1. Szabad és kötött változók

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).

2. A funarg problémák

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.

JavaScript
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:

JavaScript
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.

JavaScript
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.

JavaScript
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)

3. Érték és pointer szemantika

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

4. Okos pointerek: tulajdonos szemantika

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. A new kifejezésből kapunk egy T * 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ály

Az std::unique_ptr osztálysablon (#include <memory>) a fentiekhez hasonlóan működik: tulajdonos szemantikát valósít meg egy dinamikusan foglalt T objektumra, vagy T 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

5. Okos pointerek: polimorf típus

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;
  }
};

6. Okos pointerek: referenciaszámlálás

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ály

Az 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.

7. Referenciaszámlálás: körkörös hivatkozások

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.

8. Irodalom