Bob Swart (aka Dr.Bob)
Delphi Exceptions

Als we een bepaald probleem met een computerprogramma willen oplossen richten we ons over het algemeen eerst op het algoritme dat dit probleem op kan lossen. Pas in tweede instantie maken we ons druk over wat er allemaal fout kan gaan tijdens het uitvoeren van dit algoritme. Exceptions kunnen hierbij een belangrijke rol spelen...

Laten we eerst twee rode draad voorbeeld verzinnen voor dit artikel. We gaan de grootte van een bestand bepalen (FileSize) en een bestand kopieeren (FileCopy), en gebruiken bij het laatste een dynamische buffer die natuurlijk niet groter hoeft te zijn dan de eerder bepaalde bestandsgrootte. De code om het bestand te kopieren zetten we in een aparte FileCopy routine, terwijl de aanroepende code er als volgt uit zou kunnen zien:

  var
    BronNaam, DoelNaam: String;
  begin
    write('Van: ');
    readln(BronNaam);
    write('Naar: ');
    readln(DoelNaam);
    FileCopy(BronNaam,DoelNaam);
    { maar wat nu als er iets fout ging? }
  end.
Er kunnen natuurlijk verschillende dingen mis gaan binnen de FileCopy funktie. Het bronbestand bestaat niet, of is gelijk aan het doelbestaan, de schijf is vol, of er is onvoldoende geheugen om de buffer te alloceren die we voor het kopieren willen gebruiken. Traditionele foutafhandelings technieken in computerprogramma's maken vaak gebruik van een globale functie, een globale 'error' variabele of functies die (fout-) waarden teruggeven als er iets mis is. Deze technieken hebben tot gevolg dat foutcondities gecontroleerd moeten worden gedurende het uitvoeren van een aanroepend algoritme waar het eigenlijk om gaat. Hierdoor wordt dit algoritme bedolven onder de if-then-else constructies en is het moeilijk te lezen en onderhouden. Het teruggeven van een foutwaarde door een functie kan een extra probleem vormen indien er in het aanroepend algoritme onderscheid gemaakt moet worden tussen foutwaarden en geldige functiewaarden (bijvoorbeeld bij een rekenkundige funktie). Beschouw bijvoorbeeld de "procedure FileCopy(BronNaam, DoelNaam String);". Hoe kunnen we binnen de procedure detecteren dat er wat mis ging? Een van de middelen is de IOResult variabele, waaraan we in de goede oude tijd konden zien of er een I/O-operatie was mislukt. Dat deze oplossing echter niet waterdicht is zullen we in het vervolg van dit artikel zien. Door het gebruik van exceptions kunnen we een oplossing realiseren, en ons in eerste instantie concentreren op het algoritme dat het eigenlijke werk doet, en pas in een later stadium ons bekommeren om de foutcondities of uitzonderingen (exceptions).

Syntax
De syntax van exceptions is gedeeltelijk 'geleend' van C++. In Delphi's ObjectPascal zijn er vier nieuwe keywords ter beschikking: raise (om een exception 'aan' te zetten), try (om een exception block binnen te gaan), except (om een exception te behandelen) en finally (om een code block toch uit te voeren nadat een exception is opgetreden).

Exception Class
We kunnen zowel een standaard (voorgedefinieerde) exception gebruiken, of onze eigen exceptions defini ren. De laatste zal afgeleid moeten worden van de exception hierarchie binnen ObjectPascal, bijvoorbeeld als volgt:

  type
    EIOException   = class(Exception);
    EFileNotFound  = class(EIOException);
    EFileCopyError = class(EIOException);
Wat belangrijk is om te weten, is dat de class Exception behalve zijn type (en ClassName), ook detail informatie kan bevatten over wat er precies is fout gegaan, in het Message string veld. Op die manier kan vanaf de plek waar iets fout is gegaan (en de exception aangemaakt is), informatie doorgegeven worden naar de plek waar de exception wordt afgehandeld (en meestal pas een boodschap aan de eindgebruiker wordt gepresenteerd).

Genereren Exceptie: Raise
Met het gebruik van exceptions hoeven we niet langer foutwaarden terug te geven. We kunnen simpelweg een exception 'aan' zetten met 'raise' als er iets mis gaat. Een 'FileSize' functie zou er bijvoorbeeld als volgt uit kunnen zien:

  function FileSize(FileName: String): LongInt;
  var
    SRec: TSearchRec;
  begin
    if FindFirst(FileName,faArchive,SRec) <> 0 then { error }
      raise EFileNotFound.Create('File '+FileName+' not found')
    else
      FileSize := SRec.Size;
    FindClose(SRec)
  end {FileSize};
Zoals we in bovenstaande listing kunnen zien wordt een exception van het type 'EFileNotFound' gemaakt (create) en 'aan' gezet (met raise) als de FindFirst funktie faalt. In dat geval wordt de rest van de functie niet meer uitgevoerd en zal de functie FileSize worden afgebroken. Vervolgens wordt gezocht naar een 'exception handler' voor de exception EFileNotFound. De call-stack wordt hiertoe afgelopen naar boven, en elke 'aanroeper' wordt gecontroleerd op de aanwezigheid van de gezochte exception handler. Dit proces gaat door totdat de gewenste exception handler gevonden is, of totdat de call-stack leeg is. Als een exception handler is gevonden, dan wordt deze uitgevoerd en gaat het programma verder direkt na deze exception handler (bij de betreffende 'aanroeper' aan het einde van een try-block). Aangezien de stack onderweg wordt opgegeten lopen we volledig terug naar boven. Ook dus in het geval van een indirecte aanroep wanneer de routine met de exception handler eerst een procedure aanroept die dan vervolgens weer een functie aanroept waar de problemen ontstaan. Als er geen exception handler is gevonden, dan wordt de default exception handler uitgevoerd: resulterend in een run-time error en het afbreken van het programma.

Afvangen Exceptie: Try
Als we een Pascal block binnen willen gaan waarbinnen we gebruik willen maken van exception handling (we willen dus in staat zijn om een exception af te vangen als er eentje aan wordt gezet - in plaats van het programma af te laten breken), dan moeten we gebruik maken van het 'try' keyword, bijvoorbeeld als volgt:

  procedure FileCopy(BronNaam, DoelNaam: String);
  const
    BufSize = 48 * 1024;
  var
    Bron,Doel: File;
    Buffer: Pointer;
    Size: Cardinal;
  begin
    try
      GetMem(Buffer,BufSize);
      Assign(Bron, BronNaam);
      Reset(Bron,1);
      Assign(Doel, DoelNaam);
      Rewrite(Doel,1);
      repeat
        BlockRead(Bron,Buffer,BufSize,Size);
        BlockWrite(Doel.Buffer,Size)
      until Size < BufSize;
      Close(Bron);
      Close(Doel);
      FreeMem(Buffer)
    end
  end {FileCopy};
In bovenstaand voorbeeld kunnen verschillende I/O exceptions ontstaan. In dit specifieke geval zal het programma overigens niet eens compileren, aangezien we (nog) geen exception handler hebben gedefinieerd. De exception wordt dus wel afgevangen (doordat we het keyword try gebruiken), maar nog niet afgehandeld.

Afhandelen Exceptie: Except
Teneinde een (mogelijk) gegenereerde exception af te handelen dienen we gebruik maken van het 'except' keyword:

  begin
    try
      FileSize('C:\autoexec.bat');
    except
      ShowMessage('File not found')
    end
  end.

Onderscheiden Excepties: On X do
Een probleem met bovenstaand voorbeeld is dat de boodschap 'File not found' zal worden gegenereerd voor elke mogelijk exception die op deze plaats wordt opgevangen. Voor dit kleine voorbeeld is dat geen probleem, maar voor een complexer algoritme is het noodzakelijk om onderscheid te kunnen maken tussen de verschillende soorten exceptions. Gelukkig kan dat door gebruik te maken van de "on X do" syntax:

  begin
    try
      FileSize('C:\autoexec.bat');
    except
      on EFileNotFound do
        ShowMessage('File not found')
    end
  end.
We kunnen het zelfs nog mooier maken als we gebruik maken van de informatie die in de exception class zelf zit opgesloten, namelijk de naam van het bestand dat niet gevonden kon worden. Dit kunnen we doen door binnen de on X do clause een instantie van deze exception class aan te maken, die we dan binnen de lokale skope kunnen gebruiken. Dit ziet er als volgt uit:
  begin
    try
      FileSize('C:\autoexec.bat');
    except
      on E.EFileNotFound do
        ShowMessage(E.Message)
    end
  end.
Naast Message kunnen we ook gebruik maken van ClassName, waardoor we zelfs kunnen vertellen welk soort exception is opgetreden en wat de foutmelding precies is met ShowMessage(E.ClassName+': '+E.Message); Dit scheelt weer in de hoeveelheid strings die we anders zelf in de code moeten zetten (wat natuurlijk ook een stuk netter is).

Nette Afsluiting: Finally
Stel dat we een stuk code hebben waarin we eerst een bepaalde hoeveelheid geheugen alloceren, en vervolgens iets gaan uitvoeren waarin exceptions kunnen optreden, om tenslotte pas het geheugen weer vrij te geven. Het zou natuurlijk vervelend zijn als er voor het eind van het try-block een exception op zou treden waardoor we het geheugen niet meer vrij zouden kunnen geven (dit is overigens een probleem met de C++ exceptions). Teneinde dit probleem op te lossen, kunnen we gebruik maken van het 'finally' keyword in een try-block:

  procedure FileCopy(BronNaam, DoelNaam: String);
  const
    BufSize = 48 * 1024;
  var
    Bron,Doel: File;
    Buffer: Pointer;
    Size: Cardinal;
  begin
    GetMem(Buffer,BufSize);
    try
      Assign(Bron, BronNaam);
      Reset(Bron,1);
      Assign(Doel, DoelNaam);
      Rewrite(Doel,1);
      repeat
        BlockRead(Bron,Buffer,BufSize,Size);
        BlockWrite(Doel.Buffer,Size)
      until Size < BufSize;
      Close(Bron);
      Close(Doel);
    finally
      FreeMem(Buffer)
    end
  end {FileCopy};
Wat er tijdens het kopieren (binnen het try-finally blok) ook zal gebeuren, de "FreeMem(Buffer)" code achter de finally zal worden uitgevoerd, waardoor we zorgen dat er geen geheugen verloren gaat. Dit is natuurlijk een uitermate belangrijke zaak als het om grotere hoeveelheden geheugen en bijvoorbeeld Windows resources gaat. Daarom is een van mijn persoonlijke richtlijnen ook om na elke allocatie van geheugen en/of resources meteen een try-finally blok te schrijven, waarbij na de finally de zaak weer netjes wordt opgeruimd. Helaas kunnen try-except en try-finally niet direkt gecombineerd worden in ObjectPascal. Hiertoe moeten ze genest worden, maar dit is in praktijk geen probleem (het zijn ook in feite twee verschillende soorten structuren: eentje voor foutafhandelijk en de ander voor resourcehuishouding). De uiteindelijke FileCopy funktie kan er bijvoorbeeld als volgt uit komen te zien (waarbij ook elk bestand gegarandeerd weer gesloten wordt, en er een exception gecreeerd wordt indien niet het gehele bestand in een keer werd ingelezen of weer weggeschreven):
  procedure FileCopy(BronNaam, DoelNaam: String);
  var
    Bron,Doel: File;
    Buffer: Pointer;
    BufSize,Size: Cardinal;
  begin
    try
      BufSize := FileSize(BronNaam)
    except
      BufSize := 0;
      raise { re-raise the exception }
    end;
    GetMem(Buffer,BufSize);
    try
      Assign(Bron, BronNaam);
      Reset(Bron,1);
      try
        Assign(Doel, DoelNaam);
        Rewrite(Doel,1);
        try
          BlockRead(Bron,Buffer,BufSize,Size);
          if Size < BufSize then { not entire file read? }
            raise EFileCopyError.Create(
              Format('FileCopy: %d bytes read, %d expected.',[Size,BufSize]));
          BlockWrite(Doel,Buffer,BufSize,Size)
          if Size < BufSize then { not entire file written? }
            raise EFileCopyError.Create(
              Format('FileCopy: %d bytes written, %d expected.',[Size,BufSize]));
        finally
          Close(Bron);
        end;
      finally
        Close(Doel);
      end;
    finally
      FreeMem(Buffer,BufSize);
    end
  end {FileCopy};
Merk op dat we tevens van een laatste eigenschap van raise gebruik hebben gemaakt (binnen een except-skope), namelijk het weer opnieuw "opsturen" van de exception die zojuist afgevangen en verwerkt is. Overigens is het op nul zetten van de BufSize variabele hierbij niet echt nodig, aangezien de exception zelf al zal zorgen dat de procedure wordt afgebroken. Het is hier slechts ter illustratie opgenomen. Het gebruik, door bijvoorbeeld iemand anders en in een heel ander programma deel, ziet er dan als volgt uit:
  var
    BronNaam, DoelNaam: String;
  begin
    try
      write('Van: ');
      readln(BronNaam);
      write('Naar: ');
      readln(DoelNaam);
      FileCopy(BronNaam,DoelNaam);
    except
      on E: Exception do
        ShowMessage(E.ClassName+': '+E.Message)
    end
  end.
De try-except zal een eventueel door de FileCopy funktie gedetecteerde exception afvangen en afhandelen binnen het except blok. Merk op dat we dus in feite exceptions in verschillende lagen gebruiken: zowel binnen de funktie FileCopy (waar ze worden afgevangen en aangemaakt) als in het hoofdprogramma (dat ze alleen maar afvangt) en de run-time library (die ze over het algmeen slechts genereert en naar "boven" stuurt).

Conclusies
We hebben gezien dat exceptions ons in staat stellen om de foutafhandeling (het except deel) binnen het algoritme te scheiden. We kunnen ons nu weer in eerste instantie richten op hetgeen dat gedaan moet worden, en pas later op de afhandeling van de fouten en uitzonderingen. Bovendien vormen exceptions een belangrijke ondersteuning voor veilig programmeren. We zagen dat het keyword raise vooral nut kan hebben bij de bouw van (standaard)funkties waarbinnen fouten kunnen onstaan, terwijl de keywords try, except en finally met name voor de gebruikers van deze functies van belang zijn (namelijk voor de afhandeling van de opgetreden foutsituaties).

Nu we hebben gezien wat exceptions kunnen betekenen, gaan we de volgende keer dieper in op het voorkomen van fouten door gebruik te maken van assertions binnen Delphi.
Mocht iemand nog vragen, opmerkingen of suggesties hebben, dan hoor ik die het liefst via .


Dit artikel is eerder verschenen in SDGN Magazine #41 - april 1997

This webpage © 1997-2006 by webmaster drs. Robert E. Swart (aka - www.drbob42.com). All Rights Reserved.