Az alfejezet címe kicsit félrevezető, mivel első hallásra feltételezhetnénk, hogy három különböző dologról van szó. A .NET keretrendszer esetén nem ilyen egyszerű a dolog.
Objektumorientált környezetekben a destruktorok felelősek az objektum lebontásáért, az általa foglalt memóriaterület felszabadításáért. A .NET egy menedzselt nyelv, ebből adódóan rendelkezik egy szemét gyűjtővel (Garbage collector), ami a memória felszabadításáért felelős. C# esetén a destruktor finalizer néven ismert.
Egy osztály csak egy finalizer metódussal rendelkezhet, ami paramétereket nem fogadhat és elérés módosítókkal sem rendelkezhet, mivel a finalizer közvetlen meghívására nincs lehetőségünk a programunkból. A finalizert a szemétgyűjtő fogja végrehajtani, amikor felszabadítja az objektumunk memóriaterületét.
Az osztályunkhoz nem minden esetben kell finalizert írnunk, sőt speciális eset, amikor erre szükség van, éppen ezért nem került részletesen tárgyalásra korábban a téma.
Mielőtt azonban belemegyünk az implementációs részletekbe, érdemes áttekintenünk először a .NET szemétgyűjtőjét.
A szemétgyűjtő nem determinisztikus, azaz előre nem jósolható meg, hogy mikor fog lefutni. Alapvetően akkor, amikor a rendszernek memóriára van szüksége és éppen kezd kifogyni a rendszer ebből. A működése leegyszerűsítve a következő: minden olyan objektum, amire nem mutat referencia, az törölhető.
Vagyis ha egy referencia értéke null-ra vált, akkor az törölhető a szemétgyűjtő által. Lokális változók (pl. függvényben) esetén erre nincs szükségünk, mivel a szemétgyűjtő generációs elven működik. Ez azt jelenti, hogy a legfrissebben létrehozott objektumok lesznek a leghamarabb felszabadíthatóak, mivel a lokális változók hatásköre a scope határánál (a metódus végénél) véget ér.
A 0. generációs szemétgyűjtés külön szálon, a metódus végénél történhet. Ha itt talál olyan objektumokat, amelyek felszabadíthatóak, akkor az általuk foglalt memóriaterületet szabadnak jelzi1. A nem felszabadítható elemeket átteszi a következő generációba, majd valamikor átvizsgálja azokat is.
Minél magasabb generáció számmal rendelkezik egy objektum, annál ritkábban fog vele foglalkozni a szemétgyűjtő. Ha egy objektum életciklusa eljut a 2. generációig, akkor nagy valószínűséggel az életciklusa megegyezik az alkalmazás életciklusával, vagyis felesleges lenne minden szemétgyűjtési körben megvizsgálni. Éppen ezért számolhatunk úgy, hogy a legutolsó generáció elemei csak akkor fognak a memóriából törlődni, ha a programunk megáll, vagy menet közben teljesen elfogy a memória.
Ha finalizert írunk egy osztályhoz, akkor az kapásból az 1. generációban fogja kezdeni az életciklusát és egy speciális bejegyzést is kap a szemétgyűjtőben az extra kódfuttatás miatt. Ez az extra kódfuttatás nem történhet külön szálon, mert nem az a szál hozta létre az objektumot. Ez végső soron azt jelenti, hogy a finalizer futtatása lassítja a program működését. Éppen ezért az az ajánlás, hogy csak akkor írjunk finalizer-t, ha mindenképpen szükségünk van rá.
Mikor is kell akkor finalizert írnunk? Akkor, ha az osztályunk implementálja az IDisposable interfészt és/vagy rendelkezik natív memóriára mutató pointerekkel.
IDisposable
A .NET felületei közül kiemelt jelentőséggel bír az IDisposable interfész. Ez tekinthető a C# virtuális destruktorának és a feladata az, hogy determinisztikus, a programozó által kontrollálható erőforrás felszabadítást biztosítson.
Erre olyan esetekben van szükség, amikor nem lenne célszerű a szemétgyűjtőre várakozni, például fájlírás esetén. A fájlírás minden esetben az operációs rendszeren keresztül történik. Logikus, hogy a megnyitáshoz használt kód a konstruktorban kapjon helyet, a bezárás és írás véglegesítése meg a finalizerben. Azonban ha ez így történne, akkor a fájl majd egyszer csak bezáródna, de hogy mikor, azt egzakt módon nem tudnánk megmondani. Ez minimum problémás lenne olyan esetekben, amikor ugyanazt a fájlt szeretnénk későbbiek során ismételten írni.
Az ehhez hasonló problémák áthidalására keletkezett az IDisposable interfész, ami csak egy metódussal rendelkezik:
public interface IDisposable
{
void Dispose();
}
A metódust azonban nem triviális implementálni, mivel nem minden esetben egyformán kell és erősen függ az osztály felépítésétől, amiben implementálni szeretnénk.
Az IDisposable interfész implementálása kötelező, ha az osztályunk a konstruktorában IDisposable interfészt megvalósító típusokat hoz létre.
IDisposable nem örökölhető osztályok esetén
Ha az osztályunk sealed módosítóval rendelkezik, akkor egyszerű dolgunk van, a felszabadítási logikát a Dispose metódusba kell tennünk:
using System.IO;
public sealed class Egyszeru : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public Egyszeru()
{
//Fájlkezelésre később majd lesz példa részletesen
//itt annyi lényeges, hogy a StreamWriter osztály is IDisposable
_writer = File.CreateText(@"c:\teszt.txt");
}
//Egy metódus ami, IDisposable adattagot használ
public void Muvelet()
{
if (_disposed)
throw new ObjectDisposedException(nameof(_writer));
_writer.WriteLine("teszt");
}
//Dispose implementáció
public void Dispose()
{
if (!_disposed)
{
//Disposable adattagok esetén Dispose hívás
_writer.Dispose();
//natív erőforrások felszabadítása itt történne
//ha vannak
_disposed = true;
}
}
}
A fenti kódban a _disposed változó tárolja annak az állapotát, hogy a Dispose() metódus meg lett-e már hívva.
Ha műveletet szeretnénk végezni egy olyan adattagon, ami már fel van szabadítva, akkor az több, mint valószínűleg kivétellel járna, de elképzelhető, hogy nem. Ez a rosszabbik eset, mert ebben az esetben megjósolhatatlan következményei lehetnek a programunkra nézve. Éppen ezért minden olyan műveletvégzés esetén, ahol IDisposable adattagot használunk meg kellene nézni, hogy az már felszabadult-e. Ha igen és ez a műveletvégzés nem megengedett, akkor dobhatunk ObjectDisposedException típusú kivételt, hogy jelezzük: ez egy nem megengedett művelet az osztályunk esetén.
Ezt a kivételt try-catch blokkban elkapni tilos, mert ez programozói hibát jelent, aminek az okát kell kijavítani, hogy ne is dobódjon és nem a tünetét kezelni.
A Dispose() metódusban szintén a _disposed változó van arra felhasználva, hogy ellenőrizzük, lefutott-e már a metódusunk és elkerüljük a dupla felszabadítást. A dupla felszabadítás natív kódot alkalmazó típusok esetén komoly hibákat tud okozni, ezért kerülendő. Azonban a Dispose metódusban a kivételdobás nem javasolt. Ennek okairól majd az örökléses minta esetén lesz szó.
IDisposable örökölhető osztályok esetén
Amennyiben öröklésben részt vehet az objektumunk, akkor nehezebb dolgunk van, mivel ekkor követnünk kell a Dispose mintát.
Ebben a mintában az IDisposable interfészt az ősosztály implementálja. Azonban így ha a gyerekosztály további IDisposable adattagokat hoz létre, akkor azok nem szabadulnának fel. Éppen ezért a központi Dispose logikát a paraméter nélküli Dispose metódus helyett egy protected virtual void Dispose(bool disposing) metódusba kell tennünk.
A metódus paramétere azt fogja indikálni, hogy a publikus paraméter nélküli Dispose metódusból lett meghívva, vagy indirekt módon a GC hívta meg a finalizeren keresztül.
using System.IO;
public abstract class Base : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public Base()
{
//Fájlkezelésre később majd lesz példa részletesen
//itt annyi lényeges, hogy a StreamWriter osztály is IDisposable
_writer = File.CreateText(@"c:\teszt.txt");
}
//Egy metódus, ami IDisposable adattagot használ
public void Muvelet()
{
if (_disposed)
throw new ObjectDisposedException(nameof(_writer));
_writer.WriteLine("teszt");
}
//Dispose logika
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
//A metódust a publikus Dispose() hívta
if (disposing)
{
//Disposable adattagok esetén Dispose hívás
_writer.Dispose();
_disposed = true;
}
//natív erőforrások felszabadítása itt történne
//ha vannak
}
public void Dispose()
{
Dispose(true);
}
}
public class Gyerek : Base
{
private readonly StreamWriter _gyerekDisposable;
private bool _disposed;
public Gyerek()
{
_gyerekDisposable = File.CreateText(@"c:\masik.txt");
}
//Egy metódus, ami IDisposable adattagot használ
public void GyerekMuvelet()
{
if (_disposed)
throw new ObjectDisposedException(nameof(_gyerekDisposable));
_gyerekDisposable.WriteLine("gyerek");
}
~Gyerek()
{
//indirekt hívás jelzése
Dispose(false);
}
//Ős dispose felülírása és kiegészítése a gyerek erőforrásainak felszabadításával
protected override void Dispose(bool disposing)
{
if (_disposed)
return;
base.Dispose(disposing);
if (disposing)
{
_gyerekDisposable.Dispose();
_disposed = true;
}
}
}
A fenti kódban látható, hogy direkt hívás esetén (a publikus Dispose-on keresztül) foglalkozunk a IDisposable adattagokon Dispose hívással. Ez azért van, mert ha már a finalizer fut, akkor az IDisposable adattagok finalizere megtette a felszabadítást.
A leszármazott osztályunkban írnunk kell finalizert. Erre azért van szükség, hogy lehetőséget biztosítsunk legalább a részleges felszabadításra, akkor is, ha valahol kimaradna a Dispose() metódus meghívása.
A leszármazott osztálynak akkor kell felülírnia a paraméteres virtuális Dispose metódust, ha az további IDisposable adattagokat hoz létre.
Finalizer és öröklés
Öröklési lánc esetén, amikor a finalizer elkezd lefutni, akkor az öröklési lánc összes tagján lefut a finalizer függvény, tehát öröklési láncban a finalizer metódusnak csak az adott osztály által tárolt erőforrásokat kell felszabadítania, a szülőét nem, mivel azért a szülő destruktora lesz felelős. Az alábbi kód a Finalizer metódusok használatát mutatja be:
using System.Diagnostics;
namespace PeldaDestruktor
{
class Elso
{
~Elso()
{
Trace.WriteLine("Első finalizere lefutott");
}
}
class Masodik : Elso
{
~Masodik()
{
Trace.WriteLine("Második finalizere lefutott");
}
}
class Harmadik : Masodik
{
~Harmadik()
{
Trace.WriteLine("Harmadik finalizere lefutott");
}
}
class Program
{
static void Main()
{
Harmadik t = new Harmadik();
}
}
}
A Trace objektum hibakeresési üzenetek kiírását teszi lehetővé. A Trace objektumra írt üzenetek a Visual Studio Output ablakában lesznek láthatóak. Jelen esetben a Trace objektum azért került használatra, mivel a destruktorok ezen program esetében a program végén futnak le. Ezért, ha Console objektumot használtunk volna a kírásra, akkor a finalizer futásának idejében a programnak már nem lenne hozzáférése az erőforráshoz, így nem látnánk a kiírásból semmit.
Az Output ablakban a következő üzeneteket kell látnunk:
Harmadik finalizere lefutott
Második finalizere lefutott
Első finalizere lefutott
A using, mint blokk
A using kulcsszót korábban névterek használatbavételére használtuk, viszont ezen kívül használható még IDisposable objektumok eltakarítására is. Az alábbi példa ezt mutatja be:
using System;
namespace PeldaDisposable3
{
class UsingDispose : IDisposable
{
public void Metodus()
{
Console.WriteLine("Metódus meghívva");
}
public void Dispose()
{
Console.WriteLine("Using scope vége. Dispose futtatása");
}
}
class Program
{
static void Main(string[] args)
{
using (var objektum = new UsingDispose())
{
objektum.Metodus();
}
Console.ReadKey();
}
}
}
A program kimenete:
Metódus meghívva
Using scope vége. Dispose futtatása
A fenti példában a felület implementációt a példa kedvéért egyszerűsítettem, mivel itt nem konkrétan a felület implementáció a lényegi pont.
A using után zárójelek között létrehozzuk az IDisposable objektumot, amit a blokkon belül normál, megszokott módon használhatunk. A blokk végére érve azonban automatikusan meghívódik az objektumon a Dispose metódus. A using blokk csak IDisposable objektumokkal használható.
Ezen minta különösen hasznos lesz majd fájlkezelés kapcsán. A keretrendszerben a fájlok kezelése az operációs rendszer segítségével van megvalósítva, vagyis natív erőforrásnak számítanak. Ebből adódóan, ha nem hívjuk meg rajtuk a Dispose metódust, akkor nem lesznek eltakarítva a hozzájuk tartozó zárolási információk.
Fájl olvasás és írás után, ha elfelejtkeznénk a metódus meghívásáról, akkor a fájl további műveletvégzésre zárolva marad, egészen addig, amíg a szemétgyűjtő le nem fut. Addig azonban más alkalmazások nem fognak hozzáférni a fájl tartalmához.
A using blokk használatával azonban az ilyen kellemetlen szituációk kivédhetőek. A using blokk működésében azonos az alábbi try-finally blokkal:
var objektum = new UsingDispose()
try
{
objektum.Metodus();
}
finally
{
((IDisposable)objektum).Dispose();
}
Ahogy látható, a kódban van egy explicit interfész típus konverzió. Ennek köszönhető, hogy a using blokk csak IDisposable típusokkal működik. Ebből következik azonban egy másik dolog is, mégpedig az, hogy struktúrák esetén nem igen kellene implementálni az IDisposable interfészt.
Mégpedig azért, mert a using blokk által generált kód végén az implicit konverzió egy boxing művelet, ami azzal jár, hogy a struktúránkból egy másolat keletkezik, ami azt jelenti, hogy a másolaton fog megtörténni a felszabadítás és nem az eredeti objektumon. Ez pedig végső soron memóriaszivárgáshoz vezet.
-
Az adat a memóriában marad ekkor is, viszont későbbi használatra szabadnak lesz jelölve. Tehát, ha adatot kell elhelyezni, akkor az kerülhet az ilyen megjelölt területekre, viszont a feladatkezelőben nem feltétlen fog kevesebbet mutatni a folyamat memóriahasználatára. A mechanizmus azért került bevezetésre, hogy az egyes generációkba tartozó elemek összefüggő memóriaterületen legyenek. Ez némiképpen egyszerűsíti a szemétgyűjtési folyamatot, ami így gyorsabb tud lenni.↩