Temporális objektumok

Czirkos Zoltán · 2022.06.21.

Néhány történelmi érdekesség a temporális objektumok élettartamával, másolásával kapcsolatban.

Az előadáson láttuk, hogy megfelelő másoló konstruktorok és destruktorok írásával a ránk kényszerített indirekciót meg tudjuk szüntetni. Olyan objektumokat tudunk létrehozni, amelyek pointert tartalmaznak, mégis képesek értékként viselkedni. Ez teszi lehetővé, hogy ehhez hasonló kódrészleteket írjunk:

Matrix a, b, c, d;

d = a * b + c * 3 - d * 2;

Mindez azonban felvet két problémát. Az egyik csak hatékonysági kérdés: a sok temporális objektum létrehozása, másolása időbe és memóriába kerül. A másik viszont a kód helyességét is érinti: tudnunk kell, egy temporális objektum meddig létezik, mert az általa kezelt erőforrásokat csak addig hivatkozhatjuk.

1. A temporális objektumok és másolatok

Vizsgáljuk meg az alábbi kódrészletet!

Matrix get_matrix() {
    Matrix m;

    /* ... */

    return m; // 1
}

int main() {
    Matrix x = get_matrix(); // 2

    /* ... */
} // 3

A függvény lokális változóként létrehoz egy m mátrixot, amely a visszatérési értéke is lesz. A visszatéréskor ennek meg kell szűnnie, ezért le kell majd másolni. A másolás egy nagy mátrixnál időigényes művelet lehet, ami ugyanakkor felesleges. Az x változó inicializálásakor ugyanez a helyzet: a függvény visszatér egy temporális mátrixszal, amelynek értékével (azaz a mátrix másoló konstruktorával) inicializálni kell az x-et. A másolás ezen a ponton szintén felesleges.

A modern fordítók mindkét másolást ki tudják optimalizálni. Az elsőt azért, mert C++-ban a visszatérési érték helyét a hívó már lefoglalta. A visszatérési érték egy olyan, Matrix objektum méretű memóriaterület, amelyet a függvény a return utasítással inicializál; mintha abban a pillanatban futna le a konstruktora. Ha a fordító észreveszi, hogy a visszatérési érték ugyanaz, mint az m változó, akkor generálhat olyan kódot is, amelyben az m változót eleve oda teszi a memóriában, ahova a visszatérési értéket kell tennie. Így megspórolható az 1-es másolás. Hasonló a trükk a 2-es pontnál: ha a függvény által visszaadott objektumot megtartja a veremben, tehát a destruktorát késlelteti a 3-as pontig, akkor nem kell létrehozni egy újabb, de teljesen ugyanolyan objektumot, hanem a meglévőt lehet x-nek nevezni.

Ezt a módszert a másolatok kiküszöbölésének (copy elision) nevezik. A szabvány egy külön szabállyal teszi ezt lehetővé. Azt mondja, hogy a fordítók számára megengedett a névtelen másolatok kiküszöbölése, még akkor is, ha a másoló konstruktornak vagy a destruktornak van mellékhatása. Emiatt az alábbi program egy árva ctor és dtor páron kívül egyebet lehet, hogy nem is ír a képernyőre:

class Matrix {
  public:
    Matrix() { std::cout << "ctor" << std::endl; }
    Matrix(Matrix const &) { std::cout << "copy" << std::endl; }
    ~Matrix() { std::cout << "dtor" << std::endl; }
};

Matrix get_matrix() {
    Matrix m;
    return m;
}

int main() {
    Matrix x = get_matrix();
}

Ezekkel a módszerekkel sajnos nem küszöbölhető ki minden másolat.

2. A temporálisok élettartama

A névvel rendelkező változók élettartama egyértelműen definiált: addig tart, amíg az őket létrehozó blokkot el nem hagyja a végrehajtás. Ez egyértelmű, és a kódban is jól látható pont, mivel a változó láthatósága is eddig tart lexikailag. Ez azért fontos, mert temporális objektumnál, vagy temporális objektum által kezelt erőforrásnál tudnunk kell, hogy meddig használhatjuk azt. Nézzük meg példának az alábbi sztring osztályt! Ebben az operator char const * tagfüggvény feladata, hogy a tárolt sztring C stílusú, azaz nullával lezárt karaktertömb reprezentációját állítsa elő. Így az összeg sztring kiírható a képernyőre a printf() függvénnyel is, amely csak ezt ismeri.

class String {
    /* ... */
  public:
    ~String();                 /* delete dynamic arrays */
    operator char const * ();  /* return C-style representation */
};

printf("%s", (char const *)(s1+s2));

Kérdés, hogy amikor a printf() meghívódik, akkor az s1+s2 kifejezés eredménye, a névtelen, temporális sztring objektum létezik-e még. Ha igen, akkor a kódrészlet helyes. Ha nem, akkor viszont egy megszűnt memóriaterületre fog hivatkozni a printf(), mivel a sztring destruktora a tömböt minden bizonnyal fel kellett szabadítsa.

A C++ szabványosításakor többféle lehetőséget is vizsgáltak a temporálisok élettartamát tekintően, mint pl. 1) blokk végéig, 2) az első hivatkozás utánig, 3) utasítás végéig, 4) az őket tartalmazó kifejezés végéig.

  • A blokk végéig tartó élettartam biztonságos, de a sok temporális miatt túl nagy erőforrásigényű lehet.
  • Az első hivatkozásig tartó élettartam esetén a fenti kódrészlet egyszerűen nem működne: az operator char const *() tagfüggvény meghívása az első hivatkozása az összeg sztringnek. Ha ezután meghívnánk a destruktorát, az abban a pillanatban felszabadítaná azt a memóriaterületet, amelyre a pointert kaptuk.
  • Ha egy utasítás végéig tartjuk meg a temporálisokat, az elég abszurd eredményekhez vezet. Például ez egy helyes kódrészlet, mert az utasítás vége az if() igaz ága után van:
    char *p;
    if (p = s1+s2)
        printf("%s", p);
                        // itt

    Ez pedig helytelen kódrészlet, mert az utasítás vége az értékadás után van:

    char *p;
    p = s1+s2;
                        // itt
    printf("%s", p);

Végül kompromisszumos megoldásként döntöttek úgy, hogy a temporálist az őt tartalmazó teljes kifejezés végéig kell megtartani (end of full expression). Ez egy jól definiált és könnyen elmagyarázható pont a program végrehajtásában, ugyanakkor nem kényszeríti a fordítót arra, hogy a temporális objektumokat túl sokáig megtartsa a memóriában. Emiatt működik az első kódrészlet, mert itt a teljes kifejezés a printf() függvény hívása:

printf("%s", (char const *)(s1+s2));

Működik bármely bonyolultabb kifejezés is:

if ((p = s1+s2) && p[0])
    /* ... */

Ha ennél tovább van szükségünk egy objektumra, nevet kell neki adni:

String s3 = s1+s2;
printf("%s", (char const *) s3);

Vegyük észre, hogy ebből a szempontból különbözik a C és a C++. A C-ben az összetett literálisok (compound literal) élettartama a blokk végéig tartott. C++-ban rövidebb az élettartam. Pedig a kettő ugyanaz: névtelen objektumokról van szó mindkét esetben.

3. Az élettartam meghosszabbítása

Létezik egy rejtett nyelvi elem, amely segítségével egy temporális objektum élettartama meghosszabbítható (extending the lifetime of a temporary). Ezt a következőképpen kell használni:

{
    String s1, s2;

    String const & s = s1+s2;
    std::cout << s;
}

Ezt a kódot elsőre hibásnak mondanánk, azzal az érveléssel, hogy az s1+s2 kifejezésben egy temporális jön létre, és ahhoz kötjük hozzá az s referenciát. De ez nincs így: különleges szabályként ilyenkor a temporális megmarad, egészen addig, amíg a referencia látóköre tart.

Mennyiben más ez vajon, mintha objektumként, nem referenciaként hoztuk volna létre az s-t tehát így festene a kódunk?

String s1, s2;

String s = s1+s2;
std::cout << s;

Ebben az esetben a létrejövő temporális lemásolódhat. Attól függően, hogy a fordító optimalizált-e, az s objektum a megtartott visszatérési érték lesz, vagy annak a másolata, de ezt nyelvi elemmel nem tudjuk szabályozni. Éppen ezért találták ki a referenciás trükköt; az egyértelmű jelzés arra, hogy a másolást el kell kerülni.

A C++11 óta ennek az eszköznek már nincs akkora jelentősége, mint régen. A String s = s1+s2 itt már mozgatás is lehet, és az szinte ingyen van.

4. Mese a függvények paraméterezéséről

Adott egy ilyen függvényünk:

String operator+(String const &a, String const &b);

Tegyük fel, hogy valahogy megpróbálunk megszabadulni az érték szerinti visszatéréstől. Ötleteljünk egy kicsit, nem tudjuk-e átalakítani valahogy a paraméterezését, hogy ne ilyen legyen! Mert ugyan ilyenkor a használat egyszerű, de ha nem léteznének mozgató konstruktorok, akkor a program teljesítményében fizetnénk meg ennek az árát. Mi lenne, ha az eredményt is egy referenciaként átvett sztringbe tennénk?

void concatenate(String const &a, String const & b, String &c);

String c;
concatenate(a, b, c);

Az egyszerű szintaxist már el is veszítettük, de a program teljesítményében sem nyertünk annyit, amennyit lehetne. Vegyük észre, hogy itt az eredmény sztringet külön kell létrehoznunk, az alapértelmezett konstruktorát már le kellett futtatnunk feleslegesen. Az összefűző függvényt pedig fel kell készítenünk arra, hogy a c objektumban felesleges, felszabadítandó memóriaterületet kellhet kezelnie.

Ha már úgyis bonyolodik a szintaxis, megpróbálhatjuk kihagyni az alapértelmezett konstruktor futtatását. Ehhez a sztring létrehozását át kell helyezni a függvénybe. Ehhez pointer kell:

void concatenate(String const &a, String const &b, String * &c);

String * c = nullptr;
concatenate(a, b, c);   /* belül: c = new String{...} */
...
delete c;

Ettől csak rosszabb lett a helyzet programozási szempontból, mert még az élettartam kezelése is a mi dolgunkká vált. Csak le ne felejtsük a delete-eket! Ha mégis operátorként szeretnénk ezt használni, akkor a visszatérési érték kell a pointer legyen, de az furcsa lesz, mert sztring+sztring pointert ad:

String* operator+(String const &a, String const &b);

String * c = nullptr, * d = nullptr;
c = a + b;
d = a + *c;

delete c; delete d;

Ha elfogadjuk a furcsa szintaktikát (néhol érték, néhol pointer), megpróbálkozhatunk okos pointerekkel. Ezeknek azonban újra van egy többlet absztrakciós költsége (refenciaszámlálás stb.):

shared_ptr<String> operator+(String const &a, String const &b);

shared_ptr<String> c, d;
c = a + b;
d = a + *c;

Akárhogy csűrjük-csavarjuk a függvény fejlécét, azt látjuk, hogy nem tudunk találni olyan megoldást, amely egyszerre hatékony és egyszerű. C++98-ban a nyelv kifejezőképességének határára érkeztünk: ezekkel a nyelvi elemekkel (érték, referencia, pointer) nem tudjuk automatikusan a fordító tudatára hozni azt, hogy melyik másolás, melyik objektum létrehozása felesleges. Ezért lettek C++11-ben a mozgató konstruktorok.