Személyes eszközök
Keresés

 

A InfoWiki wikiből



Tartalomjegyzék

Destruktorok

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

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ányra valamelyik kódrésznek szüksége van, akkor a példány memóriacímét lemásolja magának, és jelezze hogy másolatot készített. A rendszer számolja, hány másolat készült. Amikor egy másolatra már nincs tovább szükség, 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 destruktort 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 azt támogatták, 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
A lap eredeti címe: „http://wiki.ektf.hu/wiki/Mp3/ea11
Nézetek
nincs sb_3.141.198.146 cikk