Home » Tutorials » Programmierkonzepte » Fehlerbehandlung

Fehlerbehandlung

Vermeiden von Fehlern

Der Titel ist hoch gegriffen, denn Fehler kann man nicht vermeiden. Wohl aber kann man die Anzahl der eigenen Fehler minimieren, wenn man sich an bestimmte Verhaltensmuster und Programmiertechniken beim Erstellen des Quelltextes hält. Die folgenden Abschnitte sollen dem Einsteiger ein Gespür dafür geben.

Lesbarkeit des Quelltextes

Das Verständnis für einen Quelltext steht und fällt mit seiner Lesbarkeit. Die Stilmittel, die einem Programmierer dafür zur Verfügung stehen sind allerdings recht begrenzt. Umso wichtiger ist es deshalb, diese Mittel voll auszuschöpfen. Die Beachtung von nur wenigen grundsätzlichen Regeln wird uns die Lesbarkeit des Textes und die Fehlersuche darin erheblich erleichtern. Wir betrachten jetzt einen voll funktionsfähigen Quelltext, so wie er im schlimmstmöglichen Fall, in einem Delphi-Forum, gepostet werden könnte:

unit UEinrueckungUndBenennung;

interface

uses
  Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls;

type
  TForm1 = class(TForm)
    Memo1: TMemo;
    Button1: TButton;
    Edit1: TEdit;
    procedure Button1Click(Sender: TObject);
  private
    { Private-Deklarationen }
  public
    { Public-Deklarationen }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.Button1Click(Sender: TObject);
var
arr:array of integer;
i,a:integer;
begin
if trystrtoint(edit1.Text,a) and (a>2) then
begin
setlength(arr,a);
arr[0]:=0;arr[1]:=1;
for i:=2 to high(arr) do arr[i]:=arr[i-1]+arr[i-2];
memo1.Clear;
for i:=0 to high(arr) do memo1.Lines.Add(inttostr(arr[i]));
end
else showmessage('Ungültige Ganzzahl-Eingabe');
end;

end.

Die Funktionalität spielt sich einzig in dieser obigen Methode ab, in der absolut keine Struktur zu erkennen ist. Das ist jedoch eine Grundvoraussetzung um dem Programmablauf gedanklich folgen zu können. Eine einfache oder blockweise Einrückung von 2 Leerzeichen hat sich hier etabliert. Zusätzlich wird die Anzahl an Anweisungen pro Zeile auf maximal eins reduziert, denn das zeilenweise Abarbeiten des Debuggers würde die Betrachtung von Variablen unmöglich machen. Die beiden For-Schleifen z.B. würden vom Debugger am Stück ausgewertet werden und das gäbe uns keine Möglichkeit zur Einsicht oder Manipulation der Daten.

procedure TForm1.Button1Click(Sender: TObject);
var
  arr:array of integer;
  i,a:integer;
begin
  if trystrtoint(edit1.Text,a) and (a>2) then
  begin
    setlength(arr,a);
    arr[0]:=0;
    arr[1]:=1;
    for i:=2 to high(arr) do
      arr[i]:=arr[i-1]+arr[i-2];
    memo1.Clear;
    for i:=0 to a-1 do
      memo1.Lines.Add(inttostr(arr[i]));
  end
  else 
    showmessage('Ungültige Ganzzahl-Eingabe');
end;

Die Veränderung ist groß und so manchen könnte auch schon klar sein, was dieser Text bewirkt. Um die Lesbarkeit weiter zu steigern, vergeben wir allen verwendeten Bezeichnern sprechende Namen. Es ist überaus wichtig, aus der Benennung heraus bereits Rückschlüsse auf Bedeutung, Typen oder Funktionsweisen ziehen zu können. So werden Prozeduren nach ihrer Verwendung benannt, zumeist beginnend mit einem Verb in Befehlsform und Funktionsnamen sollten auf den entsprechenden Rückgabewert schließen lassen. Bei Komponenten wird der Typ als Präfix, mit 3 bzw. 4 Buchstaben abgekürzt, vorangestellt bzw. als Postfix angehängt. Unterstützt wird die Benennung durch InfixCaps bzw. CamelCase-Schreibweise, die jedes Hauptwort mit einem Großbuchstaben beginnen lässt. Typen wird ein T, Argumenten ein A und Feldern ein F in ihrer Benennung vorangestellt. Mit Ausnahme von lokalen Schleifenvariablen sollten einbuchstabige Variablennamen vermieden werden.
Soviel erstmal zu einer groben Einteilung, für genauere Informationen schaue man in den Delphi-Styleguide. Unabhängig jedoch von der verwendeten Ausrichtung und Benennung ist wichtig, dass sie konsistent über den ganzen Quelltext hinweg gegeben ist.
Ein großes Thema ist auch immer wieder die verwendete Sprache: der Programmierstandard ist Englisch; Delphi benutzt englische Bezeichner und Quelltexte ausschließlich in englischer Sprache wirken natürlicher. Es macht allerdings keinen Sinn darauf zu beharren, wenn man diese Sprache nicht, oder nur eingeschränkt beherrscht. Schlecht gewählte Bezeichner oder gar Gemischtschreibung verwirren den Lesenden.

type
  TFrmFibonacci = class(TForm)
    MemFibonacciAusgabe: TMemo;
    BtnBerechneUndZeigeFibonacci: TButton;
    EdtFibonacciAnzahl: TEdit;
    procedure BtnBerechneUndZeigeFibonacciClick(Sender: TObject);
  private
    { Private-Deklarationen }
  public
    { Public-Deklarationen }
  end;

var
  FrmFibonacci: TFrmFibonacci;

implementation

{$R *.dfm}

procedure TFrmFibonacci.BtnBerechneUndZeigeFibonacciClick(Sender: TObject);
var
  fibonacci: array of Integer;
  i, anzahl: Integer;
begin
  if TryStrToInt(EdtFibonacciAnzahl.Text, anzahl) and (anzahl > 2) then
  begin
    SetLength(fibonacci, anzahl);
    fibonacci[0] := 0;
    fibonacci[1] := 1;
    for i := 2 to High(fibonacci) do
      fibonacci[i]:= fibonacci[i-1] + fibonacci[i-2];
    MemFibonacciAusgabe.Clear;
    for i := 0 to High(fibonacci) do
      MemFibonacciAusgabe.Lines.Add(IntToStr(fibonacci[i]));
  end
  else
    ShowMessage('Ungültige Ganzzahl-Eingabe');
end;

Nach Anpassen aller Bezeichner kann sich das Ergebnis bereits sehen lassen. Dass es hier um Fibonacci-Zahlen geht ist inzwischen auch klar geworden. Trotzdem besteht weiterhin Steigerungspotenzial in der Lesbarkeit, denn die Methode wirkt immer noch unaufgeräumt. Das erkennt man auch an ihrer Benennung, denn es sind 2 Vorgänge in ihr vereint – berechne und zeige an. Das modulare Prinzip sieht jedoch eine Aufteilung in kleine Teilaufgaben vor, was enorme Vorteile bei der Fehlersuche und der Wartung des Quelltextes bietet.
Wir lagern also diese beiden Vorgänge in eigene Prozeduren aus und übergeben die zu verarbeitenden Daten als Parameter.

type
  TFibonacciArray = array of Integer;

  TFrmFibonacci = class(TForm)
    MemFibonacciAusgabe: TMemo;
    BtnBerechneUndZeigeFibonacci: TButton;
    EdtFibonacciAnzahl: TEdit;
    procedure BtnBerechneUndZeigeFibonacciClick(Sender: TObject);
  private
    { Private-Deklarationen }
    procedure BerechneFibonacci(AFibonacci: TFibonacciArray);
    procedure ZeigeFibonacci(AAusgabe: TStrings; AFibonacci: TFibonacciArray);
  public
    { Public-Deklarationen }
  end;

var
  FrmFibonacci: TFrmFibonacci;

implementation

{$R *.dfm}

procedure TFrmFibonacci.BerechneFibonacci(AFibonacci: TFibonacciArray);
var
  i: Integer;
begin
  AFibonacci[0] := 0;
  AFibonacci[1] := 1;
  for i := 2 to High(AFibonacci) do
    AFibonacci[i]:= AFibonacci[i-1] + AFibonacci[i-2];
end;

procedure TFrmFibonacci.ZeigeFibonacci(AAusgabe: TStrings; AFibonacci: TFibonacciArray);
var
  i: Integer;
begin
  AAusgabe.Clear;
  for i := 0 to High(AFibonacci) do
    AAusgabe.Add(IntToStr(AFibonacci[i]));
end;

procedure TFrmFibonacci.BtnBerechneUndZeigeFibonacciClick(Sender: TObject);
var
  fibonacci: TFibonacciArray;
  anzahl: Integer;
begin
  if TryStrToInt(EdtFibonacciAnzahl.Text, anzahl) and (anzahl > 2) then
  begin
    SetLength(fibonacci, anzahl);
    BerechneFibonacci(fibonacci);
    ZeigeFibonacci(MemFibonacciAusgabe.Lines, fibonacci);
  end
  else
    ShowMessage('Ungültige Ganzzahl-Eingabe');
end;

Der Text wurde zwar insgesamt länger, doch die gewonnen Vorteile überwiegen klar. Wegen der Kürze der Routine ist auf den ersten Blick zu erkennen, was im ButtonClick passiert. Die beiden ausgelagerten Prozeduren sind jeweils genau auf eine Funktion beschränkt. Um sie nun aber universell gebrauchen zu können, müssten sie vom Formular entkoppelt werden. BerechneFibonacci z.B. ist immer noch davon abhängig, dass das übergebene Array existiert und mindestens 2 Elemente enthält. Das Hauptanliegen dieses Abschnitts war jedoch die Steigerung der Lesbarkeit und die Mittel, die dafür zur Verfügung stehen.

Speicherlecks

Speicherlecks sind Speicherbereiche, die angefordert aber nicht wieder freigegeben werden. In selten Fällen werden Speicherlecks von Einsteigern überhaupt wahrgenommen, da beim Programmende der vom Betriebssystem für unser Programm reservierte Speicher wieder aufgeräumt wird. Mit Ausnahme von geteilten Speicherbereichen, wie z.B. Speicher bei gemeinsam genutzen DLLs, haben Speicherlecks nur dann merkliche Relevanz, wenn der von Windows zur Verfügung gestellte Speicher zur Neige geht. Das ist dann der Fall, wenn unser Programm ständig neuen Speicher anfordert, aber diesen nicht wieder freigibt. Der Rechner reagiert dann irgendwann träge, da Windows Speicher auf die Festplatte auslagert.
Aber soweit muss es natürlich nicht kommen. Es gibt einige Grundregeln im Bezug auf Erzeugen und Freigeben von Speicher, die befolgt werden sollten, damit keine Speicherlecks auftreten und vor Veröffentlichung eines Programms sollte auch eine Prüfung dahingehend stattfinden.
Aber zunächst einmal müssen wir ein Speicherleck erzeugen, um die Sachverhalte zu klären:

type
  TForm1 = class(TForm)
    Memo1: TMemo;
    OpenDialog1: TOpenDialog;
    procedure FormCreate(Sender: TObject);
  private
    { Private-Deklarationen }
  public
    { Public-Deklarationen }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

function ErzeugeRueckwaertsListe(AListe: TStrings): TStrings;
var
  i: Integer;
begin
  Result := TStringList.Create;
  for i := AListe.Count-1 downto 0 do
    Result.Add(ALIste[i]);
    //Result.Free;
end;

procedure TForm1.FormCreate(Sender: TObject);
var
  liste: TStringList;
begin
  if OpenDialog1.Execute then
  begin
    liste := TStringList.Create;
    try
      liste.LoadFromFile(OpenDialog1.FileName);
      Memo1.Lines.Assign(ErzeugeRueckwaertsListe(liste));
    finally
      liste.Free;
    end;
  end;
end;

Auf dem Formular befinden sich ein TMemo und ein TOpenDialog. Der Opendialog wird im OnCreate des Formulars geöffnet und erwartet die Übergabe einer Textdatei. Diese wird eingelesen, von der Funktion umgedreht und im Memo dann ausgegeben. Die Funktion steht hier stellvertretend für einen komplizierten, ausgelagerten Ablauf. Das alles funktioniert reibungslos. Wie also erfährt man von einem Speicherleck?
Die globale Variable ReportMemoryLeaksOnShutdown veranlasst den Speichermanager beim Beenden der Anwendung, den Speicher nach Speicherlecks zu durchsuchen. Diese boolesche Variable müssen wir zum frühest möglichen Zeitpunkt im Programm aktivieren. Im Falle einer Konsolenanwendung in der ersten Zeile des Hauptprogramms. Für unsere Formularanwendung lassen wir uns in der Projektverwaltung die Projektdatei als Quelltext anzeigen und ergänzen entsprechend:

program Speicherleck;

uses
  Forms,
  USpeicherleck in 'USpeicherleck.pas' {Form1};

{$R *.res}

begin
  ReportMemoryLeaksOnShutdown := True; //Aktivierung
  Application.Initialize;
  Application.MainFormOnTaskbar := True;
  Application.CreateForm(TForm1, Form1);
  Application.Run;
end.

Wählen wir beim Programmstart nun eine Textdatei aus und beenden danach das Programm, so sehen wir den Report des Speichermanagers. Je nach Größe der ausgewählten Datei kann diese Ausgabe sehr umfangreich sein. Sie beinhaltet Einträge mit einer bestimmten Anzahl an UnicodeStrings, Unknown und TStringList. Einzig relevant für uns ist das, was wir selbst erzeugt haben, nämlich die TStringList. Der Rest ist eine Folgeerscheinung und verschwindet automatisch, wenn die Instanz von TStringlist wieder freigegeben wird.
Ähnlich verhält es sich, wenn z.B. ein TImage erzeugt und nicht freigegeben wird. Hier erscheinen neben dem TImage auch ein TPicture, TFont, TBrush, TPen etc. in der Ausgabe. Alle wurden durch das TImage erzeugt und sind somit auch abhängig von der Freigabe des TImage. Es stellt sich natürlich nachfolgend die Frage: wie kommt es zu diesem Speicherleck? Dazu müssen wir nur die Erzeugung der beiden Stringlisten in den beiden Routinen miteinander vergleichen. Im FormCreate wird die Liste im Ressourcenschutzblock mit Free entsorgt. In ErzeugeRueckwaertsListe entfällt das Freigeben mit Free und so entsteht genau hier das Speicherleck. Testweise können wir dort das Free entkommentieren, denn dann ist das Speicherleck behoben. Allerdings haben wir dann auch keine Ausgabe mehr, denn die Liste wurde ja freigegeben bevor sie als Rückgabe dienen konnte.
Das Beispiel wollte also nicht nur die Anwendung von ReportMemoryLeaksOnShutdown zeigen, sondern auch gleichzeitig auf eine häufig gemachte Fehlerquelle aufmerksam machen. Folgende Anpassung unseres Quelltextes behebt dieses Problem auf einfache Art:

procedure BefuelleRueckwaertsListe(AListe, ARueckwaertsliste: TStrings); //Übergabe als Parameter
var
  i: Integer;
begin
  for i := AListe.Count-1 downto 0 do
    ARueckwaertsliste.Add(ALIste[i]);
end;

procedure TForm1.FormCreate(Sender: TObject);
var
  liste, rueckwaertsliste: TStringList;
begin
  if OpenDialog1.Execute then
  begin
    liste := nil;
    rueckwaertsliste := nil;
    try
      liste := TStringList.Create;
      rueckwaertsliste := TStringList.Create; //Erzeugen beim Aufrufer
      liste.LoadFromFile(OpenDialog1.FileName);
      BefuelleRueckwaertsListe(liste, rueckwaertsliste);
      Memo1.Lines.Assign(rueckwaertsliste);
    finally
      rueckwaertsliste.Free;
      liste.Free;
    end;
  end;
end;

BefuelleRueckwaertsListe ist jetzt eine Prozedur und nicht mehr eine Funktion, die ihre eigene Rückgabe erzeugen muss. Beide Stringlisten werden in FormCreate erzeugt und dann der Prozedur als Parameter übergeben. Dort wird die leere rueckwaertsliste befüllt, nach Rückkehr aus der Prozedur dem Memo zugewiesen und dann werden beide Listen im Ressourcenschutzblock freigegeben.
Der Hauptunterschied ist also, dass rueckwaertsliste dort erzeugt wird, wo sie auch freigegeben wird.
Diese Erkenntnis lässt sich dahingehend verallgemeinern, dass erzeugte Elemente auf der Ebene freigegeben werden sollten, auf der sie auch erzeugt werden, d.h. lokal, wie im Beispiel gezeigt innerhalb eines Try-Finally-Blocks. Elemente auf Klassen/Formularebene werden in Create/FormCreate erzeugt und im dazugehörenden Gegenpart Destroy/FormDestroy zerstört. Auf Unitebene heißt das Pärchen dann initialization- und finalization-Abschnitt.
Erzeuger und Zerstörer treten auch immer paarweise auf. Create und Free haben wir schon gesehen und auf jedes Create folgt irgendwo ein Free. Es sei denn, es wird z.B. beim Erzeugen einer Komponente ein Eigentümer bestimmt, der sich dann eigenverantwortlich um die Freigabe kümmert. Andere Paare sind z.B. GetMem bzw. AllocMem und FreeMem oder auch New und Dispose.