Warum Berechnungen falsch sein können (2)

In der ersten Folge haben wir uns ein Ursachengebiet für unsinnige Rechenergebnisse vorgenommen, das man „Numerische Mathematik“ nennt. Da ich selbst mal in der Lehre tätig war, weiß ich, dass man das nur wenigen Programmierern wirklich nahe bringen kann. Ob genau diese dann auf den Arbeitsstellen landen, wo man das berücksichtigen muss? Wer weiß?

Heute im Teil 2 geht es um Programmierung. Jeder weiß vermutlich um die Vielzahl der Programmiersprachen. Warum es nicht nur eine gibt, dürfte klar sein: für die Umsetzung einer bestimmten Aufgabe nimmt man eben das geeignetste Werkzeug. Um eine Dachlatte zu kürzen genügt ein einfacher Fuchsschwanz; eine lasergesteuerte Präzisionssäge tut es zwar auch, aber bis man die programmiert hat, ist das Dach bereits fertig.

Einfache Programmiersprachen übersetzen während der Laufzeit, also wenn das Programm abgearbeitet wird, jede Programmzeile in Maschinencode und wertet diesen aus. Für viele Zwecke genügt das und man kann schnell und einfach überschaubare Anwendungen programmieren.

Solche Programme sind aber nur begrenzt schnell. Die nächste Stufe besteht folglich darin, erst das komplette Programm zu übersetzen (zu compilieren) und dann abarbeiten zu lassen. Solche Programme sind außerordentlich schnell, zumal der Programmierer, wenn er nicht ganz doof ist, sich auch überlegen kann, wie er die Maschine durch seinen Code unterstützen kann. Allerdings ist zu sagen: je komplexer die Programme werden, desto schwieriger wird es, die Übersicht zu behalten, weil der Programmierer bei Änderungen immer das ganze Programm oder doch sehr große Anteile im Blick behalten muss.

Wenn es noch schneller werden muss, müssen die Programme „parallelisiert“ werden, d.h. Teile, die nicht voneinander abhängen, werden auf verschiedenen Maschinen ausgeführt. Das ist ein Kapitel für sich und wer so etwas mal selbst ausprobieren möchte, sei auf das Buch „Parallele Programmierung“ verwiesen (rechts im Slider).

Das Problem sind große Programm, bei denen der Programmierer die Übersicht verlieren kann. Ein erster Schritt besteht darin, Daten, die zusammen gehören, auch zusammen zu verwalten:

//Anstatt von Feldern

string[2] name;
double[2] value;

// Strukturen

struct Haus {
    string name;
    double value;
};

struct Auto {
    string name;
    double value;
};

Der Programmierer kann also nicht mehr durch einen falschen Index im Feld daneben greifen. Es können allerdings immer noch falsche Zugriffe erfolgen, beispielsweise indem die Werte an Stellen geändert werden, an denen das nicht passieren soll. Man ist dann schnell darauf gekommen, dass Compilersprachen auch geeignet sind, den Programmierer daran zu hindern, solche Fehler zu machen, in dem Datenstruktur und Code miteinander verknüpft werden:

class Haus {
public:
    string const& get_name() const;
    void set_name(string const&);
private:
    string name;
    double hoehe;
};
...
void function(Haus const& h){
    string s = h.get_name();
    string t = "hallo haus";
    h.set_name(t);  // Compilerfehler
    h.name = t;     // Compilerfehler
}

Durch die Kennzeichnung „private“ kann kein anderer Programmteil mehr auf den Namen direkt zugreifen. Der Compilert wirf beim Zugriff „h.name“ einen Fehler aus und übersetzt nicht weiter. Die Kontrolle geht sogar noch weiter: die Variable „h“ ist im Funktionsaufruf als „const“ deklariert, d.h. sie wird in der Funktion nicht geändert. Das „const“ bei „get_name“ garantiert das, „set_name“ besitzt aber kein „const“ und der Compiler hört mit einem Übersetzungsfehler auf. Auch in längeren Codes kann der Programmierer keine Flüchtigkeitsfehler dieser Art mehr machen.

„Funktionsaufruf? Wird das dadurch nicht langsamer?“ – hängt von der Sprache ab. Das hier ist C++ – Code, und der Compiler wirft den Funktionsaufruf bei der Optimierung raus. Es wird also nicht langsamer, nur der Code wird länger, dafür aber deutlich sicherer. Wir sind jetzt übrigens schon bei der objektorientierten Programmierung.

Da viel Code direkt in die Klassen verschoben werden kann, weil ohnehin nur die eigenen Daten manipuliert werden, wird der allgemeine Code für die Algorithmen natürlich kürzer und übersichtlicher und die Fehlersuche einfacher: funktioniert es mit einer Klasse nicht, liegt der Fehler da, funktioniert es gar nicht, beim allgemeinen Code.

Schauen wir zwei weitere Probleme anhand folgenden Beispiels an:

class Mobil{
public:
    virtual string get_name() const {return "Mobil";}
protected:
    string name;
};

class Auto: public Mobil {
public:
    string get_name() const {return "Auto";}
};

class LKW: public Mobil {
public:
    string get_name() const {return "LKW";}
};

void func(Mobil const&  m1, Mobil const& m2){
    string s = m1.get_name() + " " + m2.get_name();
    cout << s << endl;
}
....
    Auto m1;
    LKW m2;
    func(m1,m2);

Output: Auto LKW

Die beiden Objektklassen „Auto“ und „LKW“ sollen im gleichen Umfeld genutzt werden, aber nicht mit „Haus“ in Konflikt kommen. Der Trick heißt hier Vererbung: „Auto“ und „LKW“ haben die gemeinsame Eigenschaft „Mobil“. Die Methode „func“ akzeptiert jede Variable, die von „Mobil“ abgeleitet ist. Ein Objekt des Typs „Haus“ führt wieder zum Streik des Compilers, weil zwar die Methodennamen passen würden, „Haus“ aber nicht von „Mobil“ abgeleitet ist. Und wie man sieht, werden innerhalb der Funktion die Methoden von „Auto“ und „LKW“ ausgeführt, obwohl in der Übergabeschnittstelle nur die Elternklasse „Mobil“ definiert ist. Dem Programmierer werden so weitere Möglichkeiten für Flüchtigkeitsfehler verbaut und auch die Fehlersuche wird einfacher, da der Code noch weiter objektspezifisch wird.

Damit wäre zwar Ordnung in die Daten gebracht, aber noch nicht unbedingt in die Algorithmen. Man kennt oft die Algorithmen vom Prinzip her sehr gut, weiß aber noch nicht, auf welche Datentypen sie letztlich angewandt werden müssen. Es bietet sich an, sie daten- und strukurtypunabhängig zu programmieren und den Datentyp erst später beim Compilieren nachzureichen. Damit wären wir bei der generischen oder Meta-Programmierung. Beispielsweise kann es zu Beginn der Programmierung noch nicht geklärt sein, wie man viele Objekte verwaltet. In Feldern? In Listen? Die Standardbibliotheken bieten unterschiedliche Möglichkeiten an, die man mit dem passenden Typ implementiert:

    vector<int> v;   // oder wahlweise
//  list<int> v;

Den Algorithmus kann man unabhängig von der Wahl implementieren

    vector<int>::iterator it;  // oder wahlweise
//  list<int>::iterator it;

    for(it=v.begin();it!=v.end();it++)
        func(*it);

Das Feld wird mit dem Datentyp „int“ implementiert, mit dem internen Datentyp „iterator“ kann man durch das Feld wandern und der „iterator“ weiß auch gleich, welcher Datentyp beim Zugriff erzeugt (nämlich „int“) und an die Funktion übergeben wird. Gefällt einem der „vector“ aus irgendeinem Grund nicht, kann man ihn durch „list“ ersetzen, muss aber sonst im Code nichts verändern.

Die Algorithmen werden mit einer Mustervariablen, einem Template definiert:

template <class T> class vector{
public:
    typedef T* iterator;
    ...
private:
    T* data;    
    ...
};

Sieht einfach aus, ist es aber nicht. Beispielsweise besitzt der Iterator die Grunddefinition

template<
    class Category,
    class T,
    class Distance = std::ptrdiff_t,
    class Pointer = T*,
    class Reference = T&
> struct iterator;

um tatsächlich alle Speichertypen einfach austauschbar zu machen und auch mit allen Objekttypen zu finktionieren. Metaprogrammierung erlaubt auch die Erzeugung von Datentypen nach Maß mit Hilfe von Objekt-Fabriken, die abenteuerliche Schnittstellen wie

template <
    class TList,
    template<class> class Unit = AbstractFactoryUnit
> class AbstractFaktory ;

besitzen, wobei „TList“ und „AbstractFactoryUnit“ ihrerseits komplexe Definitionen besitzen. Generische Compiler sind in der Lage, Algorithmen auf Datenstrukturen zur Compilezeit auszuführen, die in ihrer Komplexität kaum hinter den Algorithmen auf den Daten zur Laufzeit zurück stehen. ¹⁾ Stimmt etwas nicht, bricht der Compiler ab und unnütze Rechenzeit für die Daten wird gar nicht erst verschwendet.

Letztlich kann man mit solchen Techniken dafür sorgen, dass dem Programmierer kaum noch grundsätzliche Fehler unterlaufen können. Allerdings erfordern diese Techniken ein entsprechendes Abstraktionsvermögen und es dauert eine ganze Weile, bis die Grundlagen gelegt sind (und auch oft einige Zeit, bis man als hinzukommender Nutzer oder Teilnehmer die Gesamtkonstruktion durchschaut hat). In dieser Zeit haben die Programmierer, die irgendwo bei „struct“ oder davor ansetzen, vermutlich schon viele Berechnungen geliefert, aber möglicherweise auch Schrott, von dem man nicht weiß, wo die kaputten Ergebnisse herkommen.

Die Fragen, die man stellen muss, lautet somit: welche Programmiersprache wurde eingesetzt? Ist sie typsicher genug, um Programmiererfehler auszuschließen bzw. schnell zu erkennen? Ist die Programmstruktur kontrollierbar oder aufgrund der Komplexität nicht kontrollierbar? ²⁾


¹⁾ „Templates? Kein Problem! Können wir!“ Das behauptet man aber nur so lange, wie man es mit Leuten zu tun hat, die generische Programmierung auch nicht verstanden haben. Und das sind relativ viele. Die gute Firma MicroSoft musste über viele Jahre hinweg immer wieder zugeben, dass sie im Vergleich mit den GNU-C++ Compilern leider nur C++ auf der Packung stehen haben, aber keines drin ist (ist natürlich längst erledigt; vermutlich, seit Bill „the Gates“ die Compilerbauer nicht mehr behindert, weil er jetzt in Pharma, Klima und Bodenspekulation macht). In Algorithmen Zahlen zu verwursten oder bestimmte Datenobjekte zu finden ist für viele schon schwer genug; das und anderes auch mit Datentypen zu machen ist noch ein paar Etagen höher aufgehangen, weil eben komplett abstrakt. Das ist fast so ähnlich als hätte man begriffen, was es mit Polynomen und deren Nullstellen auf sich hat und dann auf ²⁾ Nicht erwähnt haben wir den Begriff „Exception“, also die Behandlung von Ausnahmesituationen. Das kann in sehr komplexen Programmen ebenfalls von Bedeutung sein. In kaum überschaubaren Programmen lassen sich Fehler nicht korrekt lokalisieren und Korrekturen werden möglicherweise an Stellen angebracht, wo das Kind schon im Brunnen liegt. Der Grund ist, dass man den normalen Programmfluss nicht verlassen kann, d.h. wenn man in die 5. Unterfunktion eingestiegen ist, muss man auch über alle 5 wieder zurück. Die Exception bietet die Möglichkeit, über sämtliche Funktionen zurück von der Stelle, wo die „Ausnahme“ (ich vermeide bewusst Fehler) aufgetreten ist, zurück in eine Programmebene zu springen, von der aus die Berechnung mit auf die Ausnahme angepasstem Code fortgeführt werden kann.