fel
le

Destruktorok

A destruktorok - hasonlóan a konstruktorokhoz - speciális célú metódusok. Akkor futnak le, amikor az adott objektum-példányra a továbbiakban már nincs szükség. Ekkor a destruktor belsejében lévő kód az adott objektum-példány által lefoglalt erőforrásokat felszabadítja.

Elterjedt hibás megfogalmazás, hogy a destruktorok a példányhoz tartozó memóriát szabadítják fel. Nos (mint később látni fogjuk) erre külön mechanizmus van, melyet GC-nek nevezünk. Ő szabadítja fel a példányhoz tartozó (managelt) memóriaterületet. A destruktornak azokat az erőforrásokat kell felszabadítania, amely kívül esik a GC felügyeleti területén.<

Erőforrásnak nevezünk az életben minden olyan dolgot, amelyből kevés van, vagyis megszerzése valamilyen versenyhelyzetben történik, birtoklása pedig költségigényes. Ennek megfelelően az elosztást és a birtoklást általában szabályok írják le, és van valami központi felügyeleti szerv (erőforrás-management), amely ezt be is tartatja.

A való életben nem erőforrás (egyelőre) a tiszta levegő, de erőforrás az olaj, a gáz, a szén, az energia, egy autó, a buszbérlet, stb.

Informatikai, programozó szempontból erőforrásnak minősül a memória-foglalás, a háttértár foglalás, a hálózati kapcsolatok létesítése, a nyomtatás, stb. Ezeket az operációs rendszertől kell igényelni, és amennyiben már nincs rájuk tovább szükségünk, a lehető legkorábban ezt jeleznünk is kell, hogy a közben esetleg felhalmozódott igényeket az operációs rendszer ki tudja elégíteni. Gondoljunk csak arra, hogy a nyomtatónkon egy időben csak egy program nyomtathat. Ha ő befejezte, és továbbra is foglalja a nyomtatót, akkor a további nyomtatási feladatokat az operációs rendszer nem tudja kiszolgálni.

A destruktorok tehát nem minden osztályhoz készítünk,csak azokhoz, amelyek extra erőforrást igényeltek közvetlenül az operációs rendszertől. Ezen osztályokban ugyanis a példányok megszünésekor ellenőrízni kell hogy a foglalások életben vannak-e még, és jelezni kell (később már ugyanis nem lesz alkalmunk) hogy nincs a továbbiakban rájuk szükségünk.

Explicit destruktorhívás

Az OOP történelmének első (sötét) idejében a destruktorok szerepét egyszerű metódusok látták el. Ezen metódusokat a programozók a kódból a megfelelő időpontban meghívták.

Állandó probléma volt azonban az, hogy a destruktortok elnevezésére nem volt névadási kötelezettség (amit a fordítóprogram betartatott volna),így a programozók először is sok időt töltöttek egymás osztályainak használatakor a nevek felderítésében. Később névadási hagyományok kezdetk elterjedni,a Close(), a Finish(), a Destroy() nevek kezdtek el terjedni, és gyökeret verni.

Néha egy-egy osztályhoz több destruktort is készítettek, akár az összes elterjedt nevet felhasználva. Ezek a háttérben ugyanarra a destruktorra mutattak, csak azért volt minden néven elkészítve, hogy a programozóknak ismerős nevek is működjenek.

A destruktortnak akár paramétere is lehetett, hiszen az explicit destruktort hívás mellett a paraméterek átadása is megoldható.

Ennél nagyobb problémának tűnt az a tény, hogy a programozók maguk végezték a destruktort hívásokat explicit módon, vagyis a program forráskódjában fizikailag bele kellett írni - mint minden más függvényhívást - a destruktortok hívását is.

Állandó problémának bizonyult, hogy a programozók:

  • elfejtették meghívni a szükséges destruktorokat. Ekkor az erőforrások beragadtak, legrosszabb esetben (nem eléggé felkészült operációs rendszer esetében) a gép újraindításáig életben maradtak.
  • túl korán hívták meg a szükséges destruktorokat. Egy bonyolult, több ezer soros programban néha nem könnyű annak eldöntése, hogy egy adott példányra szükség van-e már, vagy sem. Ha a kód egyik pontja úgy döntött, hogy meghívja a destruktort, akkor felszabadultak az erőforrások. Azonban ha a program egy másik pontja még használta volna a példányt, akkor ott már nyilván hibás működés jelentkezett.

Ez utóbbi borzalmasan nehezen felderíthető hiba, mivel a hiba egy másik ponton jelentkezett, de egy teljesen más helyen írt kódrész okozza. Teszteléskor általában nem sikerült újra előidézni. Ráadásul a felbukkanó hibaüzenetből néha nem volt egyértelműen felderíthető, hogy valójában a korai destruktorhívás okozza. Ha mégis ez nyilvánvalóvá vált, akkor is át kellett nézni a teljes forráskódot, a destruktorhívásokat összegyűjteni egy listára, és átnézni, melyik nincs kellően körültekintően megoldva.

Referenciaszámlálós destruktorhívás

Erre keresett megoldást a referenciaszámlálás elve. Ez egy egyszerű gondolaton alapszik: amikor egy példány referenciájáról (memóriacíméről) másolat készül, akkor jelezzük ezt, és a "másolatok száma" számlálót növeljük 1-el. Amikor egy másolatra már nincs tovább szükség, (felülírunk egy változóban a korábban tárolt memóriacímet más memóriacímmel, töröljük a listából, stb.) akkor azt is jelezni kell a "központnak", aki ilyenkor csökkenti a példányhoz tartozó számlálót 1-el

Amikor ez a példányhoz rendelt számláló 0-ra csökken, akkor a példány memóriacímét elvileg a program sehol nem tárolja már, a program számára nem elérhető a példány semmilyen formában, így a rendszer meghívhatja annak destruktorát, és törölheti a memóriából.

Ezen technika először is igényli, hog a destruktorokat megkülönböztessük a közönséges metódusoktól valamilyen módon, hiszen a destruktort a továbbiakban nem a programozónak kell azonosítania a program szövegében, hanem a futtató rendszernek kell megkülönböztetnie azt a többi, közönséges metódustól.

Egyes nyelvek azt a megoldást választották, hogy a destruktor nevét a programozó nem választhatta meg tetszőleges, hanem nyelvi névadási előírást adtak meg rá. Más nyelvek azt választották, hogy a névadás továbbra is tetszőleges lehet, de valahol mint egy jelzőként mellé kellett írni, hogy ez egyébként destruktor szerepét tölti be, és őt kell meghívni kellő időben majd. Ezen megoldások arra irányultak, hogy a fordító fel tudja ismerni, meg tudja különböztetni a destruktorokat a többi, közönséges metódustól.

Mindkét megoldás esetén egy dolog közös volt: a destruktoroknak innentől kezdve nem lehett paraméterük. Hiszen a futtató rendszer hívta őket automatikusan (implicit módon), és a futtató rendszer nem fogja a paraméterek értékét előállítani azok értelmének ismerete nélkül.

Még egy dolog következett: egy objektum-osztálynak csak egy destruktora lehetett. Nem volt értelme ugyanis több destruktort készíteni, mivel a futtató rendszer nem tudott volna amúgy sem választani közülük.

Sajnos a referencia-számlálás elvében volt egy nagyon komoly hiba: ha egy objektum-példány referencia-számlálója eléri a nullát, a példány megszüntethető, törölhető. Ez szükséges, de nem elégséges feltétel.

Amennyiben egy kétirányú láncolt listát valósítunk meg OOP környezetben, akkor például az első elem referenciaszámlálója 2, hiszen a FEJ is tárolja a referenciáját, valamint a második példány is tárolja a referenciáját. Hasonlóan, minden köztes elem referencia-számlálója 2, hiszen az előtte lévő, és a rákövetkező listaelem is tárolja az adott köztes elem referenciáját. Az utolsó listaelem referenciája 1, csak az őt megelőző elem tárolja annak a referenciáját.

Amennyiben a listát szeretnénk üresre állítani, úgy a FEJ-be a null értéket helyezhetjük, hogy eltávolítsuk az első elem referenciáját. Ekkor ennek számlálója csökken, az új érték 1 lesz. Mi most a helyzet? Hogy a listaelemek egyikének a számlálója sem 0, vagyis a rendszer nem távolítja el őket a memóriából. Ugyanakkor a program számára a lista elemei a továbbiakban már nem elérhetőek, vagyis elvileg törölhetőek. A listaelemek beragadtak a memóriába!

Garbage Collector

A memóriában még létező, de már szükségtelen, elérhetetlen példányokat szemét-nek, garbage nevezzük. A felesleges példányok begyűjtését garbage collecting-nek, vagyis szemétgyűjtésnek. A begyüjtő mechanizmust pedig garbage collector-nak, szemétgyűjtőnek. Ennek általános elterjedt rövidítése a GC.

A kétirányú láncolt lista egy egyszerű eset volt. A példában sok objektum-példány szerepelt, amelyek egy speciális formációban, speciális gráf alakban helyezkedtek el. Általános esetben az objektum-példányok gyakran tárolják egymásról azok referenciáját, és a sok egymásra hivatkozás egy komplex, bonyolult gráf-ot eredményez. A gráf csúcspontjai az objektum-példányok, a gráf élei pedig azt mutatják, hogy melyik példány tárolja egy másik példány referenciáját. Ez egyébként egy irányított gráf.

Amennyiben el kívánjuk dönteni, hogy mely példányok feleslegeses, törölhetőek a memóriából, úgy egyetlen módszer működik: a gráfbejárás. A gráfbejárás során a program aktuális változóiból kell elindulni, és követni az irányított gráf éleit. Amennyiben valamely gráf-csomópontba (példányba) el tudunk jutni a program aktuális változóiból kiindulva, úgy az a példány a program számára még elérhető valamilyen módon: tehát szükség van rá. Amennyiben valamely csomópontba már nem tudunk eljutni az élek mentén a kiinduló pontokból, úgy az felesleges példány, törölhető.

A fenti kétirányú láncolt lista esete is ilyen: amennyiben a FEJ=null értékadást végrehajtjuk, úgy a lista egyetlen eleme sem elérhető már a program aktuális változóiból kiindulva, így a lista minden egyes eleme felesleges, szemét.

Mint látszik, a szemét felderítése és azonosítása nem egy egyszerű mechanizmuson, hanem gráfbejáró algoritmuson alapul a háttérben. A memória folyamatos átfésülése (scannelése) pedig időt (processzor-időt) rabló feladatnak tűnik. Szerencsére erre komoly optimalizálási javaslatok és megoldások léteznek ma már, így a mai GC megoldások roppant hatékonyak, és észrevehetetlen mennyiségű processzor-időt kötnek le.

Megéri ezt a technológiát használni? Határozottan IGEN! Hiszen a programozó mentesül a memóriafelszabadítás problémájától! A GC nem hibázik, sebészi pontossággal azonosítja a felesleges példányokat, és szabadítja fel a helyüket. Nem lehetséges a túl korai felszabadítás, és nem ragadhatnak be a példányok a memóriába, nincs memóriaszivárgás (memory-leak). Aki programozó valaha hozzászokott a GC segítségéhez, soha sem tud attól megszabadulni. Amennyiben újra olyan programozási nyelvet kell használnia, ahol nincs GC, helyette explicit memóriafelszabadítást kell végezni - azonnal nyűgnek kezdi érezni, és panaszkodni kezd a főnökének.

Destruktorok a GC világában

A GC világában is marad az a tény, hogy a destruktor hívása implicit, vagyis a rendszer önállóan, a megfelelő időben automatikusan fogja meghívni a destruktort. Ez azt jelenti, hogy a destruktort a fordítóprogramnak egyértelműen azonosítani kell tudnia, nem lehet paramétere, és osztályonként csak egy lehet belőle.

Egyetlen értékadó utasítással egyszerre sok példányt tehetünk szemétté (gondoljunk csak a FEJ=null hatására a láncolt listánk összes eleme egyszerre szűnik meg hasznos példánynak lennie). Amennyiben egy ilyen rész-gráf leszakad a programról, úgy a GC jön, és egyesével likvidálja azokat. A sorrendről azonban nem tudunk semmit. Nem garantálja a GC optimalizált gráfbejáró és azonosító mechanizmusa, hogy a példányokat sorban pusztítja el. Egy ilyen láncolt lista esetén könnyedén előfordulhat, hogy valahol a lista közepén találja meg az első felesleges példányt, majd az elejéről, aztán a végéről, össze-vissza sorrendben.

Ezt azt jelenti, hogy ha egy példány más példányok referenciáit hordozza, a destruktor belsejében már nem feltételezhetjük, hogy azok a példányok még mindíg léteznek. Elképzelhető, hogy igen, de az is lehet, hogy őket a GC korábban megtalálta már, és rég törölte a memóriából.

Destruktorok írása

Amennyiben destruktort kell írnunk, úgy C# esetén a névadási kötelesség roppant mód hasonlít a konstruktor névadási stílusára:

  • meg kell egyezzen az osztály nevével
  • a név előtt egy ~ jel (hullámjel) kell áljon
  • kötelezően public, olyannyira, hogy ezt kiírni tilos
  • nem lehet visszatérési értéke (kinek is adnánk vissza értéket, a GC-nek?)
  • nem lehet paramétere (hiszen a GC nem fog neki értékeket átadni híváskor)

class SajatOsztaly
{
  ~SajatOsztaly()
  {  
    // ... a kód ide kerül
  }
}
 

A ~ jel egyébként onnan került bele a névbe, hogy ez a tagadás, méghozzá a bináris not operátor jele a C alapú nyelvekben. Vagyis a destruktor az bizonyos szempontból a konstruktort ellentétes oldala.

Destruktor a háttérben

A C#-ban valójában a destruktor speciális módon van kezelve. A fordítóprogram a destruktor láttán automatikusan egy Finalize() nevű metódus override-jára alakítja a destruktort metódusunkat.

class SajatOsztaly
{
   // a destruktor valójában erre fordul le:
   protected override void Finalize()
   {
      ...
   }
}
 

Ezt természetesen a GC is tudja, így amikor egy példányt talál a memóriában, amelyre már a továbbiakban nincs szükség, akkor a VMT táblájából kikeresi annak Finalize() metódusát, és azt hívja meg.

Ilyen Finalize() metódust minden osztály eleve tartalmaz. Az eredeti Finalize(), akit minden osztályban felül lehet definiálni, az Object osztályban került kidolgozásra.

class Object
{
  protected virtual void Finalize()
  {
   ...
  }
}
 

Ezen Object osztály minden más osztály közös őse, így minden objektumosztályban eleve létezik Finalize(), örökölt módon.

Ugyanakkor érdekesség, hogy a C#-ban nincs lehetőség a Finalize() direktben történő felülírására, az override segítségével, ezt a fordítóprogram elutasítja. Ha ilyet szeretnénk, akkor destruktort (destruktornak látszó metódust) kell írnunk, melyet a fordító maga alakít át Finalize() override-jára.

Mikor írunk destruktort?

A mai modern OOP nyelvekben, ahol a legalapvetőbb, leggépközelibb, operációs rendszer szintű szolgáltatások is objektum-osztályok formájában vannak megvalósítva - gyakorlatilag nincs szükség destruktort írására. Az erőforrások foglalását ugyanis nem operációs rendszer szintű függvényhívásokkal valósítjuk meg, hanem egy op. rendszer közeli osztály példányosításával.

Például egy file megnyitásához a StreamReader osztály egy példányát kell elkészíteni:

class FileKezelo
{
    protected System.IO.StreamReader r = null;
    public FileKezelo(string filenev)
    {
      r = new System.IO.StreamReader( filenev, Encoding.Default);
      ...
    }
}  
// ... főprogram ...
FileKezelo f = new FileKezelo(@"c:\szoveg.txt");
 

A fenti példában a FileKezelo osztályunk példányosításakor egy StreamReader példány is keletkezik a memóriában, melynek referenciáját az f mezője hordozza. Amikor ezen FileKezelo példányra már nincs a továbbiakban szükség, akkor egy időben válik szemétté a FileKezelo példány, és a hozzá csatolt StreamReader példány is.

Mivel a FileKezelo példány erőforrást foglalt (file-t nyitott meg), azt gondolnánk, hogy destruktort kell írni hozzá:

class FileKezelo
{
    ...
    ~FileKezelo()
    {
      r.Close();
    }
}
 

Ekkor azonban két hibát is elkövetnénk:

  • volt arról szó, hogy a konstruktort belsejében már nem feltélezhetjük, hogy a csatolt példány még létezik. Vagyis az r.Close() csak akkor működik, ha az r példány még létezik, ellenkező esetben ez kivételt okoz.
  • valójában a FileKezelo példány nem nyitott meg file-t, csak példányosított a StreamReader osztályból egyet. Vagyis a file lezárása nem a FileKezelo destruktorának a dolga, hanem a StreamReader destruktoráé.

Teljesen hasonló a helyzet az egyéb erőforrásokkal is. A hálózati kapcsolat kezeléséhez a System.Net.Sockets.TcpClient osztály példányosítása szükséges, a kapcsolat lezárása is ugyanezen osztály destruktorának a dolga, nem a mienk. Nyomtatáshoz a System.Drawing.Printing.PrintDocument példányosítása szükséges, a nyomtatás lezárása is ugyanezen osztály destruktorának a dolga, nem a mienk.

A következtetés az, hogy csak akkor lenne szükségünk destruktort írására, ha valamely funkciót nem a BCL valamely osztályának példányosításával, hanem direkt módon Win32 operációs rendszerfunkció hívásával érnénk el. De remélhetőleg erre nem fog sor kerülni
Hernyák Zoltán
2013-03-17 19:23:40