Az elavult std::auto_ptr

Czirkos Zoltán · 2022.06.21.

Mi volt a baj a régi std::auto_ptr-rel? Miért került ki a szabványból? Hogy volt megvalósítva?

Miért nem használjuk már az std::auto_ptr-t? Miért döntöttek úgy, hogy teljesen kitörlik azt a szabványból? Mi volt az, ami ekkora problémát jelentett?

Tulajdonos szemantika

Az előadáson láttuk, hogy az auto_ptr tulajdonos szemantikát valósít meg, méghozzá olyan módon, hogy egy dinamikusan foglalt objektumért pontosan egy auto_ptr felel. A pointer objektum törlődésekor törölni kell a kezelt objektumot is:

template <typename T>
class auto_ptr {
  public:
    explicit auto_ptr(T *ptr = nullptr): ptr_(ptr) {}
    ~auto_ptr() { delete ptr_; }
    /* ... */
  private:
    T *ptr_;

Ezért egy ilyen objektum „lemásolásakor”, történjen az akár egy konstruktorral, akár az értékadó operátorral, az eredeti pointertől el kell venni a kezelt objektumot. Így biztosítva azt, hogy egy kezelt objektum csak egy pointerhez tartozzon:

template <typename T>
class auto_ptr {
  public:
    T * release() {
        T *temp = ptr_;
        ptr_ = nullptr;     /* elfelejti */
        return temp;        /* innentől a hívó felel érte */
    }
    
    auto_ptr(auto_ptr &the_other)     // nem konstans ref
        : auto_ptr(the_other.release())
    {
    }
    
    auto_ptr & operator=(auto_ptr &the_other) {  // itt sem konstans
        if (ptr_ != the_other.ptr_) {
            delete ptr_;
            ptr_ = the_other.release();
        }
    }
};

Problémák: másolás = másolás?

A gond az, hogy ezektől a függvényektől másolást szoktunk meg, ennek ellenére a nekik paraméterként adott objektumokat módosítják. Ez szokatlan működés, amely a legváratlanabb helyzetekben okozhat fennakadást. Pl. auto_ptr típusú függvényparaméter esetén:

void foo(auto_ptr<int> p);

void bar() {
    auto_ptr<int> p1(new int);
    *p1 = 17;

    foo(p1); // törlődik az int

    std::cout << *p1; // nullptr, segfault

Gond volt akkor is, ha auto_ptr-eket tettünk tárolókba. Sok algoritmus feltételezi, hogy a „másolások” nem teszik tönkre az eredeti objektumokat. Például a gyorsrendezés úgy működik, hogy egy kiválasztott elemet kiemel egy segédváltozóba, hogy aztán a többi elemet ahhoz hasonlítsa. A segédváltozó pedig végül megszűnik, tehát rendezés közben eltűnne a tömb egyik eleme.

A temporális objektumok és a nem konstans referenciák

Van még egy problémánk, amely a nyelvi elemekkel kapcsolatos. A temporális objektumok jobbértékek, amelyekre más paraméterátadási szabályok vonatkoznak, mint a balértékekre. A C++-ban a jobbértékeket, azaz a temporális objektumokat nem tudják átvenni paraméterként az olyan függvények, amelyek referenciát várnak (T&); csak azok, amelyek konstans referenciát (T const &) vagy értéket (T).

Emlékezzünk vissza a C-s terminológiára: balérték az, ami egy értékadás bal oldalán állhat, vagy más megközelítésben, aminek a memóriacíme képezhető. Jobbérték minden más. Az int i deklaráció után az i változó hivatkozása egy balérték; i = 0 és &i is helyes. Az i + 1 és az 5 kifejezések pedig jobbértékek; az i + 1 = 0 és &5 kifejezések helytelenek. C++-ban jobbértékek a temporális objektumok, és természetesen minden olyan függvényhívás értéke is, amely objektumot ad, nem pedig referenciát.

Ha nem konstans referencia átvehetne jobbértéket, elég furcsa dolgokat lehetne írni. Pl.:

void novel(int &i) {
    ++i;
}

novel(3);

A „másoló” konstruktorunk paramétere viszont most semmiképpen nem lehet konstans referencia, hanem csak sima referencia. Ezért a másoló konstruktor most nem tud temporális objektumot átvenni. Ami azért baj, mert az alábbiak teljesen ésszerű használatát mutatják az osztálynak, sőt pont így képzeljük el ez egészet. Az első esetben a visszatérési értéket próbáljuk egy temporális kifejezéssel inicializálni, a második esetben pedig a függvény visszatérési értéke a temporális objektum, amely alapján a p-t inicializálni szeretnénk. Egyikre sem illik rá a másoló konstruktor nem konstans referencia paramétere:

auto_ptr<int> create_int() {
    return auto_ptr<int>{new int{}};     // temporális!
}

auto_ptr<int> p = create_int();          // temporális!

A megoldási lehetőséget az adja, hogy bár temporális objektumot nem adhatunk nem konstans referencia paraméterű függvénynek – azt mégis megengedi a nyelv, hogy egy temporális objektum tagfüggvényét meghívjuk, még akkor is, ha az a tagfüggvény nem konstans minősítésű. Kell ezért egy segédobjektum (auto_ptr_helper), amely a másolásban segédkezni fog, méghozzá a következőképpen.

  • Egy auto_ptr_helper objektum ugyanolyan pointert tud tárolni, mint az auto_ptr párja, de a tároláson kívül semmit nem csinál.
  • Az auto_ptr-nek van egy auto_ptr paraméterű konstruktora: auto_ptr(auto_ptr&) (ez a „másoló” konstruktor), és egy auto_ptr_helper paraméterű konstruktora: auto_ptr(auto_ptr_helper).
  • A return auto_ptr<int>{new int{}}; utasításban egy objektumot inicializálunk, a visszatérési értéket. Ebből látja a fordító, hogy egy auto_ptr konstruktort kell hívni. Megnézi, milyenek vannak. Két konstruktor jöhet szóba.
  • Az első az auto_ptr paraméterű másoló konstruktor. Ezt nem engedik a szabályok, mert a temporális egy jobbérték, amihez nem köthető a nem konstans referencia.
  • A másik konstruktor egy auto_ptr_helper-t vár. A temporális nem ilyen típusú, de még lehet, hogy konvertálható. Megnézi ezért, van-e auto_ptrauto_ptr_helper konverzió.
  • Ezt a konverziót kell megírnunk. Ennek a konverziós függvénynek a dolga, hogy a kezelt objektumot elengedje (release()), és az ideiglenesen létrehozott auto_ptr_helper objektumra bízza. Az az objektum létrejön, a temporális auto_ptr pedig kiürül, null pointerré válik, így a destruktora nem fog csinálni semmit.
  • A kezelt objektum pointerét az auto_ptr_helper-től a másik auto_ptr átveszi, és eltárolja. Az ideiglenes auto_ptr_helper-re ezután már nincs szükség.

Mindez C++ nyelven:

template <typename T>
class auto_ptr {
  public:
    /* a segédosztály */
    class auto_ptr_helper {
      public:
        T *ptr_;
        auto_ptr_helper(T *ptr_) : ptr{ptr} {}
    };

    /* auto_ptr -> auto_ptr_helper */
    operator auto_ptr_helper () {
        return auto_ptr_helper{this->release()};
    }
    
    /* auto_ptr_helper -> auto_ptr */
    auto_ptr(auto_ptr_helper helper): ptr_{helper.ptr_} {
    }
};

auto_ptr<int> create_int() {
    return auto_ptr<int>{new int{}};     // return static_cast<auto_ptr_helper>(...);
}

Mi látszik ebből? Leginkább az, hogy a nyelvünk nem tud kifejezni valamit, amire a tulajdonos szemantikájú okos pointer leírásához szükségünk lenne. A C++98-ban még nem tudtuk megkülönböztetni egymástól nyelvi szinten a jobbértékeket és a balértékeket. C++11 óta szerencsére erre van már lehetőség.