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