C++ mutatók: miért van rájuk szükségünk, mikor használjuk őket, miben különböznek az objektumhoz való hozzáféréstől
Noha a legtöbb programozó érti, mi a különbség az objektumok és a rájuk mutató mutatók között, néha nem teljesen világos, hogy az objektumhoz való hozzáférés melyik módját válasszuk. Az alábbiakban megpróbáltunk választ adni erre a kérdésre.
Kérdés
Java háttérből érkeztem, és C++-ban kezdtem el objektumokkal dolgozni. De egy dolog, ami feltűnt nekem, hogy az emberek gyakran használnak mutatókat objektumokra, ahelyett, hogy magukat az objektumokat használnák, például ezt a deklarációt:
Object *myObject = new Object;
ahelyett, hogy:
Object myObject;
Vagy ahelyett, hogy egy függvényt, mondjuk testFunc(), használnánk, mint ez:
myObject.testFunc();
azt kell írnunk:
myObject->testFunc();
De nem értem, miért kellene ezt így csinálni. Feltételezem, hogy a hatékonysághoz és a sebességhez van köze, mivel közvetlen hozzáférést kapunk a memóriacímhez. Igazam van?
Válasz
Mellesleg meg kell jegyeznünk, hogy a Java-ban a mutatókat nem használjuk explicit módon, pl. a programozó nem férhet hozzá egy objektumhoz a kódban a rá mutató mutatóval. A Java-ban azonban a bázis kivételével minden típusra hivatkoznak: a hozzájuk való hozzáférés a hivatkozáson keresztül történik, bár explicit módon nem lehet a paramétert hivatkozással átadni. Ezen kívül a new a C++-ban és a Java-ban vagy a C#-ban különböző dolgok.
Azért, hogy egy kis képet adjunk a mutatókról a C++-ban , adunk két hasonló kódrészletet:
Java:
Object object1 = new Object(); //A new object is allocated by JavaObject object2 = new Object(); //Another new object is allocated by Javaobject1 = object2; //object1 now points to the object originally allocated for object2//The object originally allocated for object1 is now "dead" – //nothing points to it, so it//will be reclaimed by the Garbage Collector.//If either object1 or object2 is changed, //the change will be reflected to the other
A legközelebbi megfelelője ennek, a következő:
C++:
Object * object1 = new Object(); //A new object is allocated on the heapObject * object2 = new Object(); //Another new object is allocated on the heapdelete object1;//Since C++ does not have a garbage collector, //if we don't do that, the next line would //cause a "memory leak", i.e. a piece of claimed memory that //the app cannot use //and that we have no way to reclaim...object1 = object2; //Same as Java, object1 points to object2.
Lássuk az alternatív C++ módszert:
Object object1; //A new object is allocated on the STACKObject object2; //Another new object is allocated on the STACKobject1 = object2;//!!!! This is different! //The CONTENTS of object2 are COPIED onto object1,//using the "copy assignment operator", the definition of operator =.//But, the two objects are still different. //Change one, the other remains unchanged.//Also, the objects get automatically destroyed //once the function returns...
A sebességnövekedést a memóriához való közvetlen hozzáféréssel érjük el?
Ténylegesen egyáltalán nem. A mutatókat általában a heaphez való hozzáférésre használják, míg az objektumok a veremben helyezkednek el – ez egy egyszerűbb és gyorsabb struktúra. Ha kezdő vagy, van számodra néhány anyagunk, amelyben részletesen elmondjuk, mi az a verem és a halom.
Szigorúan véve ez a kérdés két különböző témát egyesít. Az első: mikor használjuk a dinamikus memóriaelosztást? Másodszor: mikor jobb a mutatók használata? Persze, nem nélkülözzük a közös szavakat, hogy mindig a legmegfelelőbb eszközt kell választani a feladathoz. Szinte mindig van jobb megvalósítás, mint a kézi dinamikus kiosztás (dinamikus allokáció) és/vagy a nyers mutatók használata.
Nagyon sajnálatos, hogy a dinamikus kiosztással olyan gyakran találkozunk. Ez csak azt mutatja, hogy mennyi rossz C++ programozó van.
Egy bizonyos értelemben két kérdés van összefűzve egybe. Az első az, hogy mikor használjunk dinamikus allokációt (new használatával)? A második, hogy mikor használjunk mutatókat?
A fontos tanulság az, hogy mindig a feladatnak megfelelő eszközt kell használni. Szinte minden helyzetben van valami megfelelőbb és biztonságosabb, mint kézi dinamikus kiosztást végezni és/vagy nyers mutatókat használni.
Dinamikus kiosztás
A kérdésedben két módszert mutattál be egy objektum létrehozására. A fő különbség az objektum tárolási időtartama. Amikor az Object myObject; egy blokkban történik, az objektum automatikus tárolási időtartammal jön létre, ami azt jelenti, hogy automatikusan megsemmisül, amikor kikerül a hatóköréből. A new Object() parancs végrehajtásakor az objektum dinamikus tárolási időtartammal rendelkezik, ami azt jelenti, hogy addig marad életben, amíg kifejezetten nem töröljük. A dinamikus tárolási időtartamot csak akkor érdemes használni, ha szükség van rá. Vagyis mindig az automatikus tárolási időtartamú objektumok létrehozását kell előnyben részesítenie, amikor csak teheti.
A két fő helyzet, amikor dinamikus kiosztásra lehet szükség:
- Az objektumnak túl kell élnie az aktuális hatókörön – az adott objektum az adott memóriahelyen, nem pedig annak másolata. Ha nincs gondod az objektum másolásával/mozgatásával (az esetek többségében így kell lennie), akkor az automatikus objektumot kell előnyben részesítened.
- Sok memóriát kell kiosztanod, ami könnyen megtöltheti a vermet. Jó lenne, ha ezzel nem kellene foglalkoznunk (az esetek többségében nem is kellene), hiszen ez tényleg kívül esik a C++ hatáskörén, de sajnos számolnunk kell azokkal a rendszerekkel, amelyekre fejlesztünk.
- Nem tudod pontosan, hogy mekkora tömböt kell majd használnod. Mint tudod, a C++-ban van a tömbök mérete fix. Ez problémát okozhat például a felhasználói bemenet beolvasásakor. A mutató csak a memóriának azt a részét határozza meg, ahová a tömb eleje kerül, nem korlátozza a méretét.
Ha a dinamikus kiosztás használata szükséges, akkor azt intelligens mutatóval vagy más olyan típussal kell kapszulázni, amely támogatja az “Resource acquisition is initialization” (a szabványos konténerek támogatják – ez egy olyan idióma, amelynek megfelelően az erőforrás: egy memóriablokk, fájl, hálózati kapcsolat, stb. – inicializálódik a konstruktorba kerülés közben, majd a destruktor óvatosan megsemmisíti). Az intelligens mutatók például az std::unique_ptr és az std::shared_ptr
Pointers
Mindenesetre a dinamikus kiosztáson túl a nyers mutatóknak vannak más, általánosabb felhasználási lehetőségei is, de a legtöbbjüknek vannak alternatívái, amelyeket előnyben kell részesíteni. Mint korábban is, mindig az alternatívákat részesítsd előnyben, hacsak nincs igazán szükséged a mutatókra.
- Szükséged van a referencia szemantikára. Néha azért akarsz átadni egy objektumot mutatóval (függetlenül attól, hogy hogyan lett kiosztva), mert azt akarod, hogy a függvény, amelynek átadod, hozzáférjen az adott objektumhoz (nem pedig annak másolatához). A legtöbb helyzetben azonban a referenciatípusokat előnyben kell részesítenie a mutatókkal szemben, mert azokat kifejezetten erre tervezték. Vegye figyelembe, hogy ez nem feltétlenül az objektum élettartamának az aktuális hatókörön túli kiterjesztéséről szól, mint a fenti 1. szituációban. Mint korábban, ha nincs gondod az objektum másolatának átadásával, akkor nincs szükséged referencia szemantikára.
- Polymorfizmusra van szükséged. Csak az objektumra mutató vagy hivatkozáson keresztül hívhatsz függvényeket polimorfikusan (azaz az objektum dinamikus típusának megfelelően). Ha erre a viselkedésre van szükséged, akkor mutatókat vagy referenciákat kell használnod. Ismét a referenciákat kell előnyben részesíteni.
- Azt akarod reprezentálni, hogy egy objektum opcionális, megengedve egy nullptr átadását, amikor az objektumot elhagyjuk. Ha ez egy argumentum, akkor előnyben kell részesíteni az alapértelmezett argumentumok vagy a függvények túlterhelésének használatát. Ellenkező esetben inkább olyan típust használjon, amely ezt a viselkedést kapszulázza, mint például az std::optional (bevezetve a C++17-ben – korábbi C++ szabványok esetén használja a boost::optional-t).
- A fordítási idő javítása érdekében a fordítási egységeket szét akarja választani. A mutató hasznos tulajdonsága, hogy csak a mutatott típus előre deklarációjára van szükség (az objektum tényleges használatához definícióra van szükség). Ez lehetővé teszi a fordítási folyamat egyes részeinek szétválasztását, ami jelentősen javíthatja a fordítási időt. Lásd a Pimpl idiómát.
- Interfészre van szükséged egy C könyvtárral vagy egy C stílusú könyvtárral. Ezen a ponton kénytelen vagy nyers mutatókat használni. A legjobb, amit tehetsz, hogy csak az utolsó pillanatban engeded el a nyers mutatókat. Nyers mutatót kaphatsz például egy intelligens mutatóból, ha használod annak get tagfüggvényét. Ha egy könyvtár valamilyen allokációt hajt végre számodra, amit elvár tőled, hogy egy fogantyún keresztül kiürítsd, akkor a fogantyút gyakran becsomagolhatod egy intelligens mutatóba egy egyéni törlővel, ami megfelelően kiüríti az objektumot.