Jeg påstod i mit forrige indlæg, at hvis en constructor bliver afbrudt, kan ressourcer være ikke-initialiserede, hvilket gør, at man naturligvis ikke kan kalde en eventuel Dispose()-metode på disse. Min løsning var at udvide Microsofts anbefalinger ved at tilføje et check for om de relevante variable er null. Nedenstående eksempel illustrerer den oprindelige problemstilling. Bemærk, at dette bestemt ikke er den rigtige måde at skrive sin Dispose()-metode.
public class DisposableType : IDisposable {
private Component component; // Disposable ressource
public DisposableType() {
// Øger sandsynligheden for at vi får afbrudt constructoren
System.Threading.Thread.Sleep(1);
component = new Component();
}
~DisposableType() {
Dispose(false);
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing) {
// Dette er *ikke* en korrekt implementering af Dispose(),
// men sådan så den ud i vores GUI-bibliotek.
component.Dispose();
}
}
public class WorkerClass {
public static void DoWork() {
for (int i = 0; i < 5000; i++) {
using (DisposableType d = new DisposableType()) {
// simuler at vi laver noget ...
System.Threading.Thread.Sleep(1);
}
}
}
}
static void Main(string[] args) {
Thread t = new Thread(new ThreadStart(WorkerClass.DoWork));
t.Start();
// Giv vores tråd en chance for at komme i gang
System.Threading.Thread.Sleep(10);
t.Abort();
}
DisposableType allokerer en disposable ressource og implementerer derfor IDisposable, så brugeren kan iværksætte en oprydning og på den måde undgå at vente på at Finalize-metoden kommer til på et eller andet tidspunkt.
Jeg har lagt en kunstig pause ind i DisposableTypes constructor. Det vil man naturligvis ikke gøre under normale omstændigheder, men det hjælper os til at gøre problemet reproducerbart. Bemærk også at der kun er en ressource i eksemplet, men man kan sagtens forestille sig situationer hvor nogle af ressourcerne er initialiserede, mens andre ikke er det. Den aktuelle tilstand kommer helt an på, hvornår constructoren bliver afbrudt.
I Main() starter vi en tråd, der kalder DoWork(), hvis arbejde består i at lave en masse instanser af DisposableType. Bemærk at disse laves i en using-konstruktion, så vi gør, som man skal og rydder pænt op efter os her. Når DoWork() først er i gang, slår vi den ihjel med et kald til Abort(). Det resulterer i en ThreadAbortException, hvilket slår vores tråd ihjel. Applikationen dør under kørsel af Finalize-metoden på DisposableType, hvilket i dette tilfælde højst sandsynligt sker som del af nedlæggelsen af den aktuelle AppDomain (der har næppe været behov for oprydning på et tidligere tidspunkt).
Da DisposableType implementerer en Finalize-metode, registreres instanser af typen på Finalize-listen, når de oprettes. Det vil sige, før constructoren bliver kørt. Det betyder også, at uanset om constructoren kører til ende eller ej, vil hver instans være registreret på listen, og Finalize-tråden vil kalde deres respektive Finalize-metode, med mindre GC.SuppressFinalize() kaldes inden. De instanser, der bliver lavet uden problemer, får kaldt deres Dispose()-metode som del af using-konstruktionen, og dermed bliver de fjernet fra Finalize-listen.
Bliver DisposableTypes constructor imidlertid afbrudt, får erklæringen i using ikke en reference til instansen og dermed bliver Dispose() heller ikke kaldt (referencen er null, men using genererer som bekendt en finally-blok, der checker om referencen er gyldig inden Dispose() kaldes, så ingen problemer her). Da Finalize-listen under alle omstændigheder har en reference til instansen, betyder det, at den aktuelle Finalize-metode og dermed Dispose() vil blive kaldt. Da Dispose() ikke tager højde for, at component kan være uinitialiseret, dør koden med en NullReferenceException på dette tidspunkt. Det var det problem, vi så i vores GUI-bibliotek.
Så for at opsummere: Enten får constructoren lov til at køre færdig eller også bliver den afbrudt. Hvis det første er tilfældet, er alle typens egne referencer i orden, og vi får en reference, vi kan bruge til at kalde Dispose(). Er det modsatte tilfældet, kan vi ikke regne med, at typens referencer er blevet initialiseret, men eftersom vi ikke har en reference til objektet, kan vi heller ikke kalde Dispose() på instansen. Har typen derimod en Finalize-metode, har Finalize-listen en reference til objektet, da denne oprettes inden constructoren kører. Det vil sige, at Finalize-tråden kan kalde Finalize på instansen, og eftersom den bør kalde Dispose(), kan vi få kaldt Dispose() på et ikke initialiseret objekt. Men – og her kommer jeg til at modsige mit tidligere indlæg – eftersom at Finalize-metoden kalder Dispose(false), bør det være tilstrækkeligt at checke på disposing-flaget.
Det vil sige, at Microsofts dokumentation som udgangspunkt ikke er så tosset endda (men flere detaljer ville stadig være på sin plads). Derfor er det også interessant, at Microsoft i mange tilfælde faktisk laver null-check i deres kode, ligesom det er værd at bemærke, at en masse kloge hoveder også anbefaler denne praksis. Skønt jeg vil mene, at der strengt taget ikke er behov for det, eftersom disposing-flaget har samme effekt under normale omstændigheder, kan der være andre gode grunde til at gøre det alligevel.
For det første er det nødvendigt, hvis man i tillæg til at kalde Dispose() også nulstiller referencer. Da Dispose() skal kunne tåle at blive kaldt et vilkårligt antal gange for en instans, er et null-check obligatorisk i denne situation.
Derudover er det fornuftigt at lave sin Finalize-kode så stabil som mulig. Da Finalize-metoderne afvikles i en separat tråd, vil en exception, der undslipper en Finalize-metode, ramme selve Finalize-tråden, med det resultat, at den kaster håndklædet i ringen, hvilket som dokumentationen siger, betyder at Finalize-tråden afbrydes, eventuelle resterende Finalize-metoder bliver sprunget over og applikationen lukkes. Så skønt et null-check kan se overflødigt ud, er det en god sikkerhedsforanstaltning mod, at nogen på et senere tidspunkt kommer til at nulstille de referencer, der bruges til at kalde Dispose() på ressourcerne.
En sidste grund er naturligvis i tilfældet hvor klassens constructor er mocked (med TypeMock.NET) som del af en test. I det tilfælde kører constructoren ikke, og dermed bliver referencerne heller ikke initialiseret. Her er disposing-flaget tilmed ikke tilstrækkeligt, da det ikke beskytter mod den manglende reference ved et eksplicit kald til typens Dispose()-metode. Bruger man TypeMock.NET i sit testmiljø skal alle Dispose()-metoder således checke disposing-flaget og validere samtlige referencer i Dispose().
Skønt min argumentation er ændret en anelse, er konklusionen stadig den samme. Når det kommer til Finalize-metoder (og Dispose()-metoder, der kaldes fra disse), er det bedre at gå med livrem og seler end at forsøge at være lidt smart, så brug disposing-flaget og check for null for en sikkerheds skyld.