06/08/1995, 16/02/2006

Fehlerfreie Software ?!

Strategien für die Softwareentwicklung

Es wird immer wieder behauptet, es sei nicht möglich, fehlerfreie Software zu entwickeln. Zu komplex seien die Softwareprodukte geworden, das menschliche Gehirn nicht darauf ausgelegt, zu unkalkulierbar die Rahmenbedingungen der Zielplatform.

Dieser Aufsatz stellt Strategien und praktische Lösungen dar, die der Verfasser innerhalb 30 Softwareentwicklungsjahren zusammengetragen hat, und die ihm ermöglichten, auch komplexeste Software termingerecht und kundenorientiert zu realisieren.

Kurze Funktionen

Der Mensch ist nicht in der Lage, große und komplexe Programmteile insgesamt zu verstehen und damit auch auf Fehler zu überprüfen. Damit lautet das Ziel, das Programm in kleinste Einheiten zu zerlegen. Des besseren Überblicks wegen sollte eine Funktion maximal eine Bildschirmseite ausfüllen. Diese Teilfunktion arbeitet dann eine überschaubare Aufgabe ab.

Diese recht triviale Strategie ist in der Praxis unwahrscheinlich effizient. Wenn der Programmierer in seinem Sourcecode blättern muß, muß er sich den aktuellen Sachverhalt merken und mit dem vergleichen, der jetzt auf dem Bildschirm steht. Das dieses nicht so einfach ist, weiß jeder Praktiker, den meistens blättert man nicht nur einmal, sondern gleich zwei- oder dreimal auf die gleichen Stellen. (hieß' das denn jetzt ClipBoard oder Clipboard).

Erfolgskontrolle

Die kurzen Funktionen müßen sich, soweit möglich, selbstüberprüfen und das Erfolgsergebnis zurückgeben. Diese Erfolgskontrolle ist so wichtig, daß der einzig mögliche Rückgabewert einer C-Funktion ausschließlich dazu hergenommen werden muß.

Ist eine Funktion zu schreiben, die einen Zahlenwert aus einer Datei liest, so ist die Funktion nicht wie

  LONG ReadNumber (FILE *fd)
zu schreiben, sondern
  BOOL ReadNumber (FILE *fd, LONG *lpResult)
damit hat die aufrufende Funktion, die Möglichkeit, eindeutig Fehler zu erkennen und muß diese nicht aus der Werterückgabe extrapolieren (ist der Rückgabewert 0 nun in der Datei gestanden und zeigt dieser Rückgabewert einen Fehler an).

Assertion

Auch wenn die Fehlererkennung in einer kurzen Funktion leichter ist, so muß doch auf den Fehler reagiert werden. Das heißt für den Programmier, eine entsprechende Meldung zu erzeugen und diese auszugeben. Nicht selten stellt man fest, daß der Programmierer bei Standardaufgaben, die ja eigentlich immer funktionieren, diesen Aufwand gescheut hat.

Hier kann man das Makro ASSERT einsetzen. Dieses Makro stopped die Programmausführung und zeigt ohne großen Programmieraufwand den aufgetretenen Fehler an. Gerade bei der Programmentwicklung und beim Debuggen stellt man so jede Menge Fehler fest, die sonst unentdeckt bleiben würden.

Für den Kunden ist es natürlich nicht befriedigend, eine Meldung

  ASSERTion Error DRAW.CPP (234)
zu sehen und dann das Programmende miterleben zu müssen. Die Erfolgskontrolle, ob eine Datei geöffnet werden kann, sollte man natürlich nicht mit ASSERT abfangen, schließlich könnte es ja sein, daß die gewünschte Datei nicht in diesem Verzeichnis liegt oder der Anwender sich vertippt hat. In den nachfolgenden Funktionen, die von einer korrekt geöffneten Datei ausgehen, ist jedoch der ASSERT eine einfache und schnelle Möglichkeit, dieses zu überprüfen. Wenn hier die Datei nicht korrekt geöffnet ist, dann ist ja die Programmlogik nicht in Ordnung und auch das ist ein Softwarefehler.

Es hat sich in der Praxis als sehr erfolgreich herausgestellt, bei der Programmentwicklung das Makro ASSERT sehr excessiv zu nutzen und erst nach dem Beweis der Funktionsfähigkeit diejenigen ASSERTs durch Meldungen zu ersetzen, an dem auch eine falsche Benutzereingabe den Fehler verursachen kann.

Parametervalidierung

Jede Funktion muß vor ihrem Arbeitsteil jeden seiner Parameter kontrollieren und das auch, wenn zufälligerweise die aufrufende Funktion diese Überprüfung vorgenommen hat, schließlich könnte die Funktion auch von einer anderen Funktion aufgerufen werden, bei der diese Überprüfung aus Versehen nicht stattgefunden hat.

  BOOL ReadNumber (FILE *fd, LONG *lpResult)
  {
    BOOL bRet = FALSE;
    VALID  (fd);       // ist der Filepointer gültig und ungleich 0
    VALID  (lpResult); // ist der Ergebnispointer gültig ungleich 0

    ASSERT (fd->IsFileOpen ()); // Datei muß bereits geöffnet sein
    LONG r = 0L;
    if (fd->ReadBlock (&r, SIZEOF (LONG)))
    {
      if (!fd->Error ())
      {
        *lpResult = r;
        bRet = TRUE;
      }
    }
    return (bRet);
  }

Das zusätzlich eingesetzte Makro VALID überprüft, ob der Pointer nicht den Bitmusterwert hat, der bei der Speicherverwaltung gesetzt wird, das würde auf ein nicht initialisiertes Element hinweisen. Siehe hierzu Speicherverwaltung.

Eine Selbsteinschränkung auf das zur Zeit Implementierte ist ebenfalls sinnvoll. Angenommen, das Programm in der Version 1 soll maximal 16 Farben unterstützen, dann sollte die Zeichenfunktion hier diese Überprüfung auch wahrnehmen.

  BOOL DrawColor (INT iColorNr, INT iWhat)
  {
    BOOL bRet = FALSE;
    ASSERT (iColorNr > 0);  // keine negativen und 0-Farbe nicht erlaubt
    ASSERT (iColorNr <= 16);// mehr Farben noch nicht implementiert
    ASSERT (iWhat >= 1);    // 1=Rechteck, 2=Kreis, 3=Oval, 4=Ellipse
    ASSERT (iWhat <= 4);    // mehr ist noch nicht implementiert
    .... working
    return (bRet);
  }

Bei der nächsten Version muß der Programmierer sowieso seinen Compiler anwerfen und die Unterstützung für 256 Farben programmieren. Der Aufwand, dann die 3 oder 4 ASSERTs auf den Wert 256 anzupassen ist dann minimal. Mit dem nachfolgenden Classenorientierungsverfahren ist der Aufwand noch geringer. Da der Programmierer ja die neue Funktion testen muß (geht's überhaupt), werden die dann ungültigen ASSERTs sowie aufgerufen. Gerade bei Funktionen mit mehreren Parametern des gleichen Typs können hier Parameterdreher und Falschaufrufe festgestellt werden.

Classenorientierung

Die objektorientierten Programmiersprachen bieten die Möglichkeit, die Fehlerüberprüfungen direkt in eine Classe zu implementieren und die Typüberprüfungen des Compilers weitergehend zu nutzen. Der im vorherigen Beispiel benutzte Parameter 'iWhat' sollte eine eigene Klasse erhalten. So kann der Compiler Parameterdreher entdecken und die fehlerüberprüfte Benutzung dieses Wertes wird erleichtert.

  class DRAWITEM
  {
    DRAWITEM (INT iWhat);      // typüberprüfter Konstruktur
    ~DRAWITEM ()          {};  // nichts zu tun im Destruktor
    operator INT ();           // Umwandlung zu INT
    __check (VOID);            // die Fehlerprüfroutine
    INT iItem;                 // der Speicherplatz
  };

  DRAWITEM::__check (VOID)
  {
    ASSERT (iItem > 0);        // 1=Rechteck, 2=Kreis, 3=Oval, 4=Ellipse
    ASSERT (iItem <= 4);       // mehr ist noch nicht implementiert
  }

  DRAWITEM::operator INT (VOID)
  {
    __check ();
    return (iItem);
  }

Hiermit wird bei jeder Benutzung dieses Typs automatisch die Gültigkeit überprüft. Bei der Programmerweiterung liegen die zu ändernden Programmzeilen übersichtlich beieinander.

Speicherverwaltung

Viele, sogar die meisten, undurchsichtigen Fehlverhalten von Software sind auf Fehler bei der Speicherverwaltung zurückzuführen. Entweder ist kein Speicher mehr frei, der angeforderte Speicherblock wird nicht richtig initialisiert, der zugeteilte Speicherbereich wird unabsichtlich überschrieben, der Speicherblock wird nicht mehr freigegeben oder ein bereits freigegebener Speicherbereich wird nochmals benutzt.

Prinzipiell wird die Speicheranforderungsfunktion (new oder malloc) mit einer eigenen Funktion überschrieben. Beim Anfordern werden ein paar mehr Bytes angefordert und der Speicherbereich mit einem bestimmten Bitmuster versehen. Die zusätzlichen Bytes (als Grenzüberwachung oben und unten) werden mit einer bestimmten Kennung versehen. Der Speicherblock wird in eine globale Liste eingetragen.

Bei der Speicherfreigabe werden die Grenzüberwachungsbytes kontrolliert, ob die Kennungen noch in Ordnung sind. Falls nicht, erfolgt ein ASSERT als Fehlererkennung, daß der Programmierer den Speicherbereich überschrieben hat. Der freizugebende Speicher wird wieder mit einem bestimmten Bitmuster versehen, freigegeben und aus der globalen Liste gelöscht. Greift der Programmierer dann nochmals auf den freigegebenen Speicher zu, so wird er (durch Parametervalidierung) recht schnell erkennen, das die nun enthaltenen Daten auf keinen Fall mehr von ihm stammen.

Beim Programmende wird die globale Speicherliste überprüft, ob noch nicht freigegebener Speicher eingetragen ist und dem Programmierer mitgeteilt.

Die Überprüfung, ob der angeforderte Speicher auch vorhanden ist, kann man im Großteil der Falle wieder mit ASSERT machen. (Sollten keine 2K Speicher mehr frei sei, so können Sie sicher sein, daß das jeweilige Betriebssystem schon lange abgeschmiert ist. Größere Speicherbereiche müssen jedoch mit Meldungen abgefragt werden, schließlich kann der Zielrechner etwas magerer ausgestattet sein als der Entwicklungsrechner).

Da nun eindeutig definiert ist, welche Daten der Speicherbereich beinhaltet (nämlich die Bitmusterkennung) kann man Elemente auch auf dieses Bitmuster kontrollieren. Gerade der Zeigerzugriff mit nicht inialisierten Zeigern kann so wirkungsvoll unterbunden werden. Hierzu dient das bereits vorher angesprochene Makro VALID.

Wenn man als Bitmuster z.B. 0x40 bzw. 64 bzw. '@' wählt, so ist ein Pointer mit der Adresse 0x40404040 mit Sicherheit ungültig. Ein Eintrag in der Adressdatenbank mit dem Wohnort '@@@@' fällt ebenfalls in diese Kategorie, genauso wie eine FarbNummer von 16448 (hex 0x4040).

Diese kombinierten Funktionen finden so gut wie jeden Speicherzugriffsfehler. Wenn eine solche Fehlerüberwachung nachträglich in ein bereits laufendes Programm eingebaut wird, dürften Sie überrascht sein, wieviele Speicherfehler in diesem noch enthalten sind.

Hardwareabhängigkeit

Vermeiden Sie diese, wo immer es geht. Benutzen Sie lieber die Standardfunktionen des Betriebssystemes und nehmen Sie ggf. ein langsameres Programm in Kauf.

Im Übrigen stellt man immer wieder fest, daß durch intelligente Algorithmen sehr viel mehr Zeit gespart werden kann als durch schnelle Hardwarezugriffe. Was nutzt Ihnen die 5µs schnelle Zeichenroutine, wenn Sie immer den gesamten Bildschirm neu zeichnen. Lieber benutze ich die 1ms langsame Betriebssystemfunktion und zeichne nur den ungültigen Teil neu.

Damit stellen Sie sicher, daß Ihr Programm immer dann läuft, wenn das Betriebssystem läuft und man ist nicht gezwungen, unbedingt DirectX 6.23 in seinem Setupprogramm mitzuführen und auf dem Zielrechner zu installieren, nur um ein Officepaket zum Laufen zu bringen.

externe Bibliotheken

Allgemein gilt, je mehr Graphik und Leistungsfähigkeit in der Bibliothek, desto mehr Softwarefehler hierin.

Wenn eine Bibliothek in das Anwendungsprogramm eingebunden wird, übernimmt man gleichzeitig die Verantwortung auch für deren Fehler. Muß die zu programmierende Adressenverwaltung für 500 Datensätze wirklich auf dem SQL - Server laufen, oder reicht die im Sourcecode verfügbare ISAM Verwaltung? Muß ich wirklich die neuen Funktionen von ANSI SQL 3 nutzen oder kann ich das mit einem geschachtelten SELECT auch durchführen.

Die bei der Programmentwicklung gesparte Zeit mit der OLE(2) Einbindung von MicroSoft Word statt eines Texteditortools, habe ich 10fach danach bei Fehlersuche und Updates verbraten. Man lernt zwar die Windows Registry recht genau kennen und hat einen sehr flexiblen Datensatz konstruiert, um der MicroSoft Updatephilosophie folgen zu können, aber ich weiß nicht, ob das Ihren Intensionen entspricht.

Optimierung ws. Fehlerkontrolle

Bei soviel Selbstüberprüfungscode wird das Programm doch langsamer? Ja, es wird, und zwar um fast 5%. Wenn man mit der Laufzeit seines Programmes Probleme hat, dann sollte man trotzdem nicht den Compiler auf Teufel komm raus (beliebtes Beispiel: Assume no Aliases) optimieren lassen (bringt fast 5%) und alle ASSERTs und Speicherüberprüfungen (bringt ja nochmal 5%) wegwerfen.

Die Faustregel besagt, das nur 5% des Programmcodes 95% der Programmlaufzeit ausmachen. Eine Optimierung um ca. 10% bringt nur unwesentliche Vorteile. Bei der Wahl des Softwarealgorithmus kann man hier Verbesserungen um fast 500% erreichen (z.B. ShellSort ws. Bubblesort).

Ist Ihr Programm also zu langsam und Sie überlegen sich, ob Sie die ASSERTs rauswerfen sollen, dann haben Sie schlicht und einfach falsch programmiert. Es soll Kunden geben, die setzen einen 800 MHz VIA-EPIA ein, und nicht wie Sie einen 7,4 Ghz Pentium IX, es gab auch mal Officeanwendungen mit 2 Diskettenlaufwerken auf 4,33 MHz;-)

Im Falle eines Falles

Es war soweit, in der Programmentwicklung trat kein ASSERT mehr auf, Sie sind stolz und zufrieden und haben das Programm beim Kunden installiert. Eine Woche darauf bekommen Sie einen wütenden Telefonanruf (ASSERT und Programmende, Daten nicht speichern können). Vor Ort können Sie den Fehler nicht nachvollziehen ("Was haben Sie denn gemacht, Hr. Meyer", "weiß ich nicht mehr").

Nun gut, ein gutes ASSERT Makro speichert den Fehler auch in einer Protokolldatei. Mit den gesicherten Sourcen im Auslieferzustand der Version 3.12 ist die Fehlerzeile genau bekannt. Sie kennen nun die Fehlerkonditionen und können diese mit dem Debugger nachvollziehen und genau einschränken. Das geht aber nur, falls Sie auf 5% Geschwindigkeitsgewinn verzichtet haben und damit innerhalb kürzester Zeit den Fehler lokalisieren und beheben können.

Fazit

Lassen Sie Ihr Programm sich selbst überprüfen und plastern Sie es mit ASSERTs zu, besser ein paar sinnlose zuviel, als ein sinnvoller zuwenig, rauswerfen kann man diese leichter, als einen unbekannten Fehler zu suchen.

Überlegen Sie sich genau, welche Bibliotheken Sie benutzen und ob ein direkter Hardwarezugriff wirklich nötig und sinnvoll ist.

Bringen Sie sich und Ihre Programmier dazu, ihre eigenen Programme bereits während der Programmentwicklung selbst zu nutzen (nur ein Koch, der sein eigenes Essen verzehrt, ist ein guter Koch).

Auch Programme mit 50.000 Funktionen können in sich fehlerfrei sein und Sie können sich auf die Fehler konzentrieren, die von außen auf Ihr Programm einwirken (z.B. ein fehlerhafter OLE Eintrag in der Windows Registry - viel Spaß).