next up previous contents index
Next: Fortgeschrittenes C++ Up: C++-Entwicklung mit Linux Previous: Grundlagen der objektorientierten Programmierung   Contents   Index

Subsections


Programmieren mit C++

Wenn Sie das Buch bis hierhin von Anfang an durchgearbeitet haben, ist Ihnen sicher eines aufgefallen: Sie wissen zwar nun einiges über Objektorientierung, aber nicht, wie man ganz elementare Abläufe in C++ programmiert. Vielleicht haben Sie auch einen Teil überblättert, um hierher zu gelangen, gerade weil Sie dieses Wissen vermisst haben (und nicht auf vorhandene C-Kenntnisse zurückgreifen konnten). Der Ansatz hier unterscheidet sich auch darin etwas von anderen Büchern. Kommen nämlich Beispiele für Kontrollstrukturen, Bedingungen etc. zu früh, besteht die Gefahr, dass Sie sich zu sehr an die dabei notgedrungen verwendete Programmierung im C-Stil gewöhnen und das objektorientierte Denken unterentwickelt bleibt.

In diesem Kapitel geht es nun um das eigentliche Programmieren mit der Sprache C++, insbesondere um folgende Aspekte:

Da Sie nun schon das Wichtigste über objektorientierte Programmierung mit C++ wissen, sind die Beispiele in diesem Kapitel zum Teil etwas weniger trivial (und damit etwas ausführlicher) als bisher, denn sie sollen Ihnen zeigen, wie Sie die vorgestellten theoretischen Konzepte praktisch nutzen können. Folgende Beispiele machen Sie daher etwas näher mit der objektorientierten Programmierung unter Linux vertraut:


Basiselemente

Zu den grundlegenden Sprachelementen, auf die ich bislang nicht eingegangen bin, gehören die Bedingungen und die Konstrollstrukturen. Ohne sie kommt eigentlich kein Programm aus, das eine vernünftige Aufgabe erfüllen soll. Dieses Versäumnis wollen wir sofort nachholen.


Bedingungen

Eine der wichtigsten Kontrollelemente sind Verzweigungen aufgrund von Bedingungen. Kaum ein Programm läuft ganz geradlinig durch; fast immer muss überprüft werden, ob die eine oder andere Bedingung erfüllt ist, so dass in Abhängigkeit davon auf die eine oder andere Weise fortgefahren werden kann. Die Vorgehensweise ist dabei immer gleich: Wenn die Voraussetzung erfüllt ist, wird etwas ausgeführt, sonst etwas anderes. Die Wörtchen wenn und sonst heißen auf Englisch if und else und genauso heißen auch die Befehle in C++:

if (Bedingung)

  Anweisung;

Wenn Sie auch etwas tun möchten, wenn die Bedingung nicht erfüllt ist, sieht das Ganze so aus:

if (Bedingung)

  Anweisung1;

else

  Anweisung2;

Natürlich können Sie auch mehrere Anweisungen in jedem Teil ausführen; Sie müssen diese dazu nur in einen Block einschließen:

if (Bedingung)

{

  Anweisung1;

  Anweisung2;

}

Das geht wieder mit und ohne else-Teil, in dem natürlich auch ein Block stehen darf.

Hinter der Bedingung darf kein Semikolon stehen, sonst wäre das nämlich eine leere Anweisung. Auch bezieht sich die bedingte Ausführung nur auf den einen Befehl hinter if oder auf den Block. Kompliziert? Ich werde Ihnen gleich anhand einiger typischer Fehler noch ein paar Tipps geben.

Doch zunächst zur Frage: Was ist eigentlich eine Bedingung? Es muss sich dabei um einen Ausdruck handeln, der einen Ganzzahltyp oder bool ergibt. Dabei können Sie alle Vergleichsoperatoren verwenden, also <, >, == und so weiter. Die Bedingung gilt als erfüllt, wenn sie true oder verschieden von 0 ist.

Die Ausdrücke dürfen auch logisch miteinander verknüpft werden, etwa durch die Operatoren && für die UND-Verknüpfung, |$\;$| für die ODER-Verknüpfung und ! für die Negation.

Beispiele sind:

  if (a>0)

    x = x/a;

  if (p != 0 && x!=0)

    x *= p;

  else

    x = 1;

  if (player.isActive() == true)

    player.stop();

Natürlich können Sie auch if-Ausdrücke beliebig tief verschachteln. Oft leidet aber die Übersichtlichkeit erheblich darunter. Zumindest sollten Sie jede Anweisung unterhalb der Bedingung einrücken, damit man sofort die Zusammengehörigkeit erkennt.


Typische Fehler bei if-Anweisungen

Überlegen Sie bitte bei jedem der folgenden Beispiele erst, wo da der Fehler stecken könnte, bevor Sie die Erklärung lesen:

  1. if (x==10);

      tuwas(x);

    Hier ist es das Semikolon hinter der Bedingung. Das hat zur Folge, dass die Funktion tuwas() immer ausgeführt wird, das heißt unabhängig vom Wert von x - und das war ja nicht beabsichtigt.

  2. if (x=10)

      tuwas(x);

    Ein geradezu klassischer Fehler, den wohl jeder C++-Programmierer schon einmal gemacht hat. Zur Klarstellung: In C++ bedeutet ein Gleichheitszeichen = eine Zuweisung, erst zwei Gleichheitszeichen == stehen für einen Vergleich. Das Tückische ist, dass Zuweisungen immer auch so etwas wie einen Rückgabewert haben, nämlich den zugewiesenen Wert. Diesen akzeptiert dann der Compiler als Wert des Ausdrucks; in unserem Fall ist die Bedingung also immer erfüllt.

    Wenn Sie beim GCC die Option -Wall verwenden, weist Sie der Compiler auf dieses Problem mit der Meldung hin: suggest parentheses around assignment used as truth value.

  3. if (i==10)

      if (k==i)

        cout << ``i und k: 10!'' << endl;

    else

      cout << ``i ist nicht 10!'' << endl;

    Hier dürfen Sie sich nicht von der Einrückung der Zeilen täuschen lassen. Das else bezieht sich auf die letzte if-Anweisung, nämlich if(k==i), denn dort steht noch kein else-Teil. Wenn Sie möchten, dass sich die letzte Anweisung auf das erste if bezieht, müssen Sie die zweite und dritte Zeile zu einem Block klammern.

  4. int c = -1;

    unsigned int k = 0;

    if (k<c)

      cout << ``k ist kleiner'' << endl;

    Erwarten Sie eine Ausgabe von diesem Codeteil? Eigentlich ist doch 0 größer als -1. Und doch wird diese Bedingung als erfüllt angesehen. Denn da die Typen der beiden Vergleichsgrößen verschieden sind, wandelt der Compiler den Typ der zweiten Vergleichsgröße in den Typ der ersten um, macht also aus -1 die Zahl 4294967295. Auch vor diesem Fehler warnt Sie der GCC, wenn Sie mit -Wall übersetzen.

Abkürzende Schreibweisen

Wir halten also fest: Eine Bedingung ist erfüllt, wenn das Ergebnis des Ausdrucks ungleich 0 ist. Wenn der Ausdruck aber nur aus einer Variablen oder Konstanten besteht, kann man eigentlich auch gleich deren Wert heranziehen und auf einen Vergleich verzichten. So lässt sich folgende, häufig anzutreffende Kurzschreibweise erklären.



if (wert != 0) ist gleichbedeutend mit if (wert)
if (wert == 0) ist gleichbedeutend mit if (!wert)



Wenn Sie also eine Abfrage programmieren wollen, ob ein bestimmter Ausdruck ungleich 0 ist, genügt es, nur den Ausdruck als Bedingung anzugeben. Auf Beispiele werden wir noch treffen.


Beispiel: Telefontarife und Uhrzeitbestimmung

Seit der Liberalisierung des Telefonmarktes ist jeder nur noch damit beschäftigt, für das nächste Gespräch den günstigsten Anbieter zu finden. Das folgende Programm, das die Arbeit mit Bedingungen illustriert, besteht aus einer abstrakten Basisklasse Telefongesellschaft, die die Schnittstelle zur Preisberechnung allgemein vorgibt, und einer Unterklasse SiriusCom, die einen konkreten Anbieter darstellt. Dieser verlangt wochentags, also von Montag bis Freitag, von 7 bis 19 Uhr fünf Cent und sonst drei Cent pro Minute. Am Wochenende kostet die Minute Ferngespräch einheitlich zwei Cent.


1:  #include <iostream> 

2: #include <string>
3: #include <ctime>
4:
5: using namespace std;
6:
7: //-------------------------------------
8: // Abstrakte Basisklasse
9: // fuer Telefongesellschaften
10: //-------------------------------------
11: class Telefongesellschaft
12: {
13: protected:
14: string name;
15:
16: public:
17: Telefongesellschaft(const string& _name) :
18: name(_name)
19: {}
20:
21: const string& getName() const
22: { return name; }
23:
24: virtual float berechneGebuehr(
25: int _minuten) = 0;
26: };
27:
28: //-------------------------------------
29: // Abgeleitete Klasse fuer
30: // eine spezielle Gesellschaft
31: //-------------------------------------
32: class SiriusCom :
33: public Telefongesellschaft
34: {
35: public:
36: SiriusCom() :
37: Telefongesellschaft("SiriusCom")
38: {}
39:
40: virtual float berechneGebuehr(
41: int _minuten);
42: };
43:
44: //-------------------------------------
45: float SiriusCom::berechneGebuehr(
46: int _minuten)
47: {
48: time_t now = time(NULL);
49: tm z = *(localtime(&now));
50:
51: int wochentag = z.tm_wday;
52: int stunde = z.tm_hour;
53: float minutenpreis;
54:
55: if (wochentag > 0 && wochentag < 6)
56: {
57: // Werktag
58: if (stunde >= 7 && stunde < 19)
59: {
60: // Zwischen 7 und 19 Uhr
61: minutenpreis = 5.0;
62: }
63: else
64: {
65: // Abends und nachts
66: minutenpreis = 3.0;
67: }
68: }
69: else
70: {
71: // Wochenende
72: minutenpreis = 2.0;
73: }
74:
75: // Gib Ergebnis aus
76: cout << "Ein " << _minuten << "-minütiges "
77: << "Gespräch kostet bei " << name
78: << " jetzt " << minutenpreis * _minuten
79: << " Cent." << endl;
80:
81: // Gib Berechnung zurueck
82: return minutenpreis * _minuten;
83: }
84:
85: //-------------------------------------
86: int main()
87: {
88: SiriusCom carrier;
89:
90: float x = carrier.berechneGebuehr(3);
91: cout << "Gesamtkosten: " << x/100
92: << " EUR." << endl;
93:
94: return 0;
95: }

In diesem Programm finden Sie ein Beispiel für das Konzept der abstrakten Klasse aus Abschnitt [*] (ab Seite [*]). Sie sehen daran auch, dass eine abstrakte Klasse nicht nur aus rein virtuellen Funktionen bestehen muss, sondern auch ganz normale Attribute und Methoden haben kann. Durch die Deklarationen in den Zeilen 24/25 gibt sie allerdings die Schnittstelle zur Gebührenberechnung vor, an die sich alle Unterklassen zu halten haben.

Eine davon ist SiriusCom. Sie implementiert nur einen Konstruktor, in dem sie den Basisklassenkonstruktor aufruft (Zeile 37), um ihren Namen zu speichern, und die Berechnungsmethode berechneGebuehr().

Frage: Warum ist SiriusCom eigentlich eine Ableitung von Telefongesellschaft und nicht ein Objekt davon? Die Klasse Telefongesellschaft stellt doch eine Schablone dar, für die SiriusCom eine konkrete Ausprägung ist. Das ist in diesem Fall aber nur die halbe Wahrheit. SiriusCom kann kein Objekt sein, weil es nicht vollständig auf Methoden von Telefongesellschaft zurückgreifen kann, sondern auch eigene implementieren muss, in diesem Fall für die Tarifberechnung. Die abstrakte Klasse ist hier dazu da, die Schnittstelle festzulegen, aber nicht die Implementierung vorzugeben. Die Ableitung ist dabei die Spezialisierung, welche die Vorgaben aufnimmt und ihren eigenen Algorithmus hinzufügt. Mit einem Objekt wäre das nicht möglich (jedenfalls nicht, wenn man streng objektorientiert vorgeht und keine Tricks aus C versucht).

In der Methode SiriusCom::berechneGebuehr() sehen Sie in den Zeilen 48/49 zunächst, wie Sie in Ihren C++-Programmen die aktuelle Uhrzeit und das Datum vom Linux-System erfragen können. Vielleicht durchschauen Sie die Details der verwendeten Syntax momentan noch nicht; man muss dabei nämlich etwas mit Zeigern hantieren, die ich erst in einem der nächsten Abschnitte besprechen werde (Seite [*]). Zumindest können Sie sich aber merken, dass sich bei der Abfrage am Ende eine so genannte Struktur ergibt. Unter einer Struktur versteht man eine Klasse, bei der alle Elemente, für die keine Zugriffsbeschränkung ausdrücklich angegeben ist, public sind. Man kennzeichnet sie mit dem Schlüsselwort struct. In diesem Fall hat die Struktur tm, die in der Header-Datei ctime definiert ist (Zeile 3!), sogar ausschließlich Attribute und keine Methoden.

struct tm

{

  int tm_sec;   // Sekunden [0-60]

                 // (1 Schaltsekunde!) 

  int tm_min;   // Minuten  [0-59] 

  int tm_hour;  // Stunden  [0-23]

  int tm_mday;  // Tag      [1-31] 

  int tm_mon;   // Monat    [0-11]

  int tm_year;  // Jahr - 1900. 

  int tm_wday;  // Wochentag [0-6] 

  int tm_yday;  // Tag des Jahres [0-365] 

  int tm_isdst; // Sommerzeitverschiebung 

                 // [-1/0/1]

};

Vielleicht fragen Sie sich noch, weshalb man eigentlich zwei Funktionen braucht, um auf so etwas Alltägliches wie Datum und Uhrzeit zu kommen. Das liegt an der Form, wie Linux die Zeit intern speichert. Unter Unix wird die Zeit stets in Sekunden nach dem 01.01.1970, 0:00:00 Uhr GMT, gemessen. Das Ergebnis von time() ist nichts anderes als die Zahl der Sekunden, die seitdem vergangen sind. Mit diesem Wert kann ein Benutzer aber nichts anfangen. Daher gibt es noch ein paar zusätzliche Funktionen, um die Umrechung in gebräuchliche Einheiten vorzunehmen.

Doch zurück zu unserem Programm: Zwei der Attribute der Struktur tm sind tm_hour für die aktuelle Stunde und tm_wday, das den Wochentag angibt, wobei die Zählung mit 0 am Sonntag beginnt. Diese Attribute kopieren wir in den Zeilen 51/52 in lokale Variablen. Von Zeile 55 bis 73 geht es dann ganz um die Tarifbedingungen. Die äußere Bedingung (Zeile 55) ist erfüllt, wenn wochentag zwischen 1 und 5, also zwischen Montag und Freitag, liegt. In diesem Fall wird der Block von Zeile 56 bis 68 abgearbeitet. Die Anweisungen fürs Wochenende finden sich im else-Teil ab Zeile 70. Gemäß dem Tarif müssen wir an Werktagen noch die Uhrzeit überprüfen, was in Zeile 58 geschieht.

Der Rest des Programms besteht aus einer Ausgabe des Ergebnisses und natürlich der main()-Funktion. Damit sollten Sie keine Schwierigkeiten haben.

Das Beispiel zeigt Ihnen nicht nur die Verwendung der if-Anweisung, sondern auch, wie über Vererbung Zusammenhänge in einer Software repräsentiert werden können. Versuchen Sie am besten, das Programm um andere Anbieter zu erweitern und zusätzliche Funktionen oder Kriterien hinzuzufügen. Machen Sie sich auch die Beziehungen zwischen den von Ihnen verwendeten Klassen klar.

Mehrfache Auswahl

Es gibt immer wieder Fälle, in denen es mehr als nur zwei Möglichkeiten gibt. Diese können mit if-Abfragen nur sehr unzureichend behandelt werden, da sie zu sehr undurchsichtigem Code führen können. Besser ist es, einen Befehl zu verwenden, der mehrere Möglichkeiten zulässt. Dieser heißt in C++ switch und hat folgende Syntax:

switch(Ausdruck)

{

  case Konstante1Anweisung1; break;

  case Konstante2Anweisung2; break;

  // ...

  default: StandardAnweisung;

}

Dabei müssen Sie Folgendes beachten:

Generell gilt, dass sich switch-Anweisungen auch in geschachtelte if-Anweisungen umformen lassen. Umgekehrt ist dies natürlich nicht immer möglich, da bei switch ja nur Vergleiche mit ganzzahligen Ausdrücken erlaubt sind. Wenn Ihr Ausdruck also eine andere Form hat, zum Beispiel ein Textstring, dann müssen Sie doch geschachtelte if-Anweisungen verwenden. Der Nachteil an diesen ist, dass dabei unter Umständen sehr viele Vergleiche durchzuführen sind, was sich bei häufigerem Durchlauf durch diesen Programmteil negativ auf die Laufzeit der Anwendungen auswirken kann. Suchen Sie beispielsweise einen Namen, kann es zu folgendem Code kommen:

int berechne(const string& name)

{

  int zulage = 0;

  if (name == ``Andrea'')

    zulage = 100;

  else

    if (name == ``Barbara'')

      zulage = 150;

    else

      if (name = ``Christa'')

        zulage = 200;

      else 

        if (name == ``Doris'')

          zulage = 400;

        else 

          // ...

}

In einigen Fällen ist es möglich, durch Uminterpretation oder vorheriges Nachschlagen in einer Liste einem Textausdruck doch noch eine Ganzzahl zuzuordnen. In diesen Fällen sollten Sie dann auch vom switch-Konstrukt Gebrauch machen. Bei diesem Beispiel ist ein möglicher, wenn auch ziemlich unschöner Trick zur Uminterpretation, sich an den Anfangsbuchstaben der Damen zu orientieren, also etwa

int berechne(const string& name)

{

  int zulage = 0;

  switch (name[0])  

  // ist ein char, also ganzzahlig

  {

    case 65: zulage = 100; // Buchstabe 'A'

             break;

    case 66: zulage = 150;

             break; 

    case 67: zulage = 200;

             break;

    case 68: zulage = 400;

             break;

    // ..

  }

}

Bei dieser Vorgehensweise bekommen Sie spätestens dann Probleme, wenn Sie weitere Fälle berücksichtigen müssen, die nicht durch Ihre Auswahl abgedeckt sind, beispielsweise eine Zulage von 200 für Angela. Der Weg über eine Listenauswahl ist da schon sicherer, wenn auch nicht immer elegant.


Beispiel: Auslesen von Kommandozeilenparametern

BereitsSec:Kommandozeile haben wir die Parameter, die der Anwender über die Kommandozeile an ein Programm übergeben kann, ausgewertet und angezeigt. Die Funktion main() kann über die Argumente argc und argv verfügen, wobei argc die Anzahl der Parameter ist (der Programmname zählt als erster Parameter) und argv ein Feld mit den eigentlichen Angaben. Nun wollen wir noch einen Schritt weitergehen und Optionen berücksichtigen, die aus einem Strich und einem Buchstaben, eventuell mit einem Argument dahinter, bestehen - wie etwa -o beim GCC.

Dazu können wir die Funktion getopt() aus der GNU-C-Bibliothek nutzen (dafür ist das Einbinden der Header-Datei unistd.h erforderlich). Diese ist ein sehr praktisches und elegantes Hilfsmittel, um Kommandozeilenoptionen auszuwerten (zu parsen, wie der Fachmann sagt). Man übergibt ihr die beiden Parameter der main()-Funktion, also argc und argv, sowie eine Zeichenkette, in der die Buchstaben, die als Optionen erkannt werden sollen, aufgelistet sind. Falls hinter einer Option noch ein Argument folgen soll, muss hinter dem Buchstaben in der Zeichenkette ein Doppelpunkt stehen.

Über den Aufruf von getopt() wird immer ein Parameter ausgelesen und in der Variablen opt gespeichert. Darin steht entweder das Zeichen, das die Funktion gefunden hat, also der Buchstabe hinter dem Strich (etwa das d bei der Option -d), oder ?, wenn eine unbekannte Option verwendet wurde, oder -1, falls das Ende der Kommandozeile erreicht ist.

Normalerweise lassen sich Programme über Kommandozeilenoptionen konfigurieren oder zu einem bestimmten Verhalten veranlassen. Beim GCC etwa haben Sie dafür auf Seite [*] eine Reihe von Möglichkeiten gesehen. Hier wollen wir jedoch nur ein Übungsbeispiel erstellen. Darin soll ein Text ausgegeben werden, den der Benutzer hinter der Option -d (für description) oder -t (für text) angeben kann. Zusätzlich wollen wir eine Zeilen- oder Fehlernummer aufnehmen, die der Anwender anschließend an -l (für line) oder -n (für number) übergeben kann. Schließlich besteht noch die Möglichkeit, im Anschluss an die Textausgabe eine zusätzliche Leerzeile einzufügen, indem man die Option -s (für space) wählt. Der Aufruf von getopt() lautet damit:

getopt(argc, argv, "d:l:n:t:s");

All diese Aspekte sind im nachfolgenden Programm enthalten. Um alle Optionen zu erfassen, musste ich noch eine Schleife einbauen. Wenn diese Sie irritiert, gedulden Sie sich bitte bis zum nächsten Abschnitt, wo deren Bedeutung genau erklärt wird.


1:  #include <iostream> 

2: #include <cstdlib>
3: #include <string>
4:
5: // Systembibliothek
6: #include <unistd.h>
7:
8: using namespace std;
9:
10: class Configuration
11: {
12: public:
13: string text;
14: unsigned short line;
15: bool space;
16:
17: Configuration() :
18: line(0), space(false)
19: {}
20: };
21:
22: int main(int argc, char* argv[])
23: {
24: int opt=0;
25: Configuration config;
26:
27: while((opt = getopt(argc, argv, "d:l:n:t:s"))
28: != -1)
29: {
30: switch(opt)
31: {
32: case 'd':
33: cout << "Argument d gegeben!" << endl;
34:
35: case 't':
36: cout << "Argument t gegeben!" << endl;
37: config.text = optarg;
38: break;
39:
40: case 's':
41: cout << "Argument s gegeben!" << endl;
42: config.space = true;
43: break;
44:
45: case 'l':
46: case 'n':
47: config.line = atoi(optarg);
48: break;
49:
50: default:
51: cout << "Unbekannte Option "
52: << (char)optopt << endl;
53: break;
54: }
55: }
56:
57: cout << "---------------" << endl;
58:
59: if (config.line)
60: cout << config.line << ": ";
61:
62: cout << config.text << endl;
63:
64: if (config.space)
65: cout << endl;
66:
67: cout << "----------------" << endl;
68:
69: return 0;
70: }

Das Programm beginnt mit einer Klasse, welche die übergebenen Konfigurationsinformationen aufnehmen soll. Für diese Aufgabe eine eigene Klasse einzusetzen, hat mehrere Vorteile: Zum einen sind damit die Parameter zusammengefasst, so dass ihre Zusammengehörigkeit sofort deutlich wird. Zum anderen bietet ein Standardkonstruktor die Möglichkeit, die Elemente automatisch mit Vorgabewerten zu belegen. Der Entwickler, der die Klasse benutzt, muss dann nicht mehr alle Variablen einzeln initialisieren, sondern kann sich darauf beschränken, die davon abweichenden Werte zu setzen. Darüber hinaus können Sie durch Ableitungen ganze Konfigurationsbäume aufbauen, mit denen sich Ihre Daten hierarchisch gliedern lassen.

Die Schleife über alle angegebenen Argumente in der Kommandozeile läuft von Zeile 27 bis Zeile 55. Wie erwähnt steht nach dem Aufruf von getopt() der ausgelesene Parameter in der Variablen opt. Der zugewiesene Wert gilt gleichzeitig als Ergebnis dieses Ausdrucks, so dass die Schleifenbedingung darauf warten kann, dass sich dabei eine -1 ergibt, was das Ende der Kommandozeile signalisiert.

In der switch-Anweisung (Zeile 30-54), um die es uns hier ja eigentlich geht, kommen gleich mehrere Varianten vor, wie sich die Fallunterscheidung realisieren lässt. Den Standardfall sehen Sie in den Zeilen 40 bis 43 für das Argument s: Auf die case-Anweisung folgen ein paar Befehle und dann sofort das break. Damit ist die Abarbeitung in diesem Fall hier beendet und das Programm fährt mit Zeile 55, also mit der Schleife fort.

Anders sieht es aus beim Argument d in Zeile 32/33. In diesem Fall wird zwar auch der Text Argument d gegeben! ausgedruckt; da aber das break fehlt, geht die Ausführung in der nächsten Zeile weiter. Somit erscheint zusätzlich die Ausgabe Argument t gegeben!, bevor der Text hinter dem Parameter (enthalten in optarg) gespeichert und die Fallunterscheidung abgebrochen wird. Beachten Sie, dass es sich hierbei um ein erwünschtes Verhalten handelt. In Ihren Programmen sollten Sie eine solche Situation durch klare Kommentare kennzeichnen.

In Zeile 45 haben wir dann den Fall, dass nach einer case-Anweisung überhaupt keine weiteren Befehle, sondern gleich das nächste case folgt. Die Funktion atoi() (also ASCII to integer) in Zeile 47 stammt übrigens aus der Standardbibliothek und wandelt eine Zeichenkette in eine Ganzzahl um.

Abschließend macht das Programm noch die Ausgabe, für die man es konfiguriert hat. Testen Sie doch ein paar Aufrufvarianten, um zu sehen, wie sich die Fallunterscheidung in verschiedenen Situationen verhält.

Hintergrund

Wenn Sie es irgendwann einmal mit Richtlinien zu tun bekommen, die Ihnen Vorschriften hinsichtlich des Designs Ihrer Programme machen, finden Sie darin eventuell auch den Hinweis, dass verschachtelte if-Anweisungen oder gar switch-Anweisungen vermieden werden sollten, da sie auf ein prozedurales (also nicht objektorientiertes) Denken hindeuten. Können Sie sich vorstellen, was damit gemeint sein könnte?

Betrachten wir als Beispiel eine Klasse Printer, die natürlich über eine (virtuelle) Methode print() verfügt. Zusätzlich habe sie ein Attribut typeId, mit der sich die Bauart bestimmen lässt. Sie habe die Unterklassen LaserPrinter, InkJet und Fax, die diese Methode entsprechend ihren Fähigkeiten implementieren.

Eine andere Klasse, etwa Document, will nun darüber einen Ausdruck vornehmen. Deren print()-Methode erhält über die Schnittstelle nach außen nur eine Referenz auf ein Printer-Objekt, muss dann aber selbst feststellen, was für ein Typ von Drucker das eigentlich ist, zum Beispiel so:

void Document::print(const Printer& _rPrinter)

{

  switch(_rPrinter.type_id) {

    case LASER_PRINTER: 

      ((LaserPrinter&)_rPrinter).warmupToner();

      _rPrinter.print(LASER_PRINTER, myText);

      break;

    case INK_JET:

      ((InkJet&)_rPrinter).selectColor(BLACK);

      _rPrinter.print(INK_JET, myText);

      break;

    case FAX:

      ((Fax&)_rPrinter).disconnect();

      _rPrinter.print(FAX, myText);

      break;

  }

}

In diesem Codefragment drückt sich in der Tat aus, dass der Autor eine Klasse als nicht viel mehr als einen Datencontainer ansieht. Sehr viel sinnvoller wäre es da, jeder Klasse eine virtuelle Methode init() hinzuzufügen, die die notwendigen Vorverarbeitungsschritte durchführt. Dann lässt sich obige Operation nämlich ganz kurz schreiben:

void Document::print(const Printer& _rPrinter)

{

  _rPrinter.init();

  _rPrinter.print(myText);

}

Über den polymorphen Charakter des Arguments erkennt das Programm zur Laufzeit selbst, um welche Art von Drucker (sprich: Unterklasse) es sich handelt, und ruft dessen Methoden auf.

Obiges Beispiel zeigt die so genannte Polymorphismus-Angst, die viele Einsteiger in die objektorientierte Programmierung haben. Wie Sie an der zweiten Version sehen, ist diese nicht nur unberechtigt, sondern auch hinderlich, da sie komplizierte Konstruktionen anstelle kurzer Aufrufe für erforderlich hält. Auf der anderen Seite heißt das aber nicht, dass sämtliche switch-Anweisungen auf diese Weise überflüssig werden können. Unser getopt()-Beispiel von vorhin beweist das Gegenteil.


Schleifen

Neben Bedingungen sind Schleifen die am häufigsten anzutreffenden Kontrollelemente für den Programmfluss. Immer wieder gibt es Aufgaben, die wiederholt werden müssen, bis eine bestimmte Bedingung erfüllt oder eine Höchstzahl an Wiederholungen erreicht ist. Die Sprache C++ bietet dazu dreierlei Arten von Schleifen, die eng miteinander verwandt sind.

Schleife mit Anfangsüberprüfung

Bei der ersten Form wird die Abbruchbedingung zuerst überprüft, bevor der Schleifenblock betreten wird. Die Syntax lautet:

while (Bedingung)

{

  // Anweisungen

}

Solange die Bedingung erfüllt ist, werden die Anweisungen innerhalb des Blocks immer und immer wieder ausgeführt (siehe linkes Diagramm in Abbildung [*]Fig:While). Mit Bedingung ist dabei wieder wie bei if ein Ausdruck gemeint, der einen ganzzahligen Wert ungleich 0 oder true ergeben muss, damit sie als erfüllt angesehen wird. Der Vorteil dieser Variante ist, dass die Bedingung schon vor der ersten Anweisung des Schleifenkörpers überprüft wird. Ist sie von Anfang an nicht erfüllt, wird der Block gleich ganz übersprungen.

Ebenso wie bei if darf auch hinter while nicht unmittelbar ein Semikolon folgen - sonst erkennt dies der Compiler als leere Anweisung und stürzt in eine Endlosschleife, wenn die Bedingung erfüllt ist und sich nicht selbst modifiziert (etwa durch einen Inkrementoperator).

Ein einfaches Beispiel ist der euklidische Algorithmus. Mit ihm berechnet man seit fast zweieinhalb Tausend Jahren den größten gemeinsamen Teiler (ggT) zweier ganzer Zahlen. Eine Anwendung ist das Kürzen in der Bruchrechnung; dort ist es genau der ggT von Zähler und Nenner, mit dem man einen vollständig gekürzten Bruch erhält, indem man nämlich sowohl Zähler als auch Nenner durch den ggT dividiert.

int ggT(int x, int y)

{

  while(y)

  {

    int r = x % y; // Rest der Division

    x = y;

    y = r;

  }

  return (x);

}

Die Schleife läuft so lange, bis y null wird, also die Division im letzten Schritt aufgegangen war. Dann ist auch das letzte Ergebnis, derzeit gespeichert in x, der ggT.

Schleifen mit Überprüfung am Ende

Das Gegenstück zur Schleife mit Anfangsprüfung ist die Schleife mit do und while.

do

{

  // Anweisungen

}

while (Bedingung);

Auf das do folgt also wieder unmittelbar eine einzelne Anweisung oder ein Block. Der wesentliche Unterschied zur reinen while-Schleife besteht darin, dass bei dieser Form die Bedingung erst überprüft wird, wenn der Schleifenkörper schon einmal durchlaufen wurde. Er wird also immer mindestens einmal ausgeführt. Ansonsten kann man die beiden Schleifenformen immer ineinander umwandeln, braucht dabei aber unter Umständen zusätzliche Bedingungen.

Figure: Die Schleifenformen unterscheiden sich durch den Zeitpunkt, an dem die Bedingung überprüft wird.



\resizebox*{10.5cm}{!}{\includegraphics{images/while.eps}}

Als Beispiel betrachten wir die Berechnung der Fläche unter einer Parabel. Diese lässt sich näherungsweise als Summe von Rechteckflächen auffassen, wobei die Höhen der Rechtecke vom Funktionswert abhängen, die Breite $h$ aber konstant sein soll. Streng mathematisch ausgedrückt, berechnen wir also eine Näherung des bestimmten Integrals

\begin{displaymath}\int^a_b x^2   {\rm d}x\end{displaymath}

Dazu dient folgendes Programm:

int main()

{

  float a, b, h, flaeche = 0.0;

  int n;

  cout << "Linke Grenze ? "; cin >> a;

  cout << "Rechte Grenze ? "; cin >> b;

  cout << "Anzahl der Rechtecke ? "; cin >> n;

  h = (b-a)/n; float x = a;

  do

  {

    flaeche += h*x*x;

    x += h;

  } while (x < b);

 

  cout << "Fläche unter Parabel ist " 

       << flaeche << endl;

  return 0;

}


for-Schleifen

Eine dritte Form der Schleife in C++ wird gesteuert von der Anweisung for. In vielen anderen Programmiersprache, wo es diese Anweisung ebenfalls gibt, dient sie ausschließlich dazu, eine feste Anzahl von Wiederholungen zu durchlaufen, ausgehend von einer Zählervariablen, die bei jedem Durchlauf um eins erhöht oder vermindert wird. Das ist zwar mit for genauso möglich; sie arbeitet aber viel allgemeiner und kann daher noch für viele andere Zwecke eingesetzt werden. Die Syntax lautet:

for ( InitialisierungBedingungAnpassung)

{

  // Anweisungen

}

Die drei Teile haben dabei folgende Bedeutung:

Beachten Sie aber, dass keiner dieser drei Teile zwingend ist. Jeder davon kann auch leer bleiben. (Im Extremfall auch alle drei - dann haben Sie eine Endlosschleife!)

Der Standardfall ist das Hochzählen einer Schleifenvariablen innerhalb fester Grenzen, etwa

for (int i=65; i<90; i++)

  cout << i << `` `` << char(i) << endl;

(Achtung: Wenn Sie keinen Block mit geschweiften Klammern { und } verwenden, erstreckt sich die Wiederholung nur auf eine einzige Anweisung. Alle Folgenden werden dann wieder nur einmal ausgeführt.) Sie sehen an diesem Beispiel gleich noch eine weitere Besonderheit: Die Zählvariable muss nicht unbedingt schon vorher deklariert sein; Sie können sie auch noch innerhalb des Initialisierungsteils der Schleife deklarieren. Bei älteren Compilern ist sie dann auch am Ende der Schleife noch gültig. Nach dem neuen ANSI-Standard ist dies indessen nicht der Fall. Der GCC erkennt diese Situation, wenn Sie versuchen, hinter der Schleife die Variable noch zu verwenden. Er unterrichtet Sie davon mit den Fehlermeldungen name lookup of `i' changed for new ISO `for' scoping sowie using obsolete binding at `i'.

Betrachten wir noch eine etwas extremere Verwendung (auf ein paar weitere gemäßigte Anwendungen werden Sie im Laufe des Buches ohnehin noch stoßen). Nehmen wir an, wir hätten eine Klasse vector, die einen Vektor mit gleichartigen (der Einfachheit halber ganzzahligen) Elementen repräsentiert, eine Zugriffsmethode vector::at(int i) auf das $i$-te Element und eine Methode vector::size(), welche die Anzahl der Elemente zurückliefert. Dann können wir die Summe aller Elemente unter anderem wie folgt berechnen:

  vector v;

  // fülle v

  ...

  // berechne Summe der Elemente

  int k = v.size(), sum = 0;

  for (; k; sum += v.at(-k));

  cout << ``Summe ist `` << sum << endl;

Wenn Sie nicht gleich verstehen, was hier vor sich geht, ist das auch nicht weiter schlimm. Versuchen Sie am besten, die Aufgabe der Summenberechnung unabhängig davon selbst zu lösen und vergleichen Sie Ihren Ansatz dann mit diesem.

Das Beispiel zeigt Ihnen noch ein Problem, das der Sprache C++ generell anhaftet und weshalb sie auch nicht gerade zu den leichtesten und praktikabelsten zählt. Viele Anweisungen können Sie auf viele verschiedene Arten benutzen, von übersichtlich bis unleserlich. Auch dieser Code ist syntaktisch durchaus korrekt, wenngleich es ein schwer verständlicher Stil ist. Um Ihrer eigenen und der Nerven Ihrer Kollegen willen: Achten Sie stets darauf, lesbaren Code zu schreiben, den Sie und andere auch noch nach einiger Zeit verstehen können. Im Streben nach Eleganz und Kompaktheit geht die Lesbarkeit leider zu häufig unter.

Doch zurück zu den for-Schleifen: Sicher ist Ihnen mittlerweile klar, dass es sich bei diesen auch nur um Spezialfälle der while-Schleifen handelt; das bedeutet, beide Formen sind äquivalent und können stets ineinander umgewandelt werden. Im Allgemeinen hängt es daher vom Kontext ab, welche Form man wählt. Ist die Anzahl der Wiederholungen bekannt, greift man lieber zu for; ist die Abbruchbedingung nicht unmittelbar an die Zahl der Durchläufe gekoppelt, verwendet man eher while. Versuchen Sie doch mal, obige Beispiele als while-Schleifen zu schreiben.


Ein Beispiel für while-Schleifen:
Nachrichtenübermittlung zwischen zwei Prozessen

Eine häufige Anwendung von while-Schleifen ist die Behandlung von Ereignissen. Wenn ein Programm eine Aufgabe nicht sequenziell zu erfüllen hat, sondern mit einem anderen Programm oder einem Benutzer interagieren soll, muss es auf Ereignisse reagieren können, die der Partner auslöst. Die Behandlung der verschiedenen Ereignisse findet dabei üblicherweise in einer while-Schleife statt. Solange kein Ende oder Abbruch signalisiert wurde, soll das Programm auf eine Nachricht des Partners warten, diese dann in Abhängigkeit vom Inhalt der Nachricht bearbeiten und wieder auf die nächste Nachricht warten. Vielleicht erinnern Sie sich: Auf Seite [*] ff. haben wir ganz allgemein festgestellt, dass ein Grundprinzip der objektorientierten Programmierung die Kommunikation der Objekte miteinander über Nachrichten ist. In der Folgezeit haben wir zwar immer Nachrichten in Form von Methodenaufrufen verschickt. Jetzt nehmen wir das Konzept aber mal wörtlich.

Nachrichten (messages) stellen unter Linux eine Möglichkeit dar, Informationen zwischen zwei Prozessen auszutauschen (also so genannte inter process communication, IPC, zu betreiben). Sie werden dabei in Nachrichtenschlangen (message queues) verwaltet. Der Absender stellt seine Nachricht in die Schlange, der Empfänger holt sie von dort ab.

Auf diese Weise können Sie einen Block von Daten zwischen zwei laufenden Programmen austauschen. Natürlich kann das kein beliebig großer Block sein. Linux definiert dabei die Konstanten MSGMAX = 4096 für die maximale Größe einer Nachricht in Byte und MSGMNB = 16384 für die maximale Größe einer Nachrichtenschlange. Unter einem anderen Unix können diese aber auch abweichende Werte haben.

Die Funktionen, die wir zur Nachrichtenübermittlung verwenden, stellt das Betriebssystem zur Verfügung. Sie sind in C geschrieben und haben mit Objektorientierung nichts am Hut. Der Aufruf wirkt daher auf den ersten Blick vielleicht etwas kryptisch. Wir werden folgende Funktionen einsetzen (für mehr Details ziehen Sie bitte die Linux-Dokumentation zu Rate; auch wenn die Schnittstellen dieser Funktionen noch etwas verwirrend aussehen, wird ihre Anwendung sicher an nachfolgendem Beispiel gleich klarer):

Figure: Die Türme von Hanoi sollen versetzt werden.

\resizebox*{10.5cm}{!}{\includegraphics{images/hanoi.eps}}

Als Problem wollen wir das Geduldsspiel Türme von Hanoi als Simulation implementieren. Gegeben sind dabei drei Stangen auf einem Brett. Auf einer der Stangen stecken mehrere Scheiben, deren Durchmesser von oben nach unten zunimmt. Die Aufgabe besteht nun darin, die Scheiben auf einen der anderen Stäbe zu versetzen. Das wäre aber zu einfach, so dass noch zwei schikanöse Regeln zu beachten sind: Zum einen darf nur eine Scheibe gleichzeitig bewegt werden und zum anderen darf immer nur eine kleinere auf eine größere Scheibe gelegt werden - nie umgekehrt! Abbildung [*] zeigt die Beteiligten.

Stellen wir uns weiter vor, wir wollen das Umsetzen nicht von Hand machen (wozu haben wir schließlich den Computer?), sondern uns eines Roboters bedienen. Der arbeitet hier zwar nur virtuell, was aber nicht bedeutet, dass Sie nicht auch einen echten Greifer anschließen dürften. Der Roboter wird vom Empfängerprozess gesteuert, das heißt, er wartet ständig auf Nachrichten, um diese gleich auszuführen.

Diese Aufgabe setzen wir in C++ folgendermaßen um: Wir trennen die einzelnen Funktionsbereiche voneinander, indem wir für jeden eine eigene Klasse bilden. Der Absenderprozess enthält ein Objekt der Klasse HanoiController, die einen Algorithmus implementiert, um das Problem zu lösen. Sie ist Unterklasse von RobotController, die das Senden der Befehle kapselt. Sowohl RobotController als auch Robot sind wiederum von der Klasse KommunikationsController abgeleitet, in der Erzeugung und Beendigung von Nachrichtenschlangen realisiert sind. Abbildung [*] zeigt die Beziehungen zwischen den Klassen. Vererbung ist in dieser Darstellungsweise (der so genannten Unified Modeling Language, UML) durch einen durchgezogenen Pfeil mit dreieckiger Spitze von der Unter- zur Oberklasse gekennzeichnet. Die gestrichelte Linie soll andeuten, dass diese Klasse von Befehl abhängig ist, sprich: ein Objekt davon verwendet.

Figure: Die Klassen unseres Beispiels sind nach ihren Funktionsbereichen getrennt.

\resizebox*{14cm}{!}{\includegraphics{images/hanoi_diag.eps}}

In der Tat ist Befehl genau die Nachricht, die zwischen den Prozessen ausgetauscht wird. Sie enthält aber nur wenige Daten. Daher kann sich die entsprechende Klasse auch auf Konstruktoren und ein paar öffentliche Attribute beschränken.

class Befehl

{

public:

  long msg_typ;

  RobotBefehl befehl;

  short arg1;

  short arg2;

  

  Befehl() : msg_typ(1), 

    befehl(START), arg1(0), arg2(0){}

  

  Befehl(RobotBefehl _befehl, short _arg1, 

    short _arg2) : msg_typ(1), befehl(_befehl),

    arg1(_arg1), arg2(_arg2){}

};

Dabei ist RobotBefehl ein Aufzählungstyp, der die Befehle START, BEWEGE, BEWEGE_OBJEKT und ENDE enthält.

In der Klasse KommunikationsController erstellen wir die Infrastruktur für die Kommunikation, nämlich die Methoden für Auf- und Abbau der Nachrichtenschlange.

inline int KommunikationsController::initialisiereVerbindung()

{

  // Initialisierung der Nachrichtenschlange

  msgid = msgget((key_t)123, 0666 | IPC_CREAT);

  

  if (msgid == -1)

  {

    cerr << "Nachrichtenschlange konnte nicht "

         << "erzeugt werden!" << endl;

    return -1;

  }

  return 0;

}  

Das Gegenstück dazu ist ganz analog aufgebaut:

inline int KommunikationsController::kappeVerbindung()

{    

  if (msgctl(msgid, IPC_RMID, 0) == -1)

  {

    cerr << "msgctl() fehlgeschlagen!" << endl;

    return -1;

  }

  return 0;

}

Das Senden ist Hauptbestandteil der Funktionalität der Klasse RobotController. Von dort aus rufen wir besagte Funktion msgsnd() auf.

int RobotController::sendeBefehl(

  RobotBefehl _befehl, short arg1, short arg2)

{

  Befehl einBefehl(_befehl, arg1, arg2);

  int erg = msgsnd(msgid, (void*)&einBefehl, 

      sizeof(Befehl)-sizeof(long), 0);

  if (erg == -1)

  {

    cerr << "msgsnd() fehlgeschlagen!" << endl;

    return -1;

  }

  

  return 0;

}  

Beachten Sie, dass bei der Angabe der Größe der zu übertragenden Datenstruktur die Bytes für den Nachrichtentyp weggelassen werden müssen.

Die Funktion sizeof() hat den Zweck, den Speicherverbrauch eines Datentyps oder einer Variablen zu bestimmen. In diesem Beispiel liefert sizeof(long) den Wert 4 und sizeof(Befehl) den Speicherbedarf eines Objekts vom Typ Befehl; die Differenz der beiden ist also die Zahl der Bytes, die das Objekt außer der einen long-Zahl braucht.

Der Algorithmus zum Lösen des Problems, die Türme von Hanoi zu versetzen, ist in der rekursiven Methode HanoiController::bewegeScheiben() untergebracht. Damit wird das Problem, $n$ Scheiben zu verlagern, auf die Verlagerung von $n-1$ Scheiben reduziert, und zwar zunächst vom ersten auf den zweiten und den dritten Stab. Am Ende befinden sich alle Scheiben auf dem zweiten Stab.

void HanoiController::bewegeScheiben(

    short _tiefe, short _pos1,

    short _pos2, short _pos3) 

{

  if (_tiefe > 1)

    bewegeScheiben(_tiefe-1, _pos1, _pos3, _pos2);

  

  sendeBefehl( BEWEGE_OBJEKT, _pos1, _pos2);

  

  if (_tiefe > 1)

    bewegeScheiben(_tiefe-1,_pos3, _pos2, _pos1);

}    

Das eigentliche Hauptprogramm ist dann sehr kurz. Die Verbindung wird initialisiert und die Scheibenbewegung auf höchster Ebene gestartet. Nach deren Abarbeitung wird der Roboter zur Ruhe gelegt und das Programm beendet.

int main()

{

  HanoiController hanoi;

  

  if (hanoi.initialisiereVerbindung() == -1)

    return 1;

    

  hanoi.bewegeScheiben(ANZAHL, 1, 2, 3);

  hanoi.sendeBefehl(ENDE);

  

  return 0;

}          

Die Anzahl der Scheiben ist eine Konstante, die zu Beginn der Datei festgelegt wurde. Wenn Sie wollen, ändern Sie das Programm ab, so dass ein Benutzer diese Zahl eingeben kann.

Nun zum Empfänger: Die Methode Robot::verarbeiteNachrichten(), in der sich die Empfängerfunktion befindet, ist der eigentliche Grund dafür, dass wir dieses Beispiel bei den while-Schleifen besprechen. Sie stellt nämlich die klassische Verarbeitungsroutine eines ereignisgesteuerten Programms dar. Die Struktur ist letztlich immer die gleiche:

Im Programm sieht das dann folgendermaßen aus:

int Robot::verarbeiteNachrichten()

{

  bool istEnde = false;

  Befehl einBefehl;

  

  while(!istEnde)

  {

    int erg = msgrcv(msgid, (void*)&einBefehl, 

      sizeof(Befehl)-sizeof(long), 0, 0);

    if (erg == -1)

    {

      cerr << "msgrcv() fehlgeschlagen!" << endl;

      return -1;

    }

     

    switch(einBefehl.befehl)

    {

      case START:

        bewegeArm(1);

        break;

        

      case BEWEGE: 

        bewegeArm(einBefehl.arg1);

        break;

        

      case BEWEGE_OBJEKT:

        bewegeScheibe(einBefehl.arg1,

          einBefehl.arg2);

        break;

        

      case ENDE:

        istEnde = true;

        break;

    }

  }

  

  return 0;

}  

Wir interpretieren also die beiden Argumente der Nachricht in Abhängigkeit ihres Typs - auch das ist gängige Praxis. Bei START ignorieren wir sie völlig, bei BEWEGE sehen wir Argument 1 als Zielposition an und bei BEWEGE_OBJEKT nehmen wir aus ihnen die Start- und die Zielposition.

Die main()-Funktion des Empfängerprogramms ist der des Senderprogramms so ähnlich, dass ich mir hier das Abdrucken spare. Der einzige Unterschied ist, dass am Ende noch mit einem Aufruf von kappeVerbindung() die Nachrichtenschlange gelöscht wird.

Setzen wir die Anzahl der Scheiben auf drei, so erhalten wir folgende Ausgabe unseres Empfängerprogramms:

receiver

Robot empfangsbereit!

Bewege Scheibe von 1 nach 2

Bewege Scheibe von 1 nach 3

Bewege Scheibe von 2 nach 3

Bewege Scheibe von 1 nach 2

Bewege Scheibe von 3 nach 1

Bewege Scheibe von 3 nach 2

Bewege Scheibe von 1 nach 2


In diesem Beispiel haben Sie gleich eine Reihe von Dingen gelernt:

Versuchen Sie am besten, sich eine ähnliche Aufgabenstellung auszudenken (etwa ein Fütterungsautomat für Ihr Aquarium oder ein Werkzeug zur Systemverwaltung) und nach diesem Muster eigene Programme zu erstellen.


Schleifenkontrolle

Manchmal werden Sie verhindern wollen, dass tatsächlich jede Iteration einer Schleife durchlaufen wird. Das kann zum einen der Fall sein, wenn eine Abfrage zwischendrin ergibt, dass ein weiterer Durchlauf nicht mehr möglich ist oder keinen Sinn mehr machen würde; dann wollen Sie die Wiederholung ganz abbrechen. Eine andere Möglichkeit besteht darin, dass Sie bei einem Durchlauf feststellen, dass Sie den Rest des Schleifenkörpers gar nicht mehr abarbeiten können oder wollen, aber trotzdem mit der nächsten Iteration fortfahren möchten. Für beide Fälle bietet C++ entsprechende Befehle.

Für die erste Aufgabe gibt es die break-Anweisung, die wir schon bei der Mehrfachauswahl kennen gelernt haben. Innerhalb einer Schleife bewirkt sie, dass die Abarbeitung des Schleifenkörpers unmittelbar abgebrochen wird und das Programm beim nächsten Befehl hinter der Schleife fortfährt. Natürlich werden Sie nie diese Anweisung in Ihr Programm schreiben, ohne sie von einer Bedingung abhängig zu machen. Zuweilen will man etwa eine bestimmte Anzahl von Objekten auf eine Eigenschaft durchsuchen und schreibt dazu eine for-Schleife; wenn ein Objekt mit der gewünschten Eigenschaft gefunden wurde, kann die Suche abbrechen - denn warum sollte man noch weitere Iterationen durchlaufen, wenn das Ziel schon erreicht wurde?

Dazu ein einfaches Beispiel: Sie wissen, dass irgendeine Zahl unter zwanzig als dritte Potenz 1728 ergibt. Leider kann Ihr Taschenrechner aber keine dritten Wurzeln berechnen und daher lassen Sie Ihren Computer probieren. Sobald die gesuchte Zahl ermittelt wurde, darf die Suche abbrechen.

  for (int i=1; i<20; i++)

  {

    if (i*i*i == 1728)

      break;

    cout << i << `` ist es nicht...'' << endl;

  }

 

  cout << i << ``^3 = 1728!'' << endl;

Kleines Quiz am Rande: Wie können Sie überprüfen, ob überhaupt ein $i$ gefunden wurde, das die Bedingung erfüllt?

Die andere Variante erreichen Sie mit der continue-Anweisung. Sie bewirkt, dass der Rest des Schleifenkörpers übersprungen und die Ausführung mit der nächsten Iteration fortgesetzt wird. Auch diese Anweisung werden Sie stets in Abhängigkeit von einer Bedingung verwenden. Sie bietet sich beispielsweise an, wenn Sie mit allen Elementen einer Menge eine bestimmte Operation durchführen wollen, nur mit einem nicht. Wenn Sie etwa die Funktion $f(x) = \frac{1}{x}$ im Bereich von -10 bis 10 mit Schrittweite 1 ausgeben wollen, können Sie schreiben:

  for (int k=-10; k<=10; k++)

  {

    if (k == 0)

      continue;

 

    cout << ``f(`` << k << ``) = `` 

         << 1.0/k << endl;

  }

denn bei 0 ist die Funktion ja nicht definiert.

Beide Anweisungen zur Schleifenkontrolle können Sie bei allen drei oben besprochenen Typen verwenden.

Zusammenfassung

Aus diesem Abschnitt sollten Sie sich Folgendes merken:

Übungsaufgaben

  1. Welche Ausgabe hat folgender Programmausschnitt:

    int i, x=0;

    for (i=0; i<10; i++)

      x += i;

    cout << x << `` `` << i << endl;

  2. Implementieren Sie ein Verfahren zur Berechnung der Kubikwurzel für beliebige positive reelle Zahlen. Testen Sie es an Beispielen.
  3. Entwickeln Sie eine ähnliche Aufgabenstellung wie beim Beispiel Türme von Hanoi und passen Sie das Programm auf dieses Problem an.


Dateien und Ströme

Eine der wichtigsten Möglichkeiten, um Daten auch über das Ende eines Programms hinaus zu erhalten oder mit anderen auszutauschen, sind Dateien. Die C++-Standardbibliothek bietet zur Arbeit mit Dateien eine umfangreiche Liste von Klassen an, mit denen Sie Dateien anlegen, lesen, beschreiben und ergänzen können.


Standardein- und -ausgabe

An vielen Stellen in diesem Buch habe ich bereits die Standardkanäle für die Ein- und Ausgabe verwendet. Daher wird es Zeit, dass Sie verstehen, um was es sich dabei eigentlich handelt. Dazu müssen wir uns aber zunächst anschauen, woher unsere Daten eigentlich kommen und wohin wir sie schicken.

Unter Unix kann ein Programm Eingaben eines Benutzers (Namen, Zahlen und so weiter) von der so genannten Standardeingabe lesen. Das ist im Normalfall die Tastatur. Wenn Sie das Programm aus der Shell aufrufen, können Sie aber auch die Standardeingabe umlenken und alles aus einer anderen Datei holen, was sonst ein Benutzer eingeben müsste. Haben Sie etwa ein Programm protect, das die Eingabe eines Benutzernamens erfordert, können Sie auch (etwa mit vi) eine kleine Datei erstellen, die nur aus diesem Namen und einem anschließenden Zeilenumbruch (newline) besteht. Nennen wir diese beispielsweise kennung.txt. Dann erfolgt der Aufruf folgendermaßen (C-Shell):

protect < kennung.txt

In C++ sind diese Kanäle durch Ströme (streams) repräsentiert. Das sind Objekte, die eine Folge von Bytes liefern beziehungsweise aufnehmen und ausgeben können. Der Standardeingabe ist der Stream cin zugeordnet. Um die Metapher vom Fluss noch zu unterstreichen, verwendet man für die Eingabe den Operator >>. Dahinter geben Sie dann die Variable an, die die Eingabe aufnehmen soll. Anhand des Typs erkennt der Stream, wie er die Bytefolge interpretieren soll. Es werden so lange Zeichen akzeptiert, bis der Benutzer die Eingabetaste drückt. In folgendem Beispiel wird eine Ganzzahl abgefragt. Stehen mehrere in einer Zeile, getrennt durch Leerzeichen, hintereinander, so wird die erste davon nach x geschrieben.

  int x;

  cin >> x;

Um die in diesem Abschnitt beschriebenen Streams zu verwenden, müssen Sie die Header-Datei iostream in Ihr Programm einbinden.

Für die Ausgabe gibt es den entgegengesetzten Operator <<. Auch hier können Sie sich vorstellen, dass der Operator in die Richtung zeigt, in die die Bytes geschickt werden sollen. Das Besondere an diesem Operator ist zum einen, dass er für alle Standardtypen definiert ist und Sie sich damit nicht darum kümmern müssen, die Daten für die Ausgabe zu formatieren. Zum anderen ist es sehr praktisch, dass mehrere Ausgaben unterschiedlichen Typs miteinander verkettet werden können, wie wir es ja bereits mehrfach verwendet haben, etwa zuletzt auf Seite [*]. Wenn Sie die Ausgabe so verketten, werden alle auszugebenden Zeichen hintereinander gefügt; um trennende Leerzeichen und so weiter müssen Sie sich natürlich selbst kümmern. Um einen Zeilenumbruch einzufügen, schicken Sie endl in den Stream, einen so genannten Manipulator, der kein Zeichen im eigentlich Sinn ist, sondern nur auf das Format der Ausgabe Einfluss nimmt. Der Stream für die Standardausgabe hat übrigens den Namen cout.

  int x=25;

  float d=3.14;

  cout << ``x ist `` << x << `` und d ist `` 

       << d << endl;

Ebenso wie die Standardeingabe ist auch die Standardausgabe ein fest definierter Begriff unter Unix. Das ist der Text, den das Programm während seines Laufs in das Shell-Fenster schreibt. Manchmal möchte man die Ausgabe aber lieber in einer Datei haben. Dazu gibt es auch hier die Möglichkeit, den Kanal in eine Datei umzulenken. Das können Sie schon mit einem einfachen Kommando wie ls ausprobieren. Wenn Sie eingeben:

ls > verzeichnis.txt

erhalten Sie überhaupt keine Ausgabe auf dem Bildschirm, sondern finden anschließend eine Auflistung aller Dateien des aktuellen Verzeichnisses in der Datei verzeichnis.txt.

Eine Möglichkeit, die Umlenkung von Aus- und Eingabe in der Shell zu verbinden, ist der Pipe-Mechanismus mit dem Zeichen |. Damit erreichen Sie, dass die Standardausgabe des ersten in die Standardeingabe des zweiten Programms umgeleitet wird. Eine häufige Anwendung ist das Zusammenspiel von cat, das einfach eine Datei auf die Standardausgabe schickt, und grep, das aus der Standardeingabe alle Zeilen herausfiltert, die ein bestimmtes Muster oder einen Suchbegriff enthalten. Suchen Sie in Ihrer Quelltextdatei prog.cc beispielsweise alle Stellen, an denen Sie das Objekt Konfiguration verwendet haben, so können Sie schreiben:

cat prog.cc | grep Konfiguration

Wenn Sie nun aber alle Ausgaben Ihres Programms in eine Datei umgelenkt haben, bekommen Sie auch die Fehlermeldungen nicht mit - sollte man meinen. Da dies aber sicher nicht hilfreich ist, gibt es noch einen zweiten Kanal, über den Sie Ausgaben vornehmen können: die Standardfehlerausgabe. Diese sprechen Sie als cerr (oder auch clog, was fast dasselbe ist) an. Ohne Umlenkung bemerkt der Benutzer des Programms gar nicht, dass es sich um zwei getrennte Ausgabewege handelt, da beide auf den Bildschirm schreiben. Erst wenn die Standardausgabe umgelenkt wird und immer noch Text auf dem Bildschirm erscheint, wurde dieser sicherlich in den Fehlerausgabekanal geschrieben. Wenn Sie Textausgaben programmieren, sollten Sie sich also stets überlegen, ob Sie eine Nachricht ausgeben wollen, die bei korrektem Programmablauf erscheinen soll, oder eine Fehlermeldung. Im letzteren Fall sollten Sie dazu cerr verwenden.

Wenn Programme im Hintergrund oder im Batch-Betrieb laufen, ist eine Bildschirmausgabe meist nicht sinnvoll, oft sogar unmöglich. Daher sollten Sie in einer solchen Situation beide Ausgabekanäle umleiten. Die Syntax der Shell-Anweisung dafür variiert von Shell zu Shell ein wenig. In der C-Shell etwa verwendet man die Zeichen >&. Wenn Sie zum Beispiel alle Ausgaben des find-Kommandos, das nach C++-Quelltextdateien sucht, in eine Datei namens gefunden.txt speichern wollen, geben Sie ein:

find . -name ``*.cc'' -print >& gefunden.txt

Auf diese Weise wird eine Bildschirmausgabe vollständig unterdrückt.

Ein- und Ausgabe mit Dateien

Für die Arbeit mit Dateien stehen eine Reihe von Klassen in der C++-Standardbibliothek zur Verfügung. Für den Programmierer besonders relevant sind dabei:

Wenn Sie also auf eine Datei zugreifen wollen, müssen Sie ein Objekt einer dieser Klassen anlegen. Damit der Compiler die Klassen auch kennt, binden Sie die Header-Dateien iostream und fstream ein, am besten gleich gefolgt von einer obligatorischen Deklaration des Standardnamensraums (siehe auch Seite [*]), also etwa:

#include <iostream>

#include <fstream>

  

using namespace std;

Figure: Der Zugriff auf Dateien auf der Festplatte läuft über Stream-Objekte.

\resizebox*{5cm}{!}{\includegraphics{images/harddisk.eps}}


Öffnen der Datei

Zum Öffnen gibt es zwei Möglichkeiten. Zur Identifikation dient in beiden Fällen der Dateiname.

  1. Entweder Sie geben den Namen gleich als Argument des Konstruktors an. Dann wird das Öffnen der Datei zusammen mit der Erzeugung der Instanz erledigt, beispielsweise

      ofstream outfile(``results.dat'');

  2. Wenn Sie schon ein Objekt haben und über dieses eine Datei öffnen möchten, so können Sie die Methode open() verwenden. Diese verlangt ebenfalls den Dateinamen als Argument.

      ifstream infile;

      infile.open(``myfile.txt'');

Zuweilen müssen Sie auch angeben, was Sie mit der Datei vorhaben. Denn davon hängt es ab, wo der Dateizeiger positioniert wird. Das Betriebssystem merkt sich nämlich stets, an welcher Stelle in der Datei Sie gerade stehen, das heißt, wohin als Nächstes geschrieben beziehungsweise von wo als Nächstes gelesen wird. Daneben gibt es noch ein paar weitere Modi, in denen Sie Dateien bearbeiten können. Um einen solchen festzulegen, können Sie dem Konstruktor oder der open()-Methode noch ein zusätzliches Argument mitgeben. Unter anderem können Sie folgende Spezifizierer verwenden:


   
Spezifizierer Beschreibung
 

ios_base::app

ios_base::ate

Dateizeiger wird beim Öffnen auf das Ende positioniert
ios_base::binary Datei wird im Binär-Modus geöffnet (Voreinstellung ist Text-Modus)

ios_base::nocreate

Die Datei wird nicht bei Bedarf erzeugt; das Öffnen scheitert, wenn die Datei nicht bereits existiert


Hier \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} noch eine wichtige Anmerkung: Der neue ANSI/ISO-Standard schreibt vor, dass die Basisklasse aller Stream-Klassen den Namen ios_base hat. Leider ist die Implementierung der C++-Standardbibliothek erst ab GCC 3.0 so weit, dass sie dies berücksichtigt. In früheren Versionen heißt die Basisklasse noch ios. Wenn Sie also einen älteren Compiler verwenden, muss es bei Ihnen im Folgenden überall dort, wo hier ios_base steht, dann schlicht ios lauten.

Bei Ausgabedateien ist die Voreinstellung so festgelegt, dass sie geöffnet werden und ihr eventuell schon vorhandener Inhalt gelöscht wird. Wenn Sie also an das Bestehende anhängen möchten, müssen Sie ios_base::app angeben.

Wollen Sie mehrere dieser Spezifizierer gleichzeitig angeben, müssen Sie sie durch eine ODER-Verknüpfung mittels | miteinander verbinden, zum Beispiel:

ofstream outfile(``tep.dat'', 

                 ios_base::app | ios_base::binary);


Schließen einer Datei

Wenn wir nun schon die Datei geöffnet haben, sollten wir sie der Ordnung halber eigentlich wieder schließen, wenn wir sie nicht mehr brauchen. Zum Glück weiß aber ein Stream-Objekt, ob mit ihm eine geöffnete Datei verbunden ist; wird das Stream-Objekt zerstört, das heißt wird sein Destruktor aufgerufen, schließt es selbstständig die Datei. Daher müssen Sie sich also nur dann selbst um das Schließen kümmern, wenn Sie dasselbe Stream-Objekt nochmals anderweitig verwenden wollen.

void ausgabe()

{

  ofstream resfile(``results.log'');

  resfile << ``The results are: `` << endl;

  // ... weitere Ausgaben


In diesem Beispiel endet mit der abschließenden geschweiften Klammer der Funktionskörper und damit die Lebenszeit des Objekts resfile. Es wird also dessen Destruktor aufgerufen, in dem auch die Datei results.log geschlossen wird.

Wenn Sie das Schließen selbst übernehmen wollen, rufen Sie dazu die Methode close() auf.

  ofstream resfile(``results.log'');

  // ... verschiedene Ausgaben

  resfile.close();

Ein- und Ausgabe

Für die Standarddatentypen wie int, char oder float stellt die C++-Standardbibliothek die Operatoren ``>>'' sowie ``<<'' zur Ein- und Ausgabe zur Verfügung, die Sie genauso verwenden wie bei der Tastatureingabe beziehungsweise der Bildschirmausgabe. Beachten Sie dabei, dass beim Einlesen Trennzeichen wie Leerzeichen, Tabulator oder Zeilenumbruch standardmäßig überlesen werden.

Zur Ein- und Ausgabe von Objekten Ihrer eigenen Klassen können Sie die Operatoren auch überladen. Ein Beispiel dazu finden Sie auf Seite [*].

Generell können Sie mehrere Operatoren hintereinander hängen und die Ausgabe durch Kontrollstrukturen steuern. Wollen Sie beispielsweise die Ergebnisse einer Funktion f() an den Stellen 1 bis 10 in eine Datei schreiben, brauchen Sie dazu den Code:

  ofstream o(``ergebnis.dat'');

  for(int i=1; i <= 10; i++)

    o << i << `` \t'' << f(i) << endl;

Sie wundern sich sicher, was das \t in der Ausgabe zu bedeuten hat. Es handelt sich dabei um ein Relikt aus C-Zeiten, zu dem man leider gezwungen ist, da nicht alle Sonderzeichen in Form von Manipulatoren wie endl verfügbar sind. Mit \t etwa fügen Sie einen horizontalen Tabulator in die Ausgabe ein. Eine solche Zeichenkombination nennt man auch Escape-Sequenz. Weitere nützliche Steuer-Sequenzen sind:


 


Möchten Sie nur einzelne Zeichen einlesen, sollten Sie die Methode get() verwenden, die auch in mehreren überladenen Versionen für die Standarddatentypen vorliegt. Sie empfiehlt sich vor allem bei binären Dateien; Sie können damit auf jedes Byte einzeln zugreifen, da sie auch Trennzeichen liest.

  char c;

  ifstream in(``orb2234.dpr'', ios_base::binary);

  in.get(c);

Die entsprechende Schreibmethode heißt put(). Auf diese Weise lässt sich auch eine Kopierfunktion realisieren:

  ifstream in(``myfile'');

  ofstream out(``myfile.copy'');

  char c;

  while (in.get(c))

    out.put(c);

Wollen Sie hingegen ganze Zeilen aus einer Textdatei einlesen, sollten Sie zur Funktion getline() greifen. Dabei werden alle Zeichen von der aktuellen Position des Dateizeigers bis zum Zeilenende eingelesen. Das Zeilenende ist dabei durch ein Newline (\n) gekennzeichnet.

  string buffer;

  ifstream in(``myfile.txt'');

  // ...

  getline(in, buffer);

Dazu müssen Sie natürlich die Header-Datei string in Ihr Programm einbinden.

Fehlerbehandlung

Die Arbeit mit Dateien ist ein sensibler Bereich, der für die Robustheit eines Programms ausschlaggebend sein kann und in dem Sie daher keinesfalls auf Fehlerabfragen verzichten sollten. Zu viel kann schief gehen: Die Datei, die Sie öffnen möchten, kann nicht vorhanden sein, das Verzeichnis, in das Sie schreiben wollen, ebenso wenig und so weiter. Unter Unix stellt sich zudem im Vergleich zu Windows die Frage nach den Zugriffsrechten drängender. Der Fall, dass ein Verzeichnis zwar existiert, der Prozess dort jedoch nicht schreiben darf, ist gar nicht so selten. Und schließlich dürfen Sie auch in Zeiten ausufernder Festplattengrößen die Möglichkeit nicht außer Acht lassen, dass Sie beim Schreiben an die Grenze der Kapazität stoßen.

Ein sehr einfacher und eleganter Weg ist die Abfrage des Status eines Datei-Streams über das Objekt selbst. Es liefert nämlich 0 zurück, wenn ein Fehler aufgetreten ist.

  ifstream infile(``readfile.asc'');

  if (!infile) {

    cerr << ``Datei readfile.asc kann nicht ``

         << ``geöffnet werden!'' << endl;

    return -1;

  }

Kein Fehler im eigentlichen Sinn, aber doch ein Zustand, auf den Sie achten sollten, ist das Erreichen des Dateiendes beim Lesen. Auch diese Situation können Sie über das Objekt abfangen:

  string buff;

  while (infile) {

    getline(infile, buff);

    cout << buff;

  }

Das bedeutet: Die Schleife läuft so lange, bis das Ende der Datei erreicht ist, denn dann wird die while-Bedingung gleich 0.

Für eine genauere Fehlererkennung bieten Ihnen die Stream-Klassen einige spezielle Methoden. Die wichtigsten davon sind:

Obige Schleife heißt damit etwa:

  while (!infile.eof()) {

  // ...

  }

Als kleines Beispiel betrachten wir ein Programm, das die Anzahl der Zeilen einer Textdatei ermittelt. Unter Linux brauchen wir ein solches Werkzeug eigentlich nicht, da das Kommando wc bereits diese Aufgabe erfüllt. Aber zur Illustration ist es sicher ganz hilfreich.


1:  #include <iostream> 

2: #include <fstream>
3: #include <string>
4:
5: using namespace std;
6:
7: int main(int argc, char** argv)
8: {
9: if (argc<2) {
10: cerr << "Bitte einen Dateinamen als"
11: << " Argument angeben!" << endl;
12: return -1;
13: }
14:
15: ifstream in(argv[1]);
16: if (!in) {
17: cerr << "Datei " << argv[1] << " kann "
18: << "nicht geöffnet werden!" << endl;
19: return -2;
20: }
21:
22: string buff;
23: unsigned long l=0;
24:
25: while(!in.eof()) {
26: getline(in, buff);
27: l++;
28: }
29:
30: cout << "Zeilen: " << l-1 << endl;
31: return 0;
32: }
Das Programm übernimmt den Dateinamen als Argument von der Kommandozeile (siehe auch Seite [*]). Der Datentyp char** ist ein Feld von Zeigern; wir werden ab Seite [*] genauer darüber sprechen. Fehlt in der Kommandozeile eine Angabe, so brechen wir gleich ab (Zeilen 9-12). Ebenso verfahren wir, wenn sich eine Datei mit diesem Namen nicht finden beziehungsweise öffnen lässt (Zeilen 16-19). Dann können wir wie oben beschrieben mit getline() den Inhalt Zeile für Zeile lesen und unseren Zähler jeweils um eins erhöhen (Zeile 26/27). Da wir auf diese Weise eine Zeile zu viel zählen (wissen Sie, warum?), müssen wir bei der Ausgabe noch eine abziehen (Zeile 30).

Positionierung des Dateizeigers

Wenn Sie eine Datei öffnen (und kein zusätzliches Argument dabei angegeben haben), steht der Dateizeiger ganz am Anfang. Besonders bei binären Dateien, in denen die Daten Byte für Byte unmittelbar hintereinander kommen und die für uns nicht ohne weiteres lesbar sind, kann es wichtig sein, den Dateizeiger an eine andere Stelle zu versetzen. Aber auch bei Textdateien, die im ASCII-Code gespeichert sind und die wir daher lesen können, will man manchmal an eine bestimmte Stelle springen - und sei es nur an den Anfang oder das Ende.

Um in einer Datei an eine bestimmte Position zu gelangen, verwenden Sie die Methoden ostream::seekp() in einer Ausgabedatei und istream::seekg() in einer Eingabedatei. Die aktuelle Position des Dateizeigers können Sie mit Hilfe von ostream::tellp() beziehungsweise istream::tellg() bestimmen. Deren Ergebnisse können Sie später als Sprungadressen verwenden.

Sie können aber auch relativ springen, das heißt eine bestimmte Anzahl von Bytes gerechnet ab Anfang oder Ende der Datei oder ab der aktuellen Position.

Die seek-Methoden benötigen folgende Parameter:

In folgendem Beispiel springen wir zunächst ans Dateiende. Wenn wir dort tellg() aufrufen, entspricht dieser Wert genau der Größe der Datei. Anschließend verschieben wir den Dateizeiger in die Mitte der Datei.

  ifstream in(``myfile.txt'');

  // Zum Ende der Datei

  in.seekg(0, ios_base::end);     

  // Aktuelle Position 

  streampos sp = in.tellg(); 

  in.seekg(-sp/2, ios_base::cur);

Hintergrund

Der Grund, dass wir so leicht zwischen verschiedenen Positionen einer Datei hin- und herspringen können, liegt darin, dass der Zugriff intern zwischengepuffert wird. Bei Ausgabedateien werden die Zeichen zunächst nur in den Puffer geschrieben. Erst wenn er voll ist oder der Stream geschlossen wird, erfolgt das tatsächliche Schreiben in die Datei. Dieses Verhalten zeigen übrigens auch die Standardstreams wie cout und clog.

Das kann manchmal unerwünschte Nebeneffekte haben. So geht man bei der Fehlersuche in größeren Programmen oft so vor, dass man zwischen den einzelnen Schritten jeweils eine Meldung mit cout ausgibt. Bricht das Programm ab, weiß man in etwa, wie weit es gekommen ist. Aufgrund der Pufferung kann es dabei jedoch vorkommen, dass die letzten Ausgaben vor dem Absturz gar nicht mehr auf dem Bildschirm erscheinen, da sie nur im Puffer standen und noch nicht rausgeschrieben wurden. Somit sucht man zuweilen den Fehler an einer völlig falschen Stelle.

Um das zu verhinden, sollten Sie bei solchen Ausgaben stets das sofortige Leeren des Puffers erzwingen. Dazu dienen sowohl der Manipulator flush als auch die Methode flush(). Ersterer ist meist eleganter, da er sich nahtlos in die übrigen Ausgaben einreiht.

  ofstream out(``myfile.txt'');

  out << ``Debug-Ausgabe: `` << endl;

  out.flush(); // Puffer sofort leeren

 

  cout << ``Schritt 2 erreicht! `` << endl << flush;

Ausgabeformatierung

Vielfach wird Ihnen die Form, in der die eingebauten Operatoren Ihre Daten ausgeben, unbefriedigend erscheinen. Aber auch dafür bieten die C++-Streams Möglichkeiten der Einflussnahme an, etwa damit Sie eine Ausgabe im vorgegebenen Zahlenformat oder als Tabelle mit fester Spaltenbreite erreichen. Einige der wichtigsten Formatierungsbefehle will ich Ihnen im Folgenden vorstellen.

Auffüllen auf vorgegebene Breite

Mit Hilfe der Methode width() der Ausgabeklassen können Sie die Breite der unmittelbar folgenden Ausgabe festlegen. Falls also die Ausgabe weniger Zeichen liefert als angegeben, werden die übrigen Plätze durch Leerzeichen aufgefüllt. Wenn Ihnen das Leerzeichen nicht gefällt, können Sie mit fill() andere Füllzeichen festlegen. Ein Beispiel:

  cout.width(5);

  cout.fill('0');

  cout << 47 << ``-``;

  cout << 11 << ``-``;

Diese Befehle führen zur Ausgabe: 00047- -11- -. Dabei zeigt sich eine wichtige Eigenschaft der width()-Methode: Sie wirkt immer nur auf die unmittelbar darauf folgende Ausgabe! Für die Ausgabe der 11 in der vierten Zeile gilt wieder der Vorgabewert 0.

Dasselbe Resultat können Sie übrigens auch mit Manipulatoren erreichen. Dazu müssen Sie die Header-Datei iomanip in Ihr Programm einbinden. Dann können Sie die Breite der folgenden Ausgabe mit setw() (von set width) bestimmen. Das ist dann keine Methode der Klasse ostream, sondern wird unmittelbar zwischen zwei Ausgabeoperatoren eingefügt. Analog dazu gibt es den Manipulator setfill(), um das Füllzeichen zu ändern. Das Beispiel von eben hieße damit:

  cout << setw(5) << setfill('0') 

       << 47 << "-" << endl;

  cout << 11 << "-" << endl;

Die Ausgabe ist wiederum dieselbe. Beachten Sie, dass auch hierbei die Manipulatoren nur auf die unmittelbar folgende Ausgabe wirken und in der nächsten schon wieder die Vorgabewerte wirksam sind. Intern werden nämlich auch bei dieser Schreibweise lediglich die obigen Methoden aufgerufen.

Genauigkeit von Gleitkommazahlen

Die Anzahl der Ziffern von Gleitkommazahlen wird mit precision() gesteuert. Anders als bei der Breite gilt diese Festlegung dauerhaft, das heißt bis zum nächsten Aufruf von precision(). Wenn die interne Darstellung mehr Ziffern enthält, wird die Ausgabe passend gerundet.

  cout.precision(6);

  cout << 1234.5678 << endl;  // ergibt: 1234.57

Auch hier haben Sie die Möglichkeit, die Einstellung der Ziffern über einen Manipulator vorzunehmen, genauer gesagt über setprecision(). Da dieser intern auf die gerade beschriebene Methode zurückgeführt wird, gelten auch dessen Einstellungen bis auf weiteres.

  cout << setprecision(6) 

       << 1234.5678 << endl;  // ergibt: 1234.57

Besonders bei wissenschaftlichen Anwendungen gibt man die Ergebnisse oft in der so genannten Exponentialdarstellung aus, also als $x\cdot 10^n$ oder kurz $x  {\rm e}+n$. Um eine solche Ausgabe zu erreichen, müssen Sie das Flag ios_base::scientific setzen. Dann stellen Sie mit precision() die Zahl der Nachkommastellen ein.

  cout.setf(ios_base::scientific);

  cout.precision(4);

  cout << 1234.56789012345 << endl;   

  // ergibt: 1.2346e+03

Weitere Ausgabeflags

Über die Methode setf der Stream-Klassen können Sie außer der Exponentialdarstellung noch weitere Ausgabeeigenschaften beeinflussen. Dazu stellt ios_base weitere Flags bereit. Tabelle [*] zeigt entsprechende Flags.

Wenn Sie mehrere dieser Flags gleichzeitig setzen wollen, können Sie sie wieder mit | verknüpfen. Ein Löschen eines oder mehrerer Flags ist mit unsetf() möglich.


Table: Mit diesen Flags lassen sich weitere Ausgabeeigenschaften festlegen
   
Flag Beschreibung
 

ios_base::left

ios_base::right

rechtsbündige Ausgabe

ios_base::internal

zwischen Vorzeichen und Wert auffüllen

ios_base::showpoint

Dezimalpunkt und nachfolgende Nullen ausgeben

ios_base::scientific

Exponential-Format

io_bases::fixed

Festkomma-Format (precision() gibt dann Zahl der gültigen Ziffern an)

Beispiel: Umrechung DM - Euro

Als Beispiel wollen wir eine kleine Tabelle erzeugen, die uns als kleine Remineszenz die Umrechnerei von DM in Euro und zurück noch einmal vor Augen führen soll. Unsere Tabelle soll dabei von 1 bis 1000 reichen, wobei in jeder Zehnerpotenz immer nur die ersten fünf ganzen Vielfachen ausgegeben werden sollen, also 1, 2, 3, 4, 5, 10, 20 und so weiter - die Beschreibung ist komplizierter als das Programm ... Außerdem sollen alle Zahlen auf zwei Nachkommastellen formatiert werden und die Spalten sollen dieselbe Breite haben. Bevor Sie nun die nachfolgende Musterlösung durchgehen, sollten Sie versuchen, selbst ein Programm zu schreiben, das diese Aufgabe erfüllt.


1:  #include <iostream> 

2: #include <iomanip>
3:
4: using namespace std;
5:
6: main()
7: {
8: int i,j;
9: const double wk = 1.95583;
10:
11: cout.precision(2);
12: cout.setf(ios_base::right | ios_base::fixed,
13: ios_base::adjustfield | ios_base::floatfield);
14: cout << setw(10) << "DM" << setw(10)
15: << "Euro" << " | " << setw(10)
16: << "Euro" << setw(10) << "DM" << endl;
17:
18: for(i=1;i<=44;i++) cout << '-';
19: cout << endl;
20:
21: for(i=1;i<=1000;i*=10) {
22: for(j=1;j<=5 && i*j<=1000;j+=1) {
23: cout << setw(10) << (float)i*j
24: << setw(10) << i*j/wk << " | "
25: << setw(10) << (float)i*j
26: << setw(10) << i*j*wk << endl;
27: }
28: }
29: return(0);
30: }
In diesem Programm verwenden wir von den oben beschriebenen Befehlen die Formatierung mit fester Breite (Zeilen 14 ff. und 23 ff.) und die rechtsbündige Ausgabe (Zeile 12/13). Das zweite Argument von setf() gibt lediglich die Menge aller Bits an, unter denen wir etwas verändern können, damit durch diesen Befehl nicht in einen völlig falschen Bereich etwas eingetragen wird.

Als Ausgabe erhalten wir die nachstehende Tabelle. Sie hat zwar alle gewünschten Inhalte und ist im Rahmen der Anforderungen und Möglichkeiten formatiert - aber mal ehrlich: Möchten Sie die Tabelle in dieser Form ausdrucken, um Sie in Ihre Brieftasche zu stecken?

        DM      Euro |       Euro        DM

----------------------

      1.00      0.51 |       1.00      1.96

      2.00      1.02 |       2.00      3.91

      3.00      1.53 |       3.00      5.87

      4.00      2.05 |       4.00      7.82

      5.00      2.56 |       5.00      9.78

     10.00      5.11 |      10.00     19.56

     20.00     10.23 |      20.00     39.12 ...

Da man heutzutage nur noch Ausgaben mehr oder weniger perfekter Textverarbeitungen auf Papier sieht, wirkt diese Form ungewohnt und geradezu hölzern. Wie stellen wir aber die Verbindung zwischen unserem Programm und einer Textverarbeitung her? Auch für dieses Problem bietet uns Linux eine Lösung: Wir benutzen einfach LATEX, um unsere Ausgabe zu formatieren. Es wird bei jeder Distribution mitgeliefert und ist so leistungsstark, dass wir uns nicht mit den Ausgabeformaten abmühen müssen, sondern diese Aufgabe einer Anwendung überlassen können, die sie sehr viel besser lösen kann. Das Prinzip ist ganz einfach: Wir erzeugen mit unserem Programm eine Eingabedatei für LATEX, starten es dann und wandeln, wenn alles bis hierher gut gegangen ist, die DVI-Datei (ein geräteunabhängiges Textausgabeformat, das aus dem LATEX-Code als Zwischenformat erzeugt wird) in eine PostScript-Datei um. Der folgende Code realisiert dieses Vorhaben.


1:  #include <iostream> 

2: #include <fstream>
3: #include <sstream>
4: #include <cstdlib>
5: #include <unistd.h>
6:
7: using namespace std;
8:
9: main()
10: {
11: int i,j;
12: const double wk = 1.95583;
13:
14: ostringstream ostr;
15: ostr << "euro." << getpid() << ".tex";
16:
17: ofstream out((ostr.str()).c_str());
18: out.precision(2);
19: out.setf(ios::fixed);
20:
21: out << "\\documentclass{article}" << endl;
22: out << "\\begin{document}" << endl;
23: out << "\\section*{Umrechungstabelle"
24: << " DM -- Euro}" << endl;
25: out << "\\begin{tabular}{rr|rr}" << endl;
26: out << "DM & EUR & EUR & DM \\\\ \\hline"
27: << endl;
28:
29: for(i=1;i<=1000;i*=10) {
30: for(j=1;j<=5 && i*j<=1000;j+=1) {
31: out << (float)i*j << " & "
32: << i*j/wk << " & "
33: << (float)i*j << " & "
34: << i*j*wk << " \\\\" << endl;
35: }
36: }
37: out << "\\end{tabular}" << endl;
38: out << "\\end{document}" << endl;
39: out.close();
40:
41: ostringstream ltcmd;
42: ltcmd << "latex " << ostr.str();
43: if (!system((ltcmd.str()).c_str())) {
44: ostringstream dvicmd;
45: dvicmd << "dvips euro." << getpid()
46: << ".dvi -o";
47: if (system((dvicmd.str()).c_str()))
48: cerr << "PS-Datei konnte nicht"
49: << " erzeugt werden!" << endl;
50: }
51: else
52: cerr << "DVI-Datei konnte nicht"
53: << " erzeugt werden!" << endl;
54: return(0);
55: }

Das Ergebnis im Druck sehen Sie in Tabelle [*] (denn auch dieses Buch ist mit LATEX erstellt).

Table: Die formatierte Umrechnungstabelle sieht im Druckbild wesentlich ansprechender aus als die unformatierte.
DM EUR EUR DM
1.00 0.51 1.00 1.96
2.00 1.02 2.00 3.91
3.00 1.53 3.00 5.87
4.00 2.05 4.00 7.82
5.00 2.56 5.00 9.78
10.00 5.11 10.00 19.56
20.00 10.23 20.00 39.12
30.00 15.34 30.00 58.67
40.00 20.45 40.00 78.23
50.00 25.56 50.00 97.79
100.00 51.13 100.00 195.58
200.00 102.26 200.00 391.17
300.00 153.39 300.00 586.75
400.00 204.52 400.00 782.33
500.00 255.65 500.00 977.91
1000.00 511.29 1000.00 1955.83

In diesem Programm dürften gleich einige Dinge für Sie neu sein. Betrachten wir es also etwas genauer.


String-Streams

Bisher waren unsere Streams solche Ströme, die auf dem Bildschirm oder in Dateien etwas ausgegeben haben oder aus ähnlichen Quellen etwas eingelesen haben. Die Streams waren also entweder einer Datei zugeordnet oder dem Terminal beziehungsweise der Tastatur. Sie können einen Stream jedoch auch einem String zuordnen, also einer Klasse für Zeichenketten. Auch wenn wir uns mit Strings aus der Standardbibliothek erst ab Seite [*] beschäftigen werden, möchte ich Ihnen an dieser Stelle schon einmal zeigen, wie Sie mit den Formatierungsmöglichkeiten der Streams in Strings schreiben können. (Das Lesen erfolgt ganz analog.) Einen solchen Stream für Strings bezeichnet man als String-Stream. Um diesen zu verwenden, müssen Sie die Datei sstream in Ihren Code einbinden.

Wie Sie etwa in den Zeilen 14 und 15 sehen, gehen Sie mit einem String-Stream genauso um wie mit einem anderen Ausgabestream (vergleiche Seite [*]). Wenn Sie den Inhalt des String-Streams an anderer Stelle verwenden wollen, etwa als Dateiname wie in Zeile 17 oder als Aufrufargument wie in Zeile 43, müssen Sie den darin enthaltenen String extrahieren, also das Objekt vom Typ string. Ein Ausgabeoperator wie in Zeile 42 kann damit noch etwas anfangen; der Konstruktor des ofstream in Zeile 17 bereits nicht mehr. Hier zeigt sich wieder einmal deutlich der Bruch zwischen dem Anspruch von C++, eine vollkommen objektorientierte Sprache zu sein, und der Wirklichkeit, die durch viele Relikte aus der C-Vergangenheit geprägt ist. Der Konstruktor erwartet nämlich kein string-Objekt, sondern eine Zeichenkette im C-Stil, die wir im nächsten Abschnitt besprechen werden. Daher genügt es nicht, nur aus dem String-Stream einen String zu machen, es muss auch noch dieser in eine C-Zeichenkette umgewandelt werden. Das erklärt die Verschachtelung im Ausdruck

(ostr.str()).c_str()

Hintergrund

Die String-Streams, wie sie hier verwendet werden, entsprechen genau den Regelungen des aktuellen C++-Standards der ANSI und ISO. Sie sind beim GCC indessen erst seit Version 3.0 verfügbar. Vorher gab es ganz ähnliche Klassen namens ostrstream und istrstream. Diese waren jedoch keinem String-Objekt zugeordnet, sondern einer einfachen C-Zeichenkette. Für unser Beispiel wäre das aber nicht nur ausreichend, sondern auch deutlich einfacher gewesen. Wenn Sie also dieses Programm mit einer früheren Version des GCC übersetzen wollen, sollten Sie folgende Variante verwenden:

1:  #include <iostream> 

2: #include <fstream>
3: #include <strstream>
4: #include <cstdlib>
5: #include <unistd.h>
6:
7: using namespace std;
8:
9: main()
10: {
11: int i,j;
12: const double wk = 1.95583;
13:
14: ostrstream ostr;
15: ostr << "euro." << getpid()+l << ".test" << ends;
16:
17: ofstream out(ostr.str());
18: out.precision(2);
19: out.setf(ios::fixed);
20:
21: out << "\\documentclass{article}" << endl;
22: out << "\\begin{document}" << endl;
23: out << "\\section*{Umrechungstabelle"
24: << " DM -- Euro}" << endl;
25: out << "\\begin{tabular}{rr|rr}" << endl;
26: out << "DM & EUR & EUR & DM \\\\ \\hline"
27: << endl;
28:
29: for(i=1;i<=1000;i*=10) {
30: for(j=1;j<=5 && i*j<=1000;j+=1) {
31: out << (float)i*j << " & "
32: << i*j/wk << " & "
33: << (float)i*j << " & "
34: << i*j*wk << " \\\\" << endl;
35: }
36: }
37: out << "\\end{tabular}" << endl;
38: out << "\\end{document}" << endl;
39: out.close();
40:
41: ostrstream ltcmd;
42: ltcmd << "latex " << ostr.str() << ends;
43: if (!system(ltcmd.str())) {
44: ostrstream dvicmd;
45: dvicmd << "dvips euro." << getpid()+l
46: << ".dvi -o" << ends;
47: if (system(dvicmd.str()))
48: cerr << "PS-Datei konnte nicht"
49: << " erzeugt werden!" << endl;
50: dvicmd.freeze(0);
51: }
52: else
53: cerr << "DVI-Datei konnte nicht"
54: << " erzeugt werden!" << endl;
55:
56: ltcmd.freeze(0);
57: ostr.freeze(0);
58:
59: return(0);
60: }
In den Zeilen 14/15 verwenden wir einen solchen ostrstream. Einer der beiden Unterschiede zum stringstream ist, dass Sie das Ende Ihrer Einträge kennzeichnen müssen, indem Sie als Letztes den Manipulator ends ausgeben. Das hat mit einem Relikt aus C zu tun: Zeichenketten müssen immer mit einem Byte vom Wert 0 abgeschlossen sein (man spricht auch von 0-terminiert). Bei String-Streams sorgt besagter Manipulator dafür, dass sich Ihr String nicht in unabsehbarer Länge über den Speicher erstreckt, sondern dort aufhört, wo auch Ihre Ausgaben beendet sind.

In den Zeilen 17 beziehungsweise 43 und 47 wird deutlich, dass solche Streams für unser Beispielprogramm besser geeignet wären, denn aus ihnen lassen sich die benötigten C-Zeichenketten direkt ermitteln.

Der \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} andere Unterschied ist schon etwas gravierender: Wenn Sie für einen strstream die Methode str() aufrufen, um an den Zeiger zu gelangen, setzen Sie damit einen Lock auf den String. Das bedeutet, dass der Speicherbereich erhalten bleibt. Das ist an sich auch Vernünftig, denn das Objekt exportiert damit einen Zeiger auf dynamischen Speicher und sollte sicher stellen, dass dieser auch verfügbar bleibt. Nur was passiert, wenn das Objekt aufhört zu existieren, etwa wenn es als dynamisches Objekt zerstört wird oder wenn bei einer lokalen Variablen schlicht der Gültigkeitsbereich verlassen wird? Auch in diesem Fall wird der Puffer nicht freigegeben! Sie müssten also selbst für eine Freigabe des Speicherbereiches für den String sorgen, etwa durch ein delete[] auf den Zeiger, den Sie mit str() erhielten. Einfacher ist es jedoch, dem Objekt noch zu dessen Lebzeiten mitzuteilen, dass Sie den Puffer nicht mehr benötigen und durch eine Freigabe nicht gestört würden. Das erreichen Sie mit der Methode freeze(0). In den Zeilen 50,56 und 57 sehen, wie Sie dies anwenden.

Insgesamt empfehle ich Ihnen aus Gründen der Portabilität und Zukunftssicherheit nur noch die stringstream-Klassen zu verwenden, da die strstream nicht zum Standard gehören und daher nicht von jedem Compiler unterstützt werden müssen. Beim GCC 3.0 ist die Unterstützung aus Gründen der Abwärtskompatibilität noch gegeben; bei einer der nächsten Versionen kann das schon nicht mehr der Fall sein.


Eindeutigkeit der Ausgabe über die Prozess-ID

Da Linux ein Multitasking/Multiuser-Betriebssystem ist, sollte sich eigentlich jede Anwendung mit dem Gedanken vertraut machen, dass der oder die Benutzer sie auch mehrfach gleichzeitig ablaufen lassen können. Das bedeutet insbesondere für die Ausgabe in Dateien, dass bei einem festen Namen die verschiedenen Prozesse im schlimmsten Fall zugleich darauf zugreifen könnten und so ein Kauderwelsch entstehen ließen. Bei einfachen Tools macht man sich meist nicht die Mühe, diesen Fall zu berücksichtigen, sondern vertraut darauf, dass die Anwendung ohnehin stets in getrennten Verzeichnissen ausgeführt wird.

Wie aber könnte man das Problem doch in den Griff bekommen? Haben Sie schon eine Idee? Der Standardweg ist, den Dateinamen so zu ergänzen, dass er eindeutig wird. Und zur Feststellung der Eindeutigkeit eignet sich die Identifikationsnummer (ID) des Prozesses besonders gut. Schließlich kann ja das Betriebssystem anhand dieser Nummer die einzelnen Programme auch auseinander halten. Da liegt es nahe, diesen Wert für eine ähnliche Aufgabe ebenfalls einzusetzen. Wenn Sie etwa das Kommando

ps aux

eingeben, erhalten Sie eine lange Liste aller gerade laufenden Prozesse. In der zweiten Spalte finden Sie die besagte Prozess-ID.

In diesem Zusammenhang stellt sich natürlich die Frage: Was ist ein Prozess? Auch wenn Sie keine systemnahe Programmierung machen wollen, sollten Sie eine ungefähre Vorstellung davon haben. Auf einem Linux-Recher können jederzeit mehrere unabhängige Programme von verschiedenen Benutzern laufen; auch das Betriebssystem startet und unterhält mehrere solcher Programme, um die Benutzer und die Systemressourcen zu verwalten. Ein Prozess ist eines dieser Programme. Er besteht damit aus Programmcode, einigen Daten im Speicher, eventuell offenen Dateien oder anderen Ein-/Ausgabekanälen und manchmal auch aus Umgebungsvariablen. Er läuft in einem eigenen Adressraum, kann also nicht auf den Speicherbereich zugreifen, den andere Programme verwenden. Es ist allerdings möglich, dass Programmcode in Form von dynamischen Bibliotheken in mehreren Prozessen gleichzeitig verwendet wird (siehe auch Seite [*]).

Zur Verwaltung aller Prozesse legt Unix eine Prozesstabelle an. Dort sind alle notwendigen Informationen über die gerade laufenden Prozesse hinterlegt. Um die Prozesse eindeutig unterscheiden zu können, vergibt Linux bei deren Start eine Prozessnummer. Weitere Daten über einen Prozess sind Name und Pfad des Programms, zugeordneter Benutzer (bei Systemprozessen root), CPU- und Speicherverbrauch, Start- und Laufzeit, Terminal und Status. Das ps-Kommando listet Ihnen alle Prozesse zu den angegebenen Kriterien auf (Näheres etwa unter man ps). Im Gegensatz dazu bietet Ihnen das top-Kommando eine Liste der Prozesse, die am meisten Rechenzeit verbrauchen. Diese Liste ist zudem nach dieser Eigenschaft absteigend sortiert und wird fortlaufend in bestimmten Intervallen aktualisiert. Mit dem angegebenen Prozentwert ist dabei gemeint, wie viele Arbeitsschritte (Takte) der CPU für diesen Prozess benutzt werden. Denn Multitasking bedeutet auf Rechnern mit einer CPU auch unter Unix lediglich, dass immer nur ein Prozess gleichzeitig die CPU verwendet, aber sehr oft zwischen den verschiedenen Prozessen umgeschaltet wird, so dass der Eindruck der Gleichzeitigkeit entsteht.

Tiefer kann ich in diesem Rahmen leider nicht auf dieses Thema eingehen. Wenn Sie mehr wissen wollen, können Sie beispielsweise den Klassiker [HETZE . 1996] zu Rate ziehen.

Nun zurück zu unserem Beispiel: In einem C++-Programm können Sie die ID des Prozesses, in dem das Programm gerade läuft, über die Funktion getpid() erfahren. Damit der Compiler diese kennt, müssen Sie die Unix-System-Header-Datei unistd.h einbinden.

Wir verwenden sie beispielsweise in Zeile 15, wo sie Bestandteil des Namens der Ausgabedatei wird. Ist die ID etwa 837, so erhalten wir hier den Namen euro.837.tex. Auf diese Weise erzeugt so ziemlich jeder Lauf der Programms eine eigene Ausgabedatei - auf alle Fälle aber jeder gleichzeitige Lauf.

Aufruf anderer Programme

Wenn wir LATEX verwenden wollen, müssen wir uns klar machen, dass es sich dabei um ein separates Programm handelt. Solche rufen wir normalerweise aus einer Shell auf. Natürlich ist es aber auch möglich, aus einer Anwendung heraus einen anderen Prozess zu starten.

Dazu dient die Systemfunktion system(), die als Argument einen String mit genau den Angaben erwartet, die Sie auch in der Shell eingeben würden. Ihr Rückgabewert entspricht im Allgemeinen dem Rückgabewert der main()-Funktion des anderen Programms; nur wenn keine Shell gestartet werden kann, in der der Befehl laufen soll, wird 127 zurückgegeben und bei allen anderen Fehlern -1. Der Aufruf über system() ist dabei synchron, das heißt, die Funktion wartet so lange, bis das aufgerufene Programm beendet ist. Eventuelle Bildschirmausgaben dieses Programms erscheinen in derselben Shell, in dem Sie auch den übergeordneten Prozess gestartet haben.

In unserem Beispiel stellen wir in Zeile 42 das Kommando aus Programmname und Argument (hier: Dateiname) zusammen und rufen in Zeile 43 system(). Wenn diese Funktion 0 zurückgibt, die Abarbeitung also fehlerfrei war, können wir in Zeile 4 mit dvips (wird in Zeile 45 zusammengestellt) das nächste Tool aufrufen. Auf diese Weise haben wir nicht nur aus der Ausgabe unseres Programms eine LATEX-Quelldatei erzeugt, sondern diese auch gleich selbst in eine PostScript-Datei umgewandelt.

Fazit

Auch dieses Beispiel enthielt einige Zusatzinformationen über die Anwendung der Streams hinaus. Sie haben gelernt,

Sicher sind Ihnen jetzt schon einige Ideen gekommen, wie Sie diese Techniken selbst einsetzen können. Also lassen Sie sich nicht aufhalten und gehen Sie an die Arbeit.

Zusammenfassung

Vom gesamten Abschnitt sollten Sie Folgendes in Erinnerung behalten:

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Schreiben Sie ein Programm, das die Werte der Sinusfunktionen an den Vielfachen von $\pi/8$ zwischen 0 und $2\pi$ formatiert ausgibt.
  3. Erweitern Sie das Programm aus Aufgabe 2 um eine Schleife, welche die Ausgabe 10.000 mal aufruft. Verwenden Sie zur Formatierung der Ausgabe einmal stringstream und einmal strstream und vergleichen Sie den Speicherverbrauch dieses Programms. Testen Sie das Programm mit und ohne freeze(0)-Anweisungen.
  4. Implementieren Sie Ihre Version des Linux-Werkzeuges wc. Schreiben Sie also ein Programm, das den Namen einer Textdatei über die Kommandozeile aufnimmt und für dieses Datei die Anzahl der Zeichen, Wörter und Zeilen ausgibt.


Felder, Zeiger und dynamische
Speicherverwaltung

Bisher haben wir alle Variablen und Objekte nur eindimensional verwendet. Von jedem Objekt hatten wir immer nur ein Exemplar - und wenn es mal zwei waren, hatten diese unterschiedliche Namen. Dieses Defizit ist aber auf Dauer nicht tragbar. In diesem Abschnitt wollen wir uns endlich mit der Frage beschäftigen, wie man in C++ Felder (Listen oder Vektoren) von Variablen und Objekten aufbaut und somit fast beliebig viel Speicher während der Laufzeit eines Programms nutzbar machen kann.


Felder (Arrays)

Ein Feld (englisch array) ist nichts anderes als eine durchnummerierte Menge von Variablen gleichen Typs. Sie können von jedem elementaren und von jedem selbst definierten Datentyp Felder bilden. Dazu geben Sie bei der Deklaration außer Typ und Namen lediglich die Anzahl der Elemente an. Für ein Feld von Ganzzahlen mit 10 Elementen etwa schreiben Sie:

  int a[10];

Diese Anweisung bedeutet für den Compiler, nicht nur für eine int-Variable Speicher zu reservieren, sondern für 10, also insgesamt 40 Bytes - auf einer Intel-32-Bit-Architektur. (Sie können den Speicherverbrauch eines Datentyps oder einer Variablen übrigens auch selbst überprüfen, nämlich mit der Funktion sizeof(). In unserem Beispiel liefert sizeof(int) den Wert 4 und sizeof(a) die Zahl 40.)

Figure: Ein Feld ist eine Menge gleichartiger Variablen hintereinander.



\includegraphics{images/array.eps}



Nun können Sie auf das Feld zugreifen, das heißt seine Elemente mit Werten belegen und später wieder auslesen. Dazu gibt es den Indexoperator, der aus einem Paar eckiger Klammern [] besteht. In diesen geben Sie den Index des Elements an, das Sie bearbeiten möchten, zum Beispiel:

  a[0] = 3;

  a[1] = 5;

  // ...

  a[9] = 13;

Damit sind wir schon bei der größten Fehlerquelle im Zusammenhang mit Arrays:

Die Indizierung eines Feldes mit $n$ Elementen läuft grundsätzlich von 0 bis $n-1$. Allerdings verhindern weder Compiler noch Laufzeitumgebung, dass Sie auch auf Speicherstellen mit höheren Indizes zugreifen, also etwa in a[10] einen Wert schreiben. Achten Sie also immer darauf, dass Ihre Indizes den zulässigen Bereich $\{0, \ldots, n-1\}$ nicht verlassen.

Was geschieht eigentlich, wenn Sie über die Größe Ihres Feldes hinausschreiben? Das Programm legt zumeist seine Variablen hintereinander an, nach Möglichkeit ohne Lücken. Wenn Sie also beispielsweise nur zehn Elemente reserviert haben und auf ein elftes zugreifen, ändern Sie damit den Wert einer anderen Variablen. Im schlimmsten Fall stehen dort aber Variablen eines anderen Prozesses oder gar Programmanweisungen. Das heißt, Sie ändern den Speicherinhalt an einer kaum vorhersagbaren Stelle. Entsprechend unvorhersehbar sind die Folgen. Meist führen solche Fehler leider nicht sofort zu Abstürzen, sondern erst einige Zeit später, an einer Stelle mit völlig korrektem Code, der eben auf den zerstörten Speicherbereich zugreifen will. Ihr Programm endet dann abrupt mit der Meldung: Segmentation fault. Bei einem solchen Fehler sollten Sie daher immer zuerst an unerlaubte Speicherzugriffe denken.

Ich habe Sie schon bei einfachen Variablen gewarnt, dass diese nach einer Deklaration völlig undefinierte Werte haben können und Sie daher stets so früh wie möglich für eine Initialisierung sorgen sollten. Bei Feldern vervielfacht sich Ihr Problem lediglich. Aber auch hier können Sie gleichzeitig mit der Deklaration das Feld initialisieren. Dabei geben Sie die gewünschten Inhalte als Liste in geschweiften Klammern {} an, getrennt durch Kommas.

  int x[3] = {3, 7, 11};

Wenn Sie jetzt besonders ökonomisch denken, werden Sie sagen: Damit gebe ich doch die Anzahl der Elemente zweimal an, einmal als explizite Größenangabe und einmal implizit durch die Zahl der Initialisierungswerte! In der Tat müssen Sie nämlich für alle deklarierten Elemente auch einen Wert in der Initialisierungsliste eintragen; also könnte man doch diese Anzahl gleich als Größenangabe verwenden. Der Compiler unterstützt solche Überlegungen sehr wohl:

  int x[] = {3, 7, 11};

Ich möchte Ihnen aber empfehlen, derartige Konstrukte nur sehr selten einzusetzen. Ich finde es wesentlich übersichtlicher, wenn man gleich mit einem Blick auf die Deklaration sieht, wie viele Elemente ein Feld hat, das heißt, bis zu welchem Index man zugreifen darf - und nicht erst nachzählen muss. Bei drei Einträgen ist das sicher harmlos, bei circa acht und mehr aber eine zusätzliche Fallgrube. Da es davon ohnehin genug gibt, müssen wir nicht noch selbst welche graben.

Felder können auch mehrere Dimensionen haben. Dazu fügen Sie einfach weitere Größenangaben in eckigen Klammern an die Deklaration an. Eine $3\times 4$-Matrix etwa können Sie deklarieren als:

  double matrix[3][4];

Auch beim Zugriff auf die Elemente gilt: pro Dimension ein Index. Wenn Sie diese Matrix beispielsweise mit null initialisieren wollen, brauchen Sie dazu folgende Schleifen:

  for(int i=0; i<3; i++)

    for(int j=0; j<4; j++)

      matrix[i][j] = 0.0;

Felder nehmen in C++ unter den Top 10 der Quellen der verheerendsten Fehler einen der vordersten Plätze ein. Sie sind ein Relikt aus C, wo man sehr viel Wert auf eine möglichst systemnahe Programmierung legte. In C++ besteht aber eigentlich keine Notwendigkeit dafür, Felder zu verwenden, da die C++-Standardbibliothek alle Arten von Containern, also Listen, Vektoren, Stapel und so weiter, in effizienter und robuster Form bereitstellt. Ich möchte Ihnen daher empfehlen, in Ihren Programmen nach Möglichkeit auf Felder zu verzichten und ausschließlich die Klassen der Standardbibliothek zu verwenden. Mehr dazu erfahren Sie ab Seite [*].

Ein weiterer Nachteil von Feldern ist, dass Sie bereits im Code die genaue Zahl der Elemente angeben müssen. Der Wert muss dabei auf alle Fälle eine Konstante sein, die während des Kompilierens bestimmbar ist. Es ist also nicht möglich, in dieser Form ein Feld zu definieren, dessen Größe Sie erst während der Laufzeit des Programms ermitteln, zum Beispiel

  unsigned int n=5;

  int a[n];  // compiler error

Wie Sie sicher bald merken werden, ist das eine erhebliche Einschränkung. Will man in einem Programm für alle Eventualitäten gewappnet sein, muss man auch mit unerwartet großen Datenfeldern umgehen können. Auch in diesem Punkt bieten Ihnen die Container der C++-Standardbibliothek nur Vorteile.

Hintergrund

Die letzte Aussage muss ich auch gleich wieder relativieren: Nach dem Standard von C und C++ muss die Feldgröße eine Compile-Zeit-Konstante sein. Der GCC verfügt jedoch schon länger über eine Compiler-Erweiterung, die diese Regel außer Kraft setzt. Deshalb können Sie mit ihm auch Funktionen wie die folgende übersetzen:

void f(int n)

{  

  int a[n];

  

  // Tue etwas mit a

}  

Ich kann Sie aber nur warnen, eine solche Konstruktion zu nutzen. Denn einer der bedeutenden Vorteile von C und C++ ist die Portabilität, die nur durch die Standardisierung erreicht wurde. Wenn Sie auf Compiler-Erweiterungen vertrauen, bedeutet das auch, dass Ihr Code von kaum einem anderen Compiler noch übersetzbar ist. Außerdem kann diese Erweiterung in künftigen Versionen eventuell wegfallen. Betrachten Sie also lieber stets die Feldgröße als eine feste Konstante, dann bleiben Sie auf der sicheren Seite.


Zeichenketten

Wenn wir bislang Zeichenketten für Namen oder Beschriftungen brauchten, haben wir immer Objekte der Klasse string der C++-Standardbibliothek verwendet (Genaueres ab Seite [*]). Das ist eine robuste und sichere Vorgehensweise; allerdings gehört diese Klasse erst seit dem ANSI/ISO-Standard von 1998 verbindlich zu C++. Der traditionelle Weg, Zeichenketten zu speichern, ist der von C übernommene: in Form von Feldern des Typs char. Eigentlich bräuchte man sich heute damit gar nicht mehr zu beschäftigen, wenn es nicht viele Systemfunktionen gäbe, die als Argumente oder Rückgabewerte gerade ein solches Zeichen-Array haben. Und da diese Funktionen sämtlich in C geschrieben sind, wird das auch noch länger so bleiben.

Die einfachste Möglichkeit, ein char-Feld zu definieren, ist diejenige mit impliziter Größenangabe. Da selten auf einzelne Elemente zuzugreifen ist, wird dieser Weg relativ häufig eingesetzt:

  char txt[] = ``Unser Text.'';

Letztlich ist eine Zeichenkette nur ein Stück Speicher, das dann als Zeichen interpretiert wird. Der Datentyp char entspricht nämlich genau einem Byte. Damit das Programm beim Interpretieren weiß, wo der String aufhört und andere Variablen anfangen, haben die Erfinder von C die 0 definiert (so genannte Nullterminierung). Das bedeutet, dass

Letzterer Fall kommt zwar nicht allzu häufig vor, kann aber dann ziemlich unerwartete Ausgaben hervorrufen. Auf der anderen Seite heißt das aber auch, dass Sie den String abkürzen können, wenn Sie eines der Elemente auf 0 setzen. So wird aus obigem String txt durch

  txt[4] = 0;

nur noch Unse. Folglich ist hier die explizite Größenangabe bei der Deklaration sogar gefährlich, weil Sie dabei leicht die abschließende 0 vergessen.

Bei der Eingabe können Sie Zeichen-Arrays wie andere einfache Datentypen behandeln. Dabei ist allerdings die begrenzte Länge zu beachten:

  char eingabe[20];

  cin >> eingabe;

Gibt der Benutzer hier mehr als 19 Zeichen ein, kommt es zu einem der gefährlichen Speicherfehler, die ich oben beschrieben habe. Besser ist es, die Methode getline zu verwenden, bei der Sie die Maximalgröße der Eingabe zusätzlich übergeben. Ist die Eingabe länger, wird sie abgeschnitten.

  const int GROESSE = 100;

  char eingabe[GROESSE];

  cin.getline(eingabe, GROESSE);

(Dieser Code lässt sich im Gegensatz zu dem vom Ende des letzten Abschnitts übersetzen, da GROESSE als Konstante deklariert ist und sich daher zur Laufzeit nicht ändern kann.)

Wenn Sie auf diese Weise ein Zeichenfeld definiert haben, dürfen Sie ihm nicht als Ganzes ein anderes zuweisen.

  char txt[] = ``Was?'';

  txt = eingabe; // Fehler

Für weitere Bearbeitungsmöglichkeiten von Zeichenfeldern brauchen wir den Begriff des Zeigers, den ich im folgenden Abschnitt einführen werde. Damit können Sie dann Funktionen verwenden, um Zeichenfelder zu kopieren, ihre Länge zu messen, Zeichen darin zu suchen und so weiter. Außerdem werden Sie eine Methode kennen lernen, um Zeichen-Felder mit variabler Größe anzulegen.


Zeiger

In den klassischen C-orientierten Lehrbüchern für C++ taucht der Begriff des Zeigers meist so früh auf, dass er mehr für Verwirrung als für Klarheit sorgt. Dass wir uns durch viele wesentliche Konzepte und Sprachelemente von C++ bis hierher durcharbeiten konnten, ohne Zeiger zu benötigen, macht jedoch deutlich, dass C++ mit einer sehr sparsamen Verwendung von Zeigern auskommt. Diese Vorgehensweise möchte ich Ihnen auch ganz allgemein empfehlen - noch bevor Sie überhaupt wissen, von was da eigentlich die Rede ist.

Was ist ein Zeiger?

Dass wir bislang überhaupt keine Zeiger benötigten, stimmt auch nicht so ganz. Bei einigen Aufrufen von Systemfunktionen habe ich mich nur etwas vor dem Begriff gedrückt und von Speicherstellen oder Ähnlichem gesprochen, Sie aber gleichzeitig mit der Syntax etwas im Unklaren gelassen. Wobei die Vorstellung einer Speicherstelle dem eigentlichen Begriff aber schon recht nahe kommt.

Figure: Zeiger sind Verweise auf Speicheradressen.



\resizebox*{2.8cm}{!}{\includegraphics{images/zeiger.eps}}



Definieren wir also:

Ein Zeiger ist eine Variable, die die Speicheradresse einer anderen Variablen (beziehungsweise eines Objekts) enthält.
Sie erfahren über den Zeiger also, an welcher Stelle im Hauptspeicher sich die Variable befindet. Damit ist der Manipulation des Speichers natürlich Tür und Tor geöffnet; entsprechend groß sind die Risiken. Obgleich ein Zeiger immer einen bestimmten Typ haben muss, ist es nicht absolut zwingend, dass der Speicherbereich, auf den er zeigt, ein existierendes Objekt ist. Über den Zeiger kann der Bereich erst als solches interpretiert werden.

Syntax bei Zeigern

Man deklariert einen Zeiger auf ein Objekt vom Typ T, indem man den *-Operator hinter den Typ setzt, etwa

  int* p1;

  double* p2;

  Raumfahrzeug* pUfo;

Damit stellt der Zeiger zwar einen eigenen Typ dar, der aber von dem des referenzierten Objekts abhängt. Beachten Sie, dass man auch Zeiger auf einen Zeiger (und so weiter!) definieren kann:

  char** pp3;

Es ist übrigens auch erlaubt, den Stern nicht an den Typ zu hängen, sondern unmittelbar vor die Zeigervariable zu setzen (allerdings auf keinen Fall dahinter!):

  float *pf;

Da der Compiler beide Schreibweisen unterstützt, ist es letztlich Gewohnheits- oder Geschmackssache. Ich finde es besser, den Stern direkt an den Typ zu hängen, weil damit die Bildung des Zeigertyps deutlicher wird.

Wenn \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Sie mehrere Zeiger gleichzeitig deklarieren, gilt der Stern indessen nur für den Ersten - oder muss ausdrücklich vor jeden gesetzt werden:

  // Deklariert einen Zeiger 

  // und eine int-Variable

  int* p1, p2; 

  // Deklariert zwei Zeiger

  int *p3, *p4; 

Aufgrund dieser Problematik gewinnt die Schreibweise mit dem Stern an der Variablen wieder etwas mehr Sinn.

Die Speicheradresse eines bestehenden Objekts können Sie sich über den Adressoperator & verschaffen, beispielsweise:

  int n;

  int* p4 = &n;

 

  Raumfahrzeug Rfz;

  Raumfahrzeug* pUfo = &Rfz;

Hat man schon einen Zeiger, will man natürlich auch den Inhalt der Speicherstelle, auf die er zeigt, ermitteln und verändern. Dazu wird abermals der Stern verwendet, diesmal als Dereferenzierungsoperator.

  int* pi = &n;

  if (*pi < 10)

    *pi = 10;

Einen solchen Zugriff nennt man auch Indirektion. In diesem Beispiel haben wir den Wert der Variablen n verändert, indem wir den Inhalt ihrer Speicherstelle modifizierten. In diesem Sinne ähneln Zeiger sehr Referenzen; der Unterschied ist, dass bei Referenzen der Compiler bereits für Bestimmung der Adresse und Indirektion sorgt.

Bei Zeigern auf Objekte müssen Sie beachten, dass der .-Operator für den Zugriff auf einzelne Elemente Vorrang vor dem Dereferenzierungsoperator hat. Wenn Sie also ein Element verändern möchten, müssen Sie die Indirektion klammern.

  Raumfahrzeug* pUfo = &Rfz;

  (*pUfo).hoehe = 3890.5;

Das ist nicht nur unpraktisch, sondern auch unübersichtlich und fehleranfällig. Daher sollten Sie zur Dereferenzierung von Objektelementen ausschließlich den Operator -> einsetzen. Äquivalent zu obiger Anweisung ist nämlich:

  pUfo->hoehe = 3890.5;

Damit sind auch Methodenaufrufe leichter zu schreiben und zu lesen:

  bool res = pUfo->setGeschwindigkeit(4000);

Zeiger und Arrays

Eine Feldvariable wird in C++ als ein Zeiger auf das erste Feldelement betrachtet (wobei sich der Compiler nur noch für die Initialisierung die Größe merkt). Damit können Sie ein Feld problemlos einem Zeiger zuweisen:

  int a[6] = {3, 5, 7, 11, 13, 17};

  int* a_ptr = a;

Entsprechend können Sie statt über den Indexoperator ein Feld auch wie einen Zeiger dereferenzieren - und umgekehrt. Dabei ist a[0] äquivalent zu *a, a[1] äquivalent zu *(a+1), a[2] äquivalent zu *(a+2) und so weiter. Da dies auch die Sichtweise des Compilers auf ein Array widerspiegelt, wird Ihnen nun vermutlich etwas klarer, warum eine Überprüfung auf Überschreitung der Feldgrenzen nicht stattfindet.

Nullzeiger

Zeiger sollten ebenso wie alle anderen Variablen unmittelbar bei oder nach der Deklaration initialisiert werden. Ein spezieller Zeigerwert, der für diesen Zweck genutzt werden kann, ist . Ein mit belegter Zeiger zeigt definitiv auf nichts.

Ebenso sollten Sie einen Zeiger, den Sie momentan nicht benötigen, weil er auf ein noch nicht oder nicht mehr existierendes Objekt verweist, mit belegen.

  int* iptr = 0;

Bevor Sie auf einen Zeiger zugreifen, sollten Sie sicherstellen, dass er auf ein existierendes Objekt zeigt.

  if (iptr)

    *iptr = 7;

Wenn Sie das nämlich nicht tun und der Zeiger steht auf 0, stürzt Ihr Programm sofort mit einem Segmentation fault ab. Dieser Laufzeitfehler wird unter anderem immer dann erzeugt, wenn Sie einen Nullzeiger dereferenzieren wollen.

In C hat man ein Makro namens NULL verwendet, um den Nullzeiger darzustellen. Das ist in C++ nicht mehr nötig. Wenn Sie dafür einfach die Zahl benutzen, sind Sie immer auf der sicheren Seite und bekommen zudem mit der Typprüfung weniger Ärger.

Hintergrund

Sie können Zeiger nicht nur statisch verwenden, sondern mit diesen auch rechnen. Dabei spielt der Typ eine sehr große Rolle: Wenn Sie einen Zeiger (um eins) inkrementieren oder dekrementieren, zeigt er anschließend nicht auf das nächste Byte im Speicher, sondern auf die Adresse der nächsten Variablen desselben Typs. Was heißt das genau? Der Zeiger wird um so viele Byte verändert, wie ein Objekt des Basistyps beansprucht. (Und das kann für ein und denselben Typ sogar von der Architektur des Rechners abhängen.) Versuchen Sie es mit folgendem Beispiel:

  double d[10];

  double *dp1 = d;

  double *dp2 = dp1 + 1 ;

 

  cout << "dp2 - dp1: " << dp2 - dp1 << endl;

  cout << "int(dp2) - int(dp1): " 

       << int(dp2) - int(dp1) << endl;

Welche Ausgabe würden Sie erwarten? Das Programm druckt für die erste Zeile 1 und für die zweite 8. Wenn Sie anschließend den Zeiger dp2 weiter erhöhen, etwa

  dp2 += 3;

und dieselben Druckanweisungen anfügen, erhalten Sie 4 und 32.

Wenn Sie direkt auf einzelne Bytes zugreifen müssen, sollten Sie als Zeigertyp char* wählen. Dieser ist auf die Größe 1 Byte festgelegt, so dass Sie damit wirklich Byte für Byte erfassen. Sie sehen daran schon, dass Zeigerarithmetik zur systemnahen Programmierung gehört und als solches eine Spezialität der Programmiersprache C darstellt. Unser C++ hat dessen Fähigkeiten geerbt, was aber noch nicht heißen soll, dass dieser Stil für Sie die Regel werden sollte.

Ein beliebtes Beispiel für eine Anwendung der Zeigerarithmetik ist das Umkopieren eines Zeichenfeldes.

  char quelle[] = ``Hier sind die Daten zuhause.''; 

  char ziel[50];

        

  char* p= quelle, *q= ziel;

  while (*q++ = *p++);

So elegant das für manche auch aussehen mag: ich finde hier gleich mehrere Punkte, die man besser nicht so machen sollte. Sie auch? Besonders raffinierte C-Hacker packen die beiden letzten Zeilen gleich in eine:

  for(char* p= quelle, *q= ziel; *q++ = *p++; );

Wie lange brauchen Sie, um zu verstehen, was hier passiert? Für mich ist es einfach schlechter Stil, wenn ein Programmcode die Problemlösung, die mit ihm eigentlich erreicht werden soll, eher verschleiert als verdeutlicht. Wenn Sie jetzt versuchen, obigen Codeteil so umzuschreiben, dass nur die Ausgangsvariablen quelle und ziel als Arrays verwendet werden und eine while-Schleife beziehungsweise eine for-Schleife zur Steuerung eingesetzt wird, werden Sie merken, wie man das Ganze auch übersichtlich und auf den ersten Blick verständlich programmieren kann.

Dynamische Speicherverwaltung

Wenn Sie ein Programm starten, wird es in den Speicher geladen. Es belegt aber gleich von Anfang an noch zusätzlichen Hauptspeicher. Neben dem Code werden zwei weitere Bereiche reserviert:

Die Größe des Stack wird beim Programmstart festgelegt und ist anschließend nicht mehr zu ändern. Wenn Sie viele Variablen oder sehr große Felder anlegen, kann es da schon eng werden. Aber eigentlich ist der Hauptspeicher bei den heutigen Rechnern im Allgemeinen sehr viel größer und meist nicht komplett belegt. Wenn der Stack schon nicht mehr hergibt, wie kommen wir sonst an den Rest des Hauptspeichers heran?

Aus Sicht des Programms bezeichnet man den gesamten restlichen freien Speicher der Maschine als Heap. Auf ihm können Sie Objekte und Felder dynamisch anlegen (also während der Laufzeit), wobei Sie nur die physikalische Speichergröße beschränkt.

Halten wir also fest: Der Stack ist der Teil des Arbeitsspeichers, der beim Start des Programms dafür frei gehalten wird und der alle lokalen Variablen sowie die Funktionsparameter enthält. Der Heap umfasst das gesamte restliche freie RAM und enthält die dynamisch angelegten Objekte und Felder. Die Vor- und Nachteile sind dabei:

       
Stack Heap    
 

+ schnelle Reservierung

+ sehr große Speichermenge zur Verfügung

- feste Größe

- Verwaltung schafft etwas Overhead
  - Der Programmierer muss den benötigten Speicher selbst reservieren und freigeben ($\Rightarrow$ Fehlerquelle!)

 

 


Das Anlegen von Objekten auf dem Heap hat aber auch noch einen weiteren Vorteil: Sie können Objekte über den Gültigkeitsbereich einer Funktion oder Klasse hinaus weiterleben lassen. Da Sie die dynamisch erzeugten Objekte selbst wieder freigeben müssen, heißt das natürlich auch, dass diese so lange existieren, bis sie ausdrücklich freigegeben werden - eben auch über das Ende einer Funktion hinaus.

Wann sollte man also was nehmen? Immer, wenn Sie beim Schreiben des Programms genau wissen, wie groß ein Feld sein wird, können Sie dieses statisch reservieren - sofern nicht allzu viele davon bei Ihnen angelegt werden müssen. In den meisten Fällen ist es aber leider so, dass die Größe im Voraus nicht genau bestimmbar ist. Dann bleibt Ihnen nichts anderes übrig, als Ihr Array auf dem Heap anzulegen. Dasselbe gilt, wenn es sich um sehr viele Elemente handelt. Es ist jedoch sinnvoll, den Umgang mit Feldern in Klassen zu kapseln, damit nicht jeder Programmabschnitt mit dynamischem Speicher hantieren muss, sondern das von den Methoden der entsprechenden Klasse komplett erledigt wird.

Dynamisches Reservieren von Speicher mit new

Um Speicher auf dem Heap zu reservieren (zu allozieren, wie man sagt), gibt es den Operator new. Dieser belegt genau so viel Speicher, wie das Objekt tatsächlich benötigt. Die Syntax lautet allgemein:

Typ* t = new Typ;

Wollen wir also ein Objekt Raumfahrzeug anlegen und damit arbeiten, können wir schreiben:

  Raumfahrzeug* r = 0;   //Zeiger initialisieren

  r = new Raumfahrzeug;  //Speicher reservieren

  //Objekt verwenden

  r->setGeschwindigkeit(20000);

Dieses Beispiel zeigt auch, weshalb ich erst an dieser Stelle auf dynamische Objekte zu sprechen komme: Der Zugriff erfolgt nämlich vorwiegend über Zeiger. Was ist dabei mit vorwiegend gemeint? Zum Anlegen (und späteren Löschen) sowie zum Zugriff auf Attribute und Methoden braucht man einen Zeiger auf das Objekt. Allerdings können Sie den Zeiger auch an eine Funktion übergeben, die eine Referenz dieses Objekts erwartet. Dazu müssen Sie ihn natürlich bei der Übergabe dereferenzieren. Die Funktion kann dann mit der Referenz wie gewohnt arbeiten; sie merkt also nicht, dass es sich eigentlich um ein dynamisch verwaltetes Objekt handelt.

Sie können mit new sowohl Variablen von einfachen Datentypen als auch von selbst definierten Strukturen und Klassen anlegen. (Einzelne Variablen vom Typ int oder float dynamisch anzulegen, ist jedoch ziemlich unüblich.) Bei Objekten kommt noch eine Besonderheit hinzu: der Konstruktor. Wenn Sie Instanzen auf dem Stack anlegen, können Sie ja durch die Angabe von Argumenten einen überladenen Konstruktor aufrufen. Das ist bei new genauso möglich. Erinnern Sie sich noch an die Klasse Datum, die wir in Abschnitt [*] als Beispiel erstellt haben? Dort gab es etwa die Konstruktoren

class Datum

{

public:

  Datum();

  Datum(unsigned int _t, 

    unsigned int _m, unsigned int _j);

  // ...

};

Wenn wir Objekte dieses Typs dynamisch anlegen, können wir beispielsweise schreiben:

int main()

{

  // Standardkonstruktor

  Datum* pHeute = new Datum; 

  

  // Konstruktor mit 3 Argumenten

  Datum* pOstern = new Datum(4,4,1999);  

  // ...

}

Freigeben von dynamisch angelegten Objekten mit delete

Alle Objekte, die Sie mit new angelegt haben, müssen Sie auch selbst wieder freigeben! So simpel diese Regel klingt, so wichtig ist es doch, sie zu beherzigen. Denn einige Probleme, die Programme mit dynamischer Speicherverwaltung immer wieder haben, sind verwaiste Speicherbereiche, also allozierter Speicher, auf den keiner mehr zugreifen kann.

Der Operator zum Freigeben heißt delete. Als Argument dahinter müssen Sie einen Zeiger auf den reservierten Bereich angeben (also genau die Adresse, die Sie von new als Rückgabewert bekommen haben). Ebenso wie bei new ein Konstruktor aufgerufen wird, findet bei delete ein Aufruf des Destruktors statt.

  Raumfahrzeug* r = 0;   //Zeiger initialisieren

  r = new Raumfahrzeug;  //Speicher reservieren

  // .. Objekt verwenden

  delete r;              //Speicher freigeben

Das bedeutet auch, dass Sie darauf achten müssen, sich die Adresse so lange zu merken, bis Sie den Speicher freigeben wollen. Dynamisch angelegter Speicher kann nämlich auch dann noch alloziert sein, wenn die Variable, über die er einst angelegt und angesprochen wurde, längst nicht mehr existiert. Da also die Speicherreservierung unabhängig von allen Gültigkeitsbereichen von Blöcken, Funktionen und so weiter ist, sind Sie selbst dafür verantwortlich, die Adresse zum Zeitpunkt der Freigabe noch verfügbar zu haben.

Neuralgische Punkte bei der dynamischen Speicherverwaltung

Das Arbeiten mit dynamisch verwaltetem Speicher ist zwar in den meisten größeren Programmen unumgänglich, stellt aber auch eine Hauptfehlerquelle bei der Programmentwicklung mit C++ dar. Im Gegensatz zu einigen anderen Programmiersprachen (wie Java oder Fortran) ist C++ in diesem Punkt sogar besonders sensibel. Aus diesem Grund gibt es auch eine Reihe von kommerziell erhältlichen Werkzeugen - auch unter Linux -, deren Hauptaufgabe es ist, Fehler bei der Speicherverwaltung ausfindig zu machen (Insure++ von ParaSoft ist beispielsweise in einer Demoversion Teil der SuSE-Distribution oder unter www.parasoft.com/products/insure zu finden). Der Programmierer ist gut beraten, wenn er sich bereits beim Schreiben des Codes darüber Gedanken macht und so mögliche Fehlerquellen von vornherein ausschließt. Darüber hinaus bietet gerade C++ mit seinem Konzept der Daten- und Prozessabstraktion die Möglichkeit, dynamische Speicherverwaltung an einigen wenigen Stellen im Gesamtprogramm zu kapseln und so die Gefahr, dass dabei Fehler unterlaufen, relativ klein zu halten.

Im Folgenden will ich Ihnen einige neuralgische Punkte vorstellen, auf die Sie bei der Programmierung besonders achten sollten. Da dieses Thema allein schon ganze Bücher füllt (zum Beispiel [BOEHM 2000] und die sehr empfehlenswerten [MEYERS 1996], [MEYERS 1997]), kann hier selbstverständlich kein Anspruch auf Vollständigkeit erhoben werden.

Zunächst ist zu bedenken, dass es auch bei new selbst Probleme geben kann. Auch die heutigen RAM-Größen stoßen irgendwann an ihre Grenzen. Der ANSI/ISO-Standard sieht vor, dass der new-Operator eine so genannte Ausnahme (namens bad_alloc, siehe Seite [*]) auslösen soll. Bei der vorherigen Version (2.95) des GCC war dies bereits eingebaut, aber noch auskommentiert (etwa in der Datei stl_alloc.h). Ab Version 3.0 wird dieses Verhalten unterstützt - jedoch nur, wenn Sie die entsprechende Header-Datei über

#include <new>

einbinden. Standardmäßig zeigt der Compiler noch das aus C bekannte Verhalten: Ist nicht genügend Speicher verfügbar, gibt new den Zeigerwert 0 zurück. Sie können daher, wenn Sie Ihr Programm robust machen wollen, nach jeder Speicherreservierung die Rückgabe überprüfen.

  Raumfahrzeug* r = new Raumfahrzeug;

  if (!r) {

    cerr << ``Kein Speicher verfügbar!'' << endl;

    exit(1);

  }

Ob Sie in diesem Fall Ihre Anwendung gleich ganz beenden oder versuchen, noch ein Stück weiter zu kommen, bleibt Ihnen überlassen. Erfahrungsgemäß können Sie aber ohnehin nicht mehr viel machen, wenn kein Speicher mehr zur Verfügung steht. Da Linux zudem mit Swap-Speicher arbeitet, das heißt die physikalische RAM-Größe um einen Bereich der Festplatte erweitert, kann es vorkommen, dass bei Speicherknappheit die Swap-Aktivitäten so umfangreich werden, dass das Betriebssystem ausgelastet ist, noch bevor ein new-Operator in Ihrem Programm eine Null zurückgibt.

Weiterhin müssen Sie darauf achten, dass Sie für jeden Bereich, den Sie mit new anlegen, ein korrespondierendes delete aufrufen. Wie ich Ihnen oben schon erklärt habe, kann ein Versäumnis zu nicht mehr zugänglichen Speicherbereichen führen.

Außerdem dürfen Sie kein delete auf Speicher aufrufen, den Sie auf dem Stack, also ohne new angelegt haben.

  Datum d;

  Datum* pD = &d;

  // ...

  delete pD;  //Absturz!

Einen solchen Versuch quittiert Linux unweigerlich mit einem Segmentation fault.

Ähnliches widerfährt Ihnen, wenn Sie versuchen, ein Objekt mehrmals zu löschen. Doppelt gelöscht ist endgültig weg gilt nämlich ganz und gar nicht.

  Datum* pD = new Datum;

  // ...

  delete pD;

 

  // ...

  delete pD; //Absturz!

Es ist daher empfehlenswert, nach dem Löschen eines Objekts den Zeiger sofort auf zu setzen. Denn wenn Sie delete auf einen Nullzeiger anwenden, ist das völlig ohne Wirkung und daher unkritisch.

Schließlich möchte ich Sie nochmals auf das oben bereits diskutierte Problem hinweisen, dass Speicher, der mit new angelegt wurde, auch über das Ende des aktuellen Blocks beziehungsweise der aktuellen Funktion hinaus reserviert bleibt. Sollten Sie den Zeiger darauf verlieren, entsteht ein allozierter Speicher, auf den Sie keinerlei Zugriff mehr haben. Im Angelsächsischen nennt man das ein memory leak, ein Speicherleck.


Dynamisch erzeugte Felder

Mit new können Sie nicht nur einzelne Objekte erzeugen, sondern auch Felder (fast) beliebiger Größe. Im Unterschied zu den auf dem Stack angelegten Arrays haben Sie bei dynamisch verwalteten die Möglichkeit, die Anzahl der Objekte erst zur Laufzeit festzulegen. Sie können daher Ihre Felder exakt an das Problem anpassen, das Sie mit Ihrem Programm gerade behandeln wollen. Die Syntax ist der von gewöhnlichen Feldern ganz ähnlich:

  cout << ``Größe angeben: ``;

  int groesse = 0;

  cin >> groesse;

  int* pVector = new int[groesse];

Beachten Sie, dass Sie alle Felder, die Sie auf diese Weise angelegt haben, mit einer Variante von delete freigeben müssen, die ein Paar eckiger Klammern [] als Hinweis auf die Array-Eigenschaft enthält.

  delete[] pVector;

Leider weisen Sie weder Compiler noch Laufzeitumgebung auf den Fehler hin, wenn Sie nur das einfache delete verwenden. Umso wichtiger ist es daher, dass Sie sich selbst über den Typ Ihrer Objekte im Klaren sind. Wenn Sie nämlich nur das einfache delete auf ein Feld anwenden, geben Sie damit nur das erste Element frei und lassen die anderen alloziert - aber völlig unzugänglich!

Ebenso leicht können Sie auch mehrdimensionale Felder anlegen. Die einfache Vorgehensweise dabei ist, zunächst ein Feld von Zeigern auf die Arrays zu definieren, die eine Dimension weniger haben. Die Felder mit Dimension eins werden dann angelegt wie oben beschrieben.

Figure: Eine Matrix ist ein Zeiger auf ein Feld von Zeigern.



\resizebox*{1\columnwidth}{!}{\includegraphics{images/matrixpointer.eps}}

Für eine Matrix, also ein zweidimensionales Feld, hat das etwa folgende Form:

  int z, s;

  cout << ``Zeilen und Spalten eingeben: ``;

  cin >> z >> s;

 

  int** mat = new int*[z];

  for(int i=0; i<z; i++)

    mat[i] = new int[s];

Ist ein solches Feld einmal angelegt, können Sie darauf genauso zugreifen, als wäre es statisch erzeugt:

  mat[i][j] = 12;

Zum Freigeben müssen Sie in der umgekehrten Reihenfolge vorgehen:

  // Zeilen freigeben

  for(int i=0; i<z; i++)

    delete[] mat[i];

 

  // Feld mit Zeigern auf Zeilenanfänge freigeben

  delete[] mat;

Wir hatten oben festgestellt, dass bei Objekten mit jedem new auch der Konstruktor aufgerufen wird. Dabei ist sogar die Angabe von Argumenten zur Verzweigung zu einem überladenen Konstruktor möglich. Bei Feldern von Objekten erlaubt C++ die Verwendung allgemeiner Konstruktoren indessen nicht. Für alle Objekte des Feldes wird lediglich der Standardkonstruktor ausgeführt. Wenn Sie den Objekten zusätzliche Informationen mitgeben wollen, müssen Sie dies über eine Methode, zum Beispiel mit Namen init(), machen, die Sie im Anschluss an die Erzeugung aufrufen.


Konstruktoren und Destruktoren

Unter dem Blickwinkel der dynamischen Speicherverwaltung bekommen auch bereits besprochene Sprachelemente neue Bedeutung. Wenn eine Klasse dynamisch angelegte Speicherbereiche verwaltet, sollten Sie bei ihrer Erzeugung und Vernichtung besondere Sorgfalt walten lassen. Betrachten wir dazu eine Klasse Vektor, die einen beliebig langen Vektor von Gleitkommazahlen doppelter Genauigkeit darstellen soll. Die Deklaration lautet etwa:

class Vektor

{

private:

  unsigned int size;

  double* v;

        

public:

  Vektor();

  Vektor(unsigned int _size);

  Vektor(Vektor& _vek);

  ~Vektor();

  unsigned int getSize();

  const double& at(unsigned int _i) const;

  double& at(unsigned int _i);

};

Wie Sie sehen, besteht die Klasse im geschützten Teil aus einem unsigned int-Attribut für die Anzahl der Elemente und einem Zeiger auf das Feld. Der öffentliche Teil enthält drei Konstruktoren, einen Destruktor und zwei Zugriffsmethoden. Diese decken das gesamte Spektrum der Anwendungsfälle ab. Die konstante Variante wird verwendet, wenn reine Lesezugriffe benötigt werden; dabei kann sogar das Objekt konstant deklariert sein. Die andere Version benutzt man bei Schreibzugriffen. Hier haben Sie übrigens den einzigen Fall vor sich, wo eine Methode mit einer anderen überladen werden kann, die denselben Namen und dieselbe Signatur hat und sich nur durch die Auszeichnung als konstante Methode unterscheidet. Wie diese Methoden zu implementieren sind, überlasse ich Ihnen (zur Übung).

Doch nun zum interessanten Teil:

Kopierkonstruktoren

Die beiden ersten Konstruktoren sind recht einfach. Der Standardkonstruktor initialisiert alle Datenelemente mit , während der Ganzzahlkonstruktor einen Vektor der angegebenen Größe anlegt.

Vektor::Vektor() : 

  size(0), v(0) {};

        

Vektor::Vektor(int _size) :

  size(_size)

  v = new double[size]; 

}

Wozu braucht man nun einen Kopierkonstruktor? Zur Beantwortung dieser Frage sollten wir uns zunächst überlegen, was denn passiert, wenn wir auf einen solchen verzichten. Der Compiler sorgt dafür, dass bei der Erzeugung eines neuen Objekts aus einem bestehenden alle Datenelemente bitweise eins zu eins kopiert werden. Für Zeiger bedeutet das, dass auch das neue Objekt auf denselben Speicherbereich zeigt wie das vorhandene. Beide Vektor-Objekte sind damit nicht mehr unabhängig voneinander verwendbar, da sie einen gemeinsamen Speicherbereich referenzieren. Schreibzugriffe auf das eine Objekt wirken sich auch auf das andere aus. Die Situation eskaliert, wenn etwa das erste Objekte vernichtet wird. Damit wird nämlich auch sein Feld freigegeben, so dass das zweite auf einen völlig undefinierten Speicherbereich zugreifen würde - mit baldigem Totalabsturz.

Um das zu vermeiden, müssen Sie bei Klassen, die mit dynamisch erzeugtem Speicher arbeiten, stets Kopierkonstruktoren selbst schreiben. In diesen können Sie dann dafür Sorge tragen, dass der Inhalt so kopiert wird, wie man es erwarten würde. In unserem Beispiel hieße das, ein neues Feld anzulegen und nur die Werte des alten dorthin zu übertragen.

Vektor::Vektor(Vektor& _vek) :

  size(_vek.size)

{

  v = new double[size];

  for(unsigned int i=0; i<size; i++)

    v[i] = _vek.v[i];

}

(In diesem Zusammenhang noch eine kleine Quizfrage: Warum dürfen wir hier auf das eigentlich geschützte Attribut v von vek zugreifen? Weil eine Klasse immer mit anderen Objekten derselben Klasse befreundet ist.)

Für Zuweisungen zwischen zwei bestehenden Objekten gelten diese Ausführungen übrigens auch. Wie man dazu den Zuweisungsoperator überlädt, erfahren Sie in einem späteren Kapitel. Schon jetzt aber sollten Sie sich als Faustregel merken: Wo immer Sie einen Kopierkonstruktor brauchen, benötigen Sie fast immer auch einen Zuweisungsoperator.


Destruktor

Jeden Speicherbereich, den Sie mit new reserviert haben, sollten Sie auch wieder freigeben. In der C-Programmierung hat dies häufig Probleme bereitet, denn bei lokal allozierten Feldern muss eine passende Freigabe bei jedem Rücksprung mit return eingebaut werden. Die Arbeit mit Objekten macht da vieles einfacher, denn ein Objekt weiß selbst, was zu tun ist, wenn es vernichtet werden soll. Sie als Programmierer können das Verhalten in dieser Situation über den Destruktor bestimmen.

In unserem Fall wollen wir natürlich, dass der reservierte Bereich freigegeben wird - vorausgesetzt, es gab überhaupt eine Reservierung. Das können wir daran erkennen, dass der Zeiger v einen anderen Wert als aufweist, mit dem wir ihn ja im Standardkonstruktor vorbelegt haben.

Vektor::~Vektor()

{

  if (v)

    delete[] v;

}

Damit ist erreicht, dass der vom Objekt angelegte Speicherbereich wieder freigegeben wird, wann immer das Objekt seine Gültigkeit verliert.

int lies_vektor()

{

  int groesse;

  ifstream eingdatei(``eingabe.dat'');

  eingdatei >> groesse;

  Vektor v(groesse); // Vektor anlegen

  for(int i=0; i<groesse; i++)

  {

    eingdatei >> v.at(i);

    if (eingdatei.eof())

    {

      cerr << ``Unerwartetes Dateiende!'' << endl;

      return -1;

    } // Hier wird v.~Vektor() aufgerufen!

  }

  // ...

}

Sie sehen an diesem Beispiel, dass durch die Freigabe im Destruktor auch bei unerwarteten Rücksprüngen ein ordnungsgemäßes Zerstören des Objekts v gewährleistet ist.


Beispiel: CGI-Programmierung

Wer sich heute mit der Entwicklung von System- oder Anwendungssoftware beschäftigt, kennt immer auch zumindest die Grundlagen der Gestaltung von HTML-Seiten für Webserver. Überhaupt ist die Web-Programmierung einer der am schnellsten wachsenden Bereiche in der IT-Entwicklung. Der Siegeszug des Internets ist dabei allgegenwärtig.

Wenn Sie sich schon intensiver mit der Erstellung von Webseiten beschäftigt haben, werden Sie schnell auf ein grundlegendes Defizit von HTML gestoßen sein: Informationen auf HTML-Seiten sind von Haus aus statisch; sie werden beim Benutzer nur präsentiert, ohne dass dieser irgendwelche Einflussmöglichkeiten darauf hätte. Oftmals wollen Sie als Web-Autor aber gerade, dass der Betrachter mit Ihrem Server interagieren kann, etwa um seine Meinung zu hinterlassen, eine Anfrage oder Bestellung aufzugeben oder nur um nach einem Stichwort zu suchen. In diesem Abschnitt wollen wir uns folglich mit der Frage beschäftigen, wie wir das Senden von Informationen vom (Web-)Client zum Server realisieren können und wie der Server dynamisch auf die Anfragen reagieren kann.

Um diesem Problem zu begegnen, hat man schon sehr früh eine Programmierschnittstelle geschaffen, das Common Gateway Interface, kurz CGI. Darüber erstellte Programme laufen auf dem Server und sorgen für die Interaktion mit dem Benutzer, das heißt, sie verarbeiten die von ihm eingegebenen Informationen und senden ein für ihn speziell erzeugtes Antwortdokument zurück (Abbildung [*]). Über diese Schnittstelle können Sie im Grunde alle Arten von Programmen an das Web anbinden, die nur über eine einfache Eingabe verfügen und die in relativ kurzer Zeit ein für den Benutzer interessantes Resultat hervorbringen. Denn als Web-Autor müssen Sie sich verschiedenen Herausforderungen in Bezug auf interaktive Webseiten stellen:

Noch vor wenigen Jahren war es üblich, CGI-Programme als Skripten in der Programmiersprache Perl zu erstellen. Für viele kleinere Aufgaben (wie Registrierung eines Benutzers) ist das sicher eine probate Methode. Als das Internet ein immer größeres Publikum anzog, schufen die Serverhersteller Netscape und Microsoft mit NSAPI beziehungsweise ISAPI proprietäre Schnittstellen, die dem Web-Programmierer zusätzliche Möglichkeiten bieten. Mittlerweile sind noch andere Technologien wie Java Server Pages (JSP) oder PHP hinzu gekommen.

Figure: Ein CGI-Programm verarbeitet Benutzereingaben und schickt individuelle Antwortdokumente.



\resizebox*{10.5cm}{!}{\includegraphics{images/cgi_connection.eps}}

Dabei haben jedoch die CGI-Interfaces nach meiner Meinung keineswegs ausgedient. Nicht jeder betreut ja eine Website, auf der er mit einer Million Zugriffen und mehr am Tag rechnen muss. Gerade für Web-Autoren, die die Betreuung in ihrer Freizeit erledigen, ist CGI aufgrund seiner Einfachheit immer noch aktuell. Und auch wenn Perl dafür weit verbreitet ist, sollten wir bedenken, dass es nach wie vor möglich ist, ein ausführbares Programm über CGI aufzurufen - das beispielsweise in C++ geschrieben wurde. Ein solches wollen wir gleich erstellen.

Der Apache-Webserver

Der weltweit meistverbreitete Webserver ist Apache. Über 60% der Server im Internet setzen ihn ein. Auch er ist freie Software und im Quelltext erhältlich (über www.apache.org). Neben vielen anderen Plattformen ist er natürlich auch unter Linux verfügbar und dort in den meisten Distributionen enthalten. Möglicherweise wurde er aber nicht standardmäßig bei Ihnen installiert, so dass Sie ihn erst von der CD auf Ihr System kopieren müssen.

Die Dateien sind bei Apache standardmäßig wie folgt organisiert:

Mehr kann ich in diesem Rahmen leider nicht ausführen. Für tiefer gehende Informationen werfen Sie am besten einen Blick in die Apache-Dokumentation oder in eines der vielen Online-Tutorials im Internet.

Formulare

Das Mittel, um dem Benutzer Eingaben auf HTML-Seiten zu erlauben, ist das <FORM>-Tag. Mit dessen Hilfe kann der Browser Steuerelemente wie Eingabefelder oder Listenfelder darstellen. Auch dazu kann ich Ihnen hier leider nur ein paar Stichworte erklären. Grundkenntnisse in HTML kann ich bei Ihnen ja sicher voraussetzen.

Das Element <FORM> umschließt die Angabe der Steuerelemente. Es trägt selbst zwei Attribute (ein drittes optionales lasse ich hier weg):

Für Eingabeelemente gibt es eine Reihe von Tags. Neben <SELECT> für Listenfelder und <TEXTAREA> für mehrzeilige Textfelder verwendet man vor allem das <INPUT>-Element. Seine Funktionalität und sein Erscheinungsbild können Sie über das Attribut TYPE festlegen, wobei TEXT der Vorgabewert ist und einem Eingabefeld entspricht; es sind aber auch CHECKBOX für Kontrollkästchen oder RADIO für Radioknöpfe möglich. Die Schaltflächen zum Abschicken der Eingaben beschreiben Sie mit INPUT; der zugehörige Typ heißt SUBMIT. Im Attribut VALUE, das sonst für Vorgabewerte dient, steht dann der Text der Schaltfläche.

Sehen wir uns ein Beispiel an:

<H1>&Uuml;bertragung von Daten mit 

Hilfe der POST-Methode</H1>

<HR>

<FORM METHOD="POST" ACTION="/cgi-bin/webtest">

<H2>Bitte geben Sie die Daten Ihres 

Rechners ein:<H2>

<P>

<P><TT>

Name:                  <INPUT NAME="Name" 

VALUE="">

Taktfrequenz:          <INPUT NAME="Frequenz" 

VALUE="300">

Linux-Kernel-Version:  <INPUT NAME="Kernel" 

VALUE="2.4.2">

</PRE>

<P>

Um die Daten zu &uuml;bertragen, bitte 

hier klicken:

<INPUT TYPE="submit" VALUE="Abschicken">

</FORM>         

Das Ergebnis dieses HTML-Codes sehen Sie in Abbildung [*]. Mehr zur Syntax von HTML und seinen Formularen erfahren Sie beispielsweise bei Stefan Münz unter www.teamone.de/selfhtml.

Figure: Über Formulare wie dieses kann der Benutzer Informationen an den Server senden.



\resizebox*{1\columnwidth}{!}{\includegraphics{images/htmlform.ps}}

Wir wollen uns nun daran machen, diese Daten auszuwerten.

Informationsübertragung an den Server

Wenn der Benutzer auf die Schaltfläche ABSCHICKEN klickt, verpackt der Browser die auf dem Formular eingegebenen Informationen und sendet sie wie üblich mit Hilfe des HTTP-Protokolls an den Server. Mit dem Verpacken sind einige Arbeitsschritte verbunden:

So wird aus den Eingaben der verschiedenen Felder eine zusammenhängende Zeichenkette. Für das Beispiel aus Abbildung [*] ist dies

Name=Sittich&Frequenz=300&Kernel=2.2.10

Wir haben oben ja bereits festgestellt, dass die Art, in der das Serverprogramm die Eingabe bekommt, vom Wert des Attributs METHOD abhängt, nämlich als Kommandozeilenoption oder in der Umgebungsvariablen QUERY_STRING bei GET oder über den Standardeingabekanal bei POST. Da die Serveranwendung sich nicht darauf verlassen sollte, dass der Autor der HTML-Datei stets nur ein und dieselbe Methode verwendet, wollen wir Reaktionsmechanismen für beide Arten implementieren.

Diese Umgebungsvariable ist übrigens nicht die einzige, die der Server für die CGI-Anwendung bereitstellt. Es sind sogar so viele, dass ich sie hier gar nicht alle aufzählen kann und deshalb nur ein paar wenige erwähne:

Die Serveranwendung

Was soll nun unsere CGI-Anwendung genau tun? Versuchen Sie vor dem Weiterlesen diese Frage selbst zu beantworten.

Das Programm, das ich Ihnen gleich vorstelle, geht in folgenden Schritten vor:

  1. Bestimme die Art der Anfrage, das heißt GET oder POST.
  2. Zerlege die übergebene Zeichenkette in Paare aus Schlüssel und Wert und speichere diese in einer dynamisch aufgebauten Liste (daher also dieses Thema in diesem Kapitel ...)
  3. Erzeuge eine Antwortseite, die die gesendeten Informationen auflistet.
Als Antwort erwartet der Benutzer nämlich eine Webseite, die ihm den Vollzug der gewünschten Aktion meldet. Deren Übertragung ist sehr einfach: Das CGI-Programm muss nämlich nur den HTML-Code auf die Standardausgabe schreiben, dann sorgt der Server dafür, dass dieser an den Anfragenden geschickt wird. Obwohl das Ergebnis meistens in HTML verfasst sein wird, können Sie auch unformatierten Text senden. Sie müssen nur zu Beginn Ihrer Ausgabe deren Typ spezifieren, was durch Content-type gefolgt vom eigentlichen Typ und einer Leerzeile geschieht. Für HTML, das ich auch im Beispiel verwende, sieht das folgendermaßen aus:

cout << "Content-type: text/html" <<endl<<endl;

Für einfachen Text (ähnlich wie eine Ausgabe im Shell-Fenster) ersetzen Sie in der vorangegangenen Zeile html durch plain.

Unsere Anwendung hat damit eine recht kurze main()-Funktion:

int main()

{

  Liste liste;

  

  if (analysiereAnfrage(liste) == false)

    antwortFehler();

  else

    antworte(liste);

 

  return 0;

}

Sehen wir uns also die weiteren Funktionen an.


Bestimmung der Anfragemethode

Die Art der Anfrage ist in der Shell-Variable REQUEST_METHOD enthalten. Den Inhalt von Umgebungsvariablen können Sie sehr leicht mit Hilfe der Funktion getenv() bestimmen. Diese erwartet den Namen der Variablen in Form eines Zeigers auf ein char-Feld und gibt deren Inhalt in derselben Form zurück. In unserem Programm heißt das etwa

bool analysiereAnfrage(Liste& _liste)

{

  // Bestimme die Anforderungsart

  string request_method =

    getenv("REQUEST_METHOD");

Pufferung der Eingabe

Anschließend müssen wir für beide Methoden jeweils einen Puffer aufbauen, der die Eingabe aufnimmt.

  // Puffer fuer uebergebene Daten

  char* buffer = 0;

  unsigned int len;

  

  // Behandle eine POST-Anforderung

  if (request_method == "POST")

  {

    len = atoi(getenv("CONTENT_LENGTH"));

    buffer = new char[len+1];

    for(unsigned int i=0; i<len; i++)

      cin.get(buffer[i]);

  }

  

  // Behandle eine GET-Anforderung

  if (request_method == "GET")

  {

    len = strlen(getenv("QUERY_STRING"));

    buffer = new char[len+1];

    strcpy(buffer,getenv("QUERY_STRING"));

  }

  

  // Null-Zeichen zur Terminierung

  buffer[len] = 0;    

 

  // Kopiere Puffer in String

  string eingabe = buffer;  

  delete[] buffer;

Zerlegung der übergebenen Zeichenkette

Nun haben wir die Eingabe in Form einer langen, zusammenhängenden Zeichenkette in der Variablen eingabe vor uns. Jetzt stehen wir vor der Aufgabe, diese in Paare aus Schlüsseln und Werten zu trennen. Dazu bedienen wir uns einiger Methoden der Klasse string, nämlich find(), um einen Teilstring ab einer gegebenen Position zu finden, length() zur Bestimmung der Gesamtlänge und substr($p$, $n$), um einen Teilstring ab Position $p$ mit $n$ Zeichen herauszuziehen. Damit können wir folgendermaßen vorgehen:

  // Lokale Variablen zur Teilstringsuche

  size_t pos = 0;

  size_t old_pos = 0;

  

  // Lies Schluessel/Wert-Paare

  while(pos < len)

  {

    pos = eingabe.find("&", old_pos);

    // Einziges oder letztes Paar

    if (pos == string::npos)

      pos = eingabe.length();

      

    // Zerlege das Paar

    string paar = eingabe.substr(old_pos, 

                                 pos-old_pos);

    size_t eq_pos = paar.find("=");

 

    string schluessel = paar.substr(0, eq_pos);

    konvertiereLeerzeichen(schluessel);

 

    string wert = paar.substr(eq_pos+1);

    konvertiereLeerzeichen(wert);

    

    // Fuege Paar in Liste ein

    _liste.push_back(schluessel, wert);

    old_pos = pos+1;

  }

Zur Liste und deren Implementierung kommen wir gleich noch.


Erzeugung einer Antwortseite

Eine entsprechende Antwort lässt sich in unserem Fall leicht erzeugen, da wir dort lediglich die übergebenen Werte auflisten wollen. Wir müssen aber darauf achten, eine korrekte und vollständige HTML-Seite auszugeben, da sonst der Browser sie nicht darstellen kann.

void antworte(Liste& _liste)

{

  cout << "Content-type: text/html" << endl;

  cout << endl;

  cout << "<HTML>" << endl;

  cout << "<HEAD><TITLE>Eingabe verstanden";

  cout << "</TITLE></HEAD><BODY>" << endl;

  cout << "<H2>Sie hatten folgende Angaben"

       << " gemacht: </H2>"<<endl;

  cout << "<UL>" << endl;

 

  ListElement* tmp; 

  for(tmp = _liste.front(); tmp != 0; 

      tmp = tmp->naechstes)

  {

    cout << "<LI>" << tmp->schluessel << ": ";

    cout << "<EM>" << tmp->wert 

         << "</EM></LI>" << endl;

  }

  cout << "</UL>" << endl;

  cout << "</BODY></HTML>" << endl;

}

Das Resultat sehen Sie in Abbildung [*]. Außer der Schleife dürften Ihnen die Befehle keine Probleme bereiten; zu dieser komme ich gleich noch.

Figure: Als Antwort erzeugen wir eine Liste der übergebenen Parameter.



\includegraphics{images/htmlantwort.ps}

Die verkettete Liste

Was jetzt noch fehlt, ist die Definition der Listenklasse. Diese führt uns vom Internet zurück zum zentralen Thema dieses Abschnitts, der dynamischen Datenverwaltung. Die Liste soll nämlich nur genauso viel Speicherplatz in Anspruch nehmen, wie sie Elemente enthält. Von diesem Typ gibt es noch einige weitere; bekannt sind etwa

Figure: Die Liste ist eine sehr häufig benötigte Datenstruktur.



\resizebox*{3.5cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/liste.eps}}



Leider kann ich an dieser Stelle nicht näher auf das Thema dynamische Datenstrukturen eingehen. Ich empfehle Ihnen aber, wenn Sie davon noch wenig gehört haben, sich für Ihre weitere Programmierarbeit mit diesen vertraut zu machen, zum Beispiel anhand des Standardwerks [SEDGEWICK 1992]. Auch wenn Sie die meisten Typen nicht selbst implementieren werden, sondern auf die C++-Standardbibliothek zurückgreifen, ist es doch hilfreich, die zugrunde liegenden Prinzipien zu verstehen.

Eines der einfachsten und (vermutlich deshalb) bekanntesten Beispiele für eine dynamische Datenstruktur ist die einfach verkettete Liste (siehe auch Abbildung [*]). Dabei sind die einzelnen Elemente über einen Zeiger miteinander verbunden. Jedes Element zeigt genau auf ein weiteres. Auf diese Weise können Sie sich vom Anfang bis zum Ende wie an einer Kette durchhangeln. Das Ende ist dadurch gekennzeichnet, dass der Zeiger den Wert hat.

Figure: Bei verketteten Listen enthält jedes Element einen Zeiger auf das nächste.



\resizebox*{14cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/Listenverkettung.eps}}

Diese Listenart ist ausreichend, wenn es darum geht, eine vorher nicht bestimmbare Anzahl von Elementen im Speicher festzuhalten, ohne dass große Anforderungen an den Zugriffskomfort zu erfüllen sind. Ihr Nachteil ist nämlich, dass Sie nicht beliebig auf jedes Element zugreifen können, sondern immer vom Anfang zum Ende durchlaufen müssen. Als Verbesserung kann man beispielsweise doppelt verkettete Listen erstellen, bei denen jedes Element nicht nur auf seinen Nachfolger, sondern mit einem zweiten Zeiger auch auf seinen Vorgänger verweist. Somit ist zumindest der Durchlauf in zwei Richtungen möglich (siehe auch die entsprechende Übungsaufgabe!).

Als Ausgangspunkt für die Definition der Liste dient uns die Elementstruktur. Wir werden später noch sehen, wie man Listen und andere Datenstrukturen generisch, das heißt ohne konkreten Bezug zum Typ des Inhalts, definieren kann. An dieser Stelle aber legen wir eine Struktur für unsere spezielle Aufgabe, die übergebenen Paare aus Schlüsseln und Werten zu speichern, genau fest.

struct ListElement

{

  ListElement* naechstes;

  string       schluessel;

  string       wert;

  

  // Standardkonstruktor

  ListElement() :

    naechstes(0) {}

  // Spezieller Konstruktor

  ListElement(const string& _schluessel,

    const string& _wert) :

    naechstes(0),

    schluessel(_schluessel),

    wert(_wert) 

    {}

};

Unsere Klasse Liste enthält als private Datenelemente neben der Anzahl der Elemente in der Liste je einen Zeiger auf das erste sowie auf das letzte Element. So können wir sowohl für Durchläufe auf den Anfang zugreifen als auch bequem neue Elemente am Ende einfügen.

class Liste

{

private:

  ListElement* erstes;

  ListElement* letztes;

  int anzahl;

Die Methoden bieten genau die Funktionalität, die wir von der Klasse erwarten.

public:

  Liste() :

    erstes(0), letztes(0), anzahl(0) {}

    

  virtual ~Liste();

  

  bool empty() const 

    { return (anzahl == 0); }

  

  int size() const

    { return anzahl; }

    

  void push_back(const string& schluessel,

    const string& wert);

  void pop_front();

  

  ListElement* front()

    { return erstes; }

};

Dabei ist der Konstruktor ziemlich einfach. Auch die Informationen über die Länge der Liste sind leicht zu implementieren. Die Namen der Methoden sind übrigens alle von der entsprechenden Klasse der Standardbibliothek übernommen, damit Ihnen und Ihren Programmen der Umstieg später leichter fällt.

Als Nächstes wollen wir uns ansehen, wie man Elemente an das Ende der Liste anfügt.

void Liste::push_back(const string& _schluessel,

    const string& _wert)

{

  ListElement* tmp = 

    new ListElement(_schluessel, _wert);

    

  if (letztes != 0)

    letztes->naechstes = tmp;

  else

    erstes = tmp;

    

  letztes = tmp;

  anzahl++;

}

Zunächst erzeugen wir dynamisch ein neues Objekt vom Typ ListElement, welches wir auch gleich über den speziellen Konstruktor mit den übergebenen Werten initialisieren. Wenn die Liste bereits andere Elemente enthält, binden wir das bisherige Ende an das neue Element an. Ansonsten ist dieses Element auch gleichzeitig das erste. Anschließend sorgen wir noch dafür, dass der Zeiger jetzt auf das neue Ende der Liste verweist, und erhöhen den Elementzähler um eins.

Das Gegenstück dazu, nämlich das Entfernen eines Elements vom Anfang der Liste, bietet uns die Methode pop_front().

void Liste::pop_front()

{

  if (anzahl == 0)

    return;

    

  ListElement* tmp = erstes;

  erstes = tmp->naechstes;

  if (erstes == 0)

    letztes = 0;

    

  delete tmp;

  anzahl-;

}  

Hier merken wir uns die Adresse des bislang ersten Elements, da wir unser Zeigerattribut erstes nun auf das zweite setzen. Gibt es kein solches Element, ist die Liste nunmehr leer. Jetzt können wir das Objekt löschen und die Anzahl vermindern. An diesem Beispiel sehen Sie auch, dass es für den Umgang mit dynamisch verwaltetem Speicher nicht auf die konkrete Variable ankommt, sondern nur auf den Zeiger auf die Speicherstelle. Dieser kann durchaus im Laufe des Programms von verschiedenen Zeigervariablen gehalten werden; es genügt, wenn zum Zeitpunkt der Freigabe in einer davon die Adresse vorhanden ist - und natürlich die Freigabe nur einmal erfolgt.

Mit dieser Methode ausgerüstet gerät unser Destruktor fast schon trivial. Wir müssen nämlich lediglich pop_front() so oft aufrufen, bis keine Elemente mehr vorhanden sind.

Liste::~Liste()

{

  while(anzahl != 0)

    pop_front();

}

Jetzt verstehen Sie sicher auch die Verwendung der Liste in der Funktion antworte() (auf Seite [*]) etwas besser. Wir definieren eine Zeigervariable, die auf das erste Listenelement verweist. Bei jedem Durchlauf geben wir die Inhalte aus und setzen den Zeiger auf das nachfolgende Element. Das können wir so lange machen, bis keines mehr da ist, der Zeiger also auf steht.

  ListElement* tmp; 

  for(tmp = _liste.front(); tmp != 0; 

      tmp = tmp->naechstes)

  {

    cout << "<LI>" << tmp->schluessel << ": ";

    cout << "<EM>" << tmp->wert 

         << "</EM></LI>" << endl;

  }

Versuchen Sie sich die Vorgänge dadurch klar zu machen, dass Sie dieses Codestück als while-Schleife umformulieren.

Fazit

Im Grunde ist das Programm, das wir gerade vervollständigt haben, nicht besonders kompliziert - es macht ja auch nicht sehr viel. Und doch hat es uns einige Mühen gekostet, die notwendigen Schritte zu verstehen und bereitzustellen. Wenn Sie auf dieser Grundlage eine größere Serveranwendung erstellen wollen, kann ich Ihnen nur raten, den Aufwand nicht zu unterschätzen. Das, was für den Web-Surfer ganz spielerisch aussieht, bedeutet für den Web-Autor harte Arbeit, sowohl am Design der Seiten als auch bei der Programmierung der dynamischen Inhalte.

Nichtsdestotrotz sollten Sie Ihre Serveranwendungen mit viel Sorgfalt planen. Denn sie unterscheiden sich in einiger Hinsicht von normalen interaktiven oder Hintergrundanwendungen:

Wenn Sie ohnehin schon geplant haben, interaktive Webseiten zu erstellen, werden Sie diese Hinweise vermutlich auch nicht mehr davon abhalten. Andernfalls sollten Sie sich vor Augen halten, wie viele Websites mit weniger Sorgfalt auskommen und auch nicht gleich unter Einbrüchen von Hackern leiden. Dynamische Webseiten machen erst den Unterschied zwischen Internet und Printmedien aus; nutzen Sie diese Möglichkeit, um mit den Betrachtern Ihrer Seiten in Kontakt zu treten.

Auch wenn das eigentliche Thema dieses Abschnitts, die dynamische Speicherverwaltung, unter den Details der CGI-Programmierung etwas verschwand, so sollte Ihnen doch anhand der Listenklasse klar geworden sein, welche Handgriffe zu erledigen und welche Stolperfallen zu beachten sind. Doch selbst wenn Sie alle Tipps verinnerlicht haben, werden Sie immer wieder einmal Abstürzen, falschen Speicherzugriffen und Speicherlecks begegnen. Selbst für erfahrene Programmierer birgt die dynamische Speicherverwaltung in C++ viele Fehlerquellen.

Zusammenfassung

Folgende Aspekte aus diesem Abschnitt sollten Sie im Gedächtnis behalten:

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:
  2. Welche Probleme finden Sie in folgendem Programm:

     1: const int LEN=32;

     2: typedef struct Foo 

     3: {

     4:   char *ding;

     5:   char *dong;

     6: };

     7:        

     8: char* f1(void)

     9: {

    10:   char carray[LEN];

    11:   carray[0] = 'a';

    12:   return carray; 

    13: }

    14:       

    15: char* f2(void)

    16: {

    17:   char *cp;

    18:   int i = LEN;

    19:   cp = new char[LEN];

    20:   

    21:   while (i) 

    22:     cp[i-] = '\0'; 

    23:    

    24:   return cp;

    25: }

    26:     

    27: char* f3()

    28: {

    29:   return new char[1024];

    30: }

    31:     

    32: int main(void)

    33: {

    34:   char* cp1;

    35:   char* cp2;

    36:   char* cp3;

    37:   Foo *f;

    38:     

    39:   f = new Foo;

    40:   f->ding = new char[128];

    41:   f->dong = new char[128];

    42:     

    43:   cp1 = f1();

    44:   cp1[0] = 0x01;

    45:    

    46:   cp2 = f2();

    47:   cp3 = f3();

    48:     

    49:   cp3 = NULL;

    50:   delete f;     

    51:   return 0;

    52: }

  3. Schreiben Sie ein Programm, das dem Benutzer erlaubt, die Anzahl der Zeilen sowie eine Matrix selbst einzugeben, und das anschließend die Determinante dieser Matrix berechnet. Diese ist definiert als

    \begin{displaymath}\vert A\vert := a_{11}A_{11} - a_{12}A_{12} + a_{13}A_{13} -+ \ldots a_{1n}A_{1n},\end{displaymath}

    wobei die Unterdeterminante $A_{kl}$ aus $A$ durch Streichen der Zeile $k$ und der Spalte $l$ hervorgeht. (Vielleicht kennen Sie aber auch ein effizienteres Verfahren zur Determinantenberechnung, etwa über Dreieckszerlegung.)

  4. Schreiben Sie die Klasse Liste aus Abschnitt [*] in eine doppelt verkettete Liste um. Fügen Sie dazu auch entsprechende Zugriffsmethoden hinzu und definieren Sie einen Kopierkonstruktor.


Die C-Bibliothek

Von Anfang an war ein Kennzeichen der Programmiersprache C, dass der eigentliche Sprachumfang recht begrenzt ist, dafür allerdings viele Funktionen über eine Standardbibliothek verfügbar sind. In dieser findet der Programmierer sowohl sehr systemnahe Routinen, mit denen er unmittelbaren Zugriff auf die Hardware seines Rechners erhält, als auch allgemeine Ein-/Ausgabe-, String- und mathematische Funktionen, die in fast allen Programmen benötigt werden.

Die C++-Standardbibliothek will die C-Bibliothek nicht ersetzen, genauso wie C++ nicht C vollständig ersetzen will. Die meisten der C-Funktionen sind nach wie vor verfügbar und sind als ein Teil in die C++-Standardbibliothek eingeflossen. Wir haben bisher nur wenig mit den C-Routinen gearbeitet, da sie vielfach Zeiger erfordern und häufig auch durch echte C++-Alternativen ersetzt werden können. In umfangreicheren Programmen werden Sie indessen nicht umhin kommen, einige der C-Funktionen zu verwenden. In diesem Abschnitt will ich Ihnen daher einen knappen Überblick über die wichtigsten Funktionen geben und Sie auf einige typische Anwendungsfälle hinweisen.

Umfang der C-Bibliothek

Zunächst will ich Ihnen ein Gefühl dafür vermitteln, was eigentlich alles zur C-Bibliothek gehört. Später werden wir uns dann einige Details herausgreifen.


Die Header-Dateien

Um eine externe Funktion zu verwenden, müssen Sie üblicherweise eine Header-Datei mit deren Prototypen in Ihren Code einbinden. Die Header der Standardbibliothek folgen der Konvention, dass ihre Dateinamen keine Endungen wie .h oder Ähnliches enthalten. Die C-Abkömmlinge sind dabei wieder in eigenen Header-Dateien gekapselt, deren Name derselbe ist wie in Standard-C, allerdings ohne Erweiterung und mit dem vorangestellten Buchstaben c. In diesen wird dann jeweils die eigentliche Header-Datei eingebunden. Beispielsweise verwenden Sie die Funktionen aus stdlib.h, indem Sie angeben

#include <cstdlib>

Der Unterschied zwischen der Verwendung etwa von <stdlib.h> und <cstdlib> ist beim GCC erst ab Version 3.0 spürbar. Ab da sind nämlich - gemäß des Standards - die Funktionen in den mit c beginnenden Dateien im Namensraum std:: definiert, während die in den herkömmlichen sich im globalen Namensraum befinden (siehe auch Seite [*]). Wenn Sie also eine Funktion verwenden, die in einer solchen Header-Datei deklariert ist, sollten Sie am besten in Ihrem Code sofort im Anschluss an die Liste der verwendeten Header die Anweisung

using namespace std;

stehen haben. Dann können Sie die Funktionen wie gewohnt aus Ihrem Code heraus aufrufen. Dieser Tipp ist jedoch nur für Implementierungsdateien sinnvoll, und dort auch nur, sofern keine Kollisionen mit anderen Funktionen Ihres Projekts zu befürchten sind. Wenn Sie Datentypen der Standardbibliothek in Header-Dateien verwenden, ist ein solches Kommando generell unangebracht, da Sie nie im Voraus wissen können, in welche Programme dieser Header irgendwann einmal eingebunden werden soll. In Header-Dateien ist es daher am besten, die Referenzierung der Elemente der Standardbibliothek explizit mittels eines vorangestellten std:: vorzunehmen. Näheres zu Namensräumen finden Sie ab Seite [*].

Bei älteren Versionen des GCC sind die Header zwar in den Namensraum std eingefasst, jedoch ausdrücklich abgeschaltet, das heißt, alle Funktionen sind global deklariert. Andererseits schadet das using-Kommando auch hier nicht, so dass Sie es aus Gründen der Portabilität (in diesem Fall der Aufwärtskompatibilität) am besten stets in Ihren Code aufnehmen, sobald Sie eine Bibliotheksfunktion irgendeiner Art verwenden.


Table: Über die Header der Standardbibliothek erhalten Sie Zugriff auf viele wichtige Routinen.
           
        Header Inhalt
         

assert.h

        errno.h Fehlercodes der Bibliotheksfunktionen
        float.h Numerische Limits von Gleitkommazahlen
        limits.h Ganzzahlige numerische Limits
        math.h Mathematische Standardfunktionen wie sin(), log(), pow(), sqrt() und die zugehörigen Konstanten ($\pi$, $e$ etc.)
        signal.h Behandlung von Signalen
        stdlib.h Allgemeine Utilities (Umwandlung von char-Strings in Zahlen, Zufallszahlenerzeugung, Programmbeendigung, Zugriff auf Umgebungsvariablen, Quick-Sort)
        stdio.h C-typische Ein- und Ausgabefunktionen wie printf() und scanf()
        string.h Zugriff auf und Manipulation von char-Strings, einschließlich Kopieren, Längenbestimmung und Teilstringsuche
        time.h Zugriff auf die Systemuhr für Datum und Uhrzeit

Die verschiedenen Anwendungsbereiche

Tabelle [*] gibt Ihnen einen ersten groben Überblick, welche Header-Dateien Sie für welche Anwendungsbereiche einsetzen können. Wenn Sie einen Blick in die jeweiligen Dateien werfen, werden Sie feststellen, dass die Prototypen teilweise nur ziemlich schwer lesbar sind. Das liegt an den hohen Portabilitätsanforderungen von C, durch die manche Definitionen etwas trickreich gestaltet sein müssen.

Darüber hinaus gibt es noch einige wichtige Funktionen, die nicht Teil der ISO-C-Bibliothek sind, da sie vom jeweiligen Betriebssystem abhängen und daher nicht portabel sind. Auf einer Unix-Plattform, die den POSIX-Standard erfüllt (wie Linux), finden Sie solche Routinen in unistd.h. Dort stehen unter anderem Header von Funktionen zur Bestimmung von Informationen über Rechner, Prozesse und Benutzer sowie zum Zugriff auf das Dateisystem.


Das man-Kommando

Die enge Verbindung zwischen dem Kern des Betriebssystems und der Programmiersprache C ist hinsichtlich der Dokumentation sicherlich ein Vorteil. So gibt es zu so gut wie allen Bibliotheksfunktionen Beschreibungen, die Sie mittels des man-Kommandos aufrufen können. Wenn Sie beispielsweise wissen möchten, wie die Funktion sqrt() definiert ist und in welcher Header-Datei sie sich befindet, geben Sie ein:

man sqrt

Daraufhin sollten Sie folgende Ausgabe erhalten:

SQRT(3)Linux Programmer's ManualSQRT(3)

NAME
sqrt - square root function

SYNOPSIS
#include <math.h>

double sqrt(double x);

DESCRIPTION
The  sqrt() function returns the non-nega-
tive square root of x.  It fails and  sets
errno to EDOM, if x is negative.

ERRORS
EDOM   x is negative.

CONFORMING TO
SVID 3, POSIX, BSD 4.3, ISO 9899

SEE ALSO
hypot(3)

Alle so genannten man-Seiten haben unter Unix diese (oder eine ähnliche) Struktur. Sie unterteilen sich in folgende Abschnitte, wobei nicht alle zwingend erforderlich sind:

Je nachdem, wie viele Pakete Sie installiert haben, kann es vorkommen, dass ein Stichwort mehrfach unter den man-Seiten vorhanden ist. Glücklicherweise sind diese Seiten aber noch in Abschnitte unterteilt, innerhalb derer die Begriffe wieder eindeutig sein müssen (denn was wir sehen, ist im Wesentlichen jeweils nur der Inhalt einer gleichnamigen Datei in einem der Verzeichnisse /usr/man/man1 bis /usr/man/man9 oder einem von Ihnen bestimmbaren zusätzlichen Pfad). Jeder Abschnitt deckt ein Gebiet des Umgangs mit dem System ab. Üblicherweise sind diese Bereiche wie folgt definiert:

  1. Shell-Kommandos
  2. Systemaufrufe (die der Kernel vornimmt)
  3. Befehle der C-Bibliothek
  4. Besondere Dateien aus dem /dev-Pfad
  5. Dateiformate und -konventionen
  6. Spiele
  7. Makropakete und Systemkonventionen wie Aufbau des Dateisystems
  8. Kommandos zur Systemverwaltung
  9. Kernelfunktionen
Möchten Sie eine Seite aus einem bestimmten Abschnitt, können Sie beim man-Kommando die Abschnittsnummer zwischen Befehl und Begriff angeben, etwa

man 3 time

Wenn Sie bei Querverweisen eine Zahl in Klammern hinter den Begriffen finden, ist damit ebenfalls die Nummer des Abschnitts gemeint.

Für die C-Bibliothek liefert der GCC selbstverständlich auch Info-Seiten mit. Auf Seite [*] haben Sie ja bereits gesehen, wie Sie darauf zugreifen können. Die Dokumentation zu den hier diskutierten Funktionen finden Sie unter der Rubrik Libc.


Mathematische Standardfunktionen (cmath)

Wenn Sie in Ihren Programmen numerische Berechnungen vornehmen wollen, kommen Sie mit den Grundrechenarten schnell nicht mehr aus. Jeder bessere Taschenrechner kann trigonometrische Funktionen und beliebige Potenzierungen. Auch in Ihren C++-Programmen können Sie solche Funktionen dank der C-Bibliothek nutzen.

Figure: Sie können vielfältige mathematische Funktionen nutzen.



\resizebox*{4cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/mathe.eps}}

Zunächst ein Hinweis: Im Gegensatz zu anderen Programmiersprachen verfügt C++ über keinen eigenen Operator für die Bildung von Potenzen, also um

\begin{displaymath}y=x^n=\underbrace{x\cdot x \cdot\ldots\cdot x}_{n{\rm -mal}}\end{displaymath}

auszurechnen. Sie haben dazu aber zwei Möglichkeiten:

  1. Sie verwenden die Funktion

    double pow(double x, double y);

    Diese kann auch mit reellen Exponenten umgehen, da sie $x^y= \exp(y\cdot\ln(x))$ berechnet. Damit ist bei natürlichen Exponenten nicht nur eine gewisse Ungenauigkeit, sondern auch ein teilweise unverhältnismäßiger Aufwand verbunden.

  2. Sie programmieren selbst eine Funktion, etwa in rekursiver Form:

    double my_power(double x, int n)

    {

      if (n<0) {

        if (x) return 1.0/my_power(x, -n);

        else   return 0.0;  // Fehlerfall!

      }

     

      switch(n) {

        case 0: return 1.0; 

        case 1: return x; 

        case 2: return x*x; 

        case 3: return x*x*x;

        default: return x*my_power(x, n-1);

      }

    }

    (Hier haben Sie übrigens ein Beispiel dafür, wie eine switch-Anweisung auch ohne break auskommt und trotzdem voneinander getrennte Fälle hat.)

    Diese Funktion dürfte gerade dann deutlich schneller sein, wenn in Ihrem Programm häufig kleine Exponenten auftauchen.

Weitere Funktionen in cmath sind:

double sin(double x)

double cos(double x)

double tan(double x)
die trigonometrischen Funktionen (gleichermaßen auch sinh(), asin() und asinh())

double exp(double x)
die Exponentialfunktion $e^x$ (in gleicher Gestalt auch exp10() mit Basis 10)

double log(double x)
der natürliche Logarithmus $\ln
(x)$ (dazu analog auch log10() mit Basis 10)

double sqrt(double x)
die nichtnegative Quadratwurzel von $x$

double ceil(double x)
Rundungsfunktion (aufrunden): zurückgegeben wird der kleinste ganzzahlige Wert, der nicht kleiner ist als $x$ (Beispiel: ceil(2.5) ergibt 3)

double floor(double x)
Rundungsfunktion (abrunden): gibt den größten ganzzahligen Wert zurück, der nicht größer ist als $x$ (Beispiel: floor(2.5) ergibt 2)

double fabs(double x)
Absolutbetrag $\vert x\vert$, also der Wert ohne sein Vorzeichen (Beispiel: fabs(-3.7) ergibt 3.7)

Daneben sind in dieser Header-Datei auch noch ein paar mathematische Konstanten definiert, zum Beispiel:

M_E         $\exp(1)$
M_PI         $\pi$
M_PI_2         $\pi/2$
M_1_PI         $1/\pi$
M_SQRT2         $\sqrt{2}$

 


Numerische Limits (climits und cfloat)

Wichtig an den Limits ist, dass es sich dabei um die größten beziehungsweise kleinsten Werte handelt, die mit dem jeweiligen Datentyp dargestellt werden können. Wenn Sie also ganz sicher gehen wollen, dass Sie mit einer Zuweisung keinen Überlauf hervorrufen, prüfen Sie vorher den Wert ab, zum Beispiel:

int func(long l)

{

  short s;

  if (l >= SHRT_MIN && l <= SHRT_MAX)

    s = l;

  else

    cerr << l << `` unzulässig!'';

  // ...

}

Die Limits sind deshalb in der Bibliothek als Konstanten definiert, da sie nicht überall gleich sind. Einige hängen vom Betriebssystem beziehungsweise der Prozessorarchitektur ab. Sie sollten also in Ihren Programmen nicht den Wert für Ihren Rechner eintragen, sondern lieber die Konstante verwenden. Damit bleibt Ihr Code portabel. (Wie Sie wissen, gibt es Linux ja nicht nur für Intel-Prozessoren, sondern auch für eine große Zahl anderer.)

Tabelle [*]Tab:Limits zeigt Ihnen die wichtigsten Grenzwerte. Die obere Hälfte enthält dabei die ganzzahligen Werte aus der Datei climits und die untere die Gleitkommawerte aus cfloat.


Table: Die wichtigsten Grenzwerte für die Datentypen von C++


Auswertung von Fehlern bei
Bibliotheksfunktionen (cerrno)

Bei vielen Funktionen der C-Bibliothek kann es vorkommen, dass der Aufruf fehlschlägt, weil beispielsweise einer der übergebenen Parameter einen ungültigen Wert hat und weil ein referenziertes Objekt nicht existiert. Wie soll das aber das Programm erfahren, wenn es einen Rückgabewert eines bestimmten Typs und keinen Fehlercode erwartet? Die Antwort der C-Bibliothek auf dieses Problem ist eine globale Variable namens errno. Sehr viele Bibliotheksfunktionen geben, wenn sie ihre Arbeit nicht zufrieden stellend erledigen können, selbst -1 zurück und setzen errno auf einen konstanten Wert, der der Art des Fehlers entspricht. Die symbolischen Konstanten für die Fehlercodes und die globale Variable errno sind in der Datei cerrno definiert. Eine Liste aller Konstanten mit ihren Bedeutungen erhalten Sie beispielsweise durch das Shell-Kommando

man errno

Für den Benutzer ist es aber recht unpraktisch, wenn er die Bedeutung einer Fehlernummer immer erst in einer Liste nachschlagen muss. Was wir also brauchen, ist die Umsetzung des Fehlercodes in einen beschreibenden Text. Auch dazu bietet uns die C-Bibliothek Unterstützung, sogar in dreifacher Form:

void perror(const char *s); // aus cstdio

const char *sys_errlist[];  // aus cerrno

char *strerror(int errnum); // aus cstring

Die Funktion perror() gibt zunächst den Text aus, den man ihr als Argument mitgegeben hat, und druckt dann eine Beschreibung des aktuellen Wertes von errno - alles auf die Standardfehlerausgabe. Das Feld sys_errlist lässt sich über errno indizieren und erlaubt damit den Zugriff auf den Fehlertext; somit müssen Sie ihn nicht gleich ausgeben, sondern können ihn auch in eine Log-Datei oder Ähnliches speichern. Die Funktion strerror() ist lediglich eine Kapselung für den Zugriff auf diese Tabelle. Im Extremfall kann es auch vorkommen, dass eine Funktion einen Fehlercode setzt, der in der Liste nicht enthalten ist. Da Sie dann einen Zeiger jenseits des Feldendes erhalten würden - mit all den bekannten Problemen -, sollten Sie vor dem Indizieren auf Einhaltung der Listengrenze prüfen. Diese erfahren Sie aus der ebenfalls globalen Variablen sys_nerr.

Betrachten wir als Beispiel die Bildung der Quadratwurzel aus einer negativen Zahl. Die C-Bibliothek rechnet nicht mit komplexen Zahlen, weshalb hier ein Fehler auftritt. Diesen wollen wir mit den gerade besprochenen Funktionen auswerten.

// fehler.cc

#include <iostream>

#include <cmath>

#include <cstdio>

#include <cerrno>

  

using namespace std;

 

int main()

{

  double s = sqrt(-1.0);

 

  if (errno)

  {

    if (errno < sys_nerr)

      cout << "Fehler aus sys_errlist: "

           << sys_errlist[errno]

           << endl;

 

    perror("Fehler aus perror()");

  }

 

  return 0;

}

Der Fehlertext sollte so ähnlich lauten wie: Numerical argument out of domain. Probieren Sie doch selbst mal aus, ähnliche Fehler zu provozieren und dann abzufangen!

Noch ein Tipp: Fragen Sie den Inhalt von errno immer sofort nach dem Aufruf der Bibliotheksfunktion ab! Denn wenn Sie zwischendurch noch etwas anderes tun, könnte es bereits den nächsten Fehler geben. Und selbst wenn alle Aufrufe erfolgreich sind, können doch in dahinter liegenden Unterfunktionen Fehler aufgetreten sein, die den Wert von errno beeinflussen.


Behandlung von Signalen (csignal)

Signale werden unter Unix dazu verwendet, Ereignisse von Prozessen anzuzeigen. Das können Fehlerzustände wie Speicherverletzungen oder Gleitkommafehler sein, aber auch Befehle an den Prozess von außen wie Unterbrechung oder Beendigung. Signale werden meist von der Shell erzeugt, teilweise gemäß einem Benutzerwunsch. Wenn Sie beispielsweise ein Programm aus der Shell starten und während des Laufs die Tastenkombination Strg+C drücken, wird an das Programm das Signal SIGINT geschickt. Hat dieses keine Vorkehrungen getroffen, um das Signal abzufangen, hält die Shell es an. Von der Shell aus können Sie ein Signal mit Hilfe des Kommandos kill an einen Prozess schicken. Sicher kennen Sie die Verwendung von kill ohne weitere Optionen, nur mit der Prozessnummer, um hängen gebliebene Prozesse endgültig zu beenden.

Figure: Auch bei Prozessen gibt es Signale.



\resizebox*{!}{3cm}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ampel.eps}}

Signale können aber auch von Prozessen absichtlich erzeugt werden, um andere Prozesse damit zu benachrichtigen. Das Schicken von Signalen ist somit eine einfache Form der Interprozesskommunikation (siehe auch Seite [*]).

Die Datei csignal definiert Konstanten für alle Signale. Die wichtigsten finden Sie in Tabelle [*]Tab:Signale. An dieser sehen Sie auch, dass die Ausnahmezustände, die durch Signale beschrieben werden, vielfach sehr schwer wiegend sind und letzte Meldungen eines sterbenden Prozesses darstellen.


Table: Die wichtigsten Signale
   
Signalkonstante Beschreibung
 

SIGINT

SIGTERM Normale Beendigung eines Prozesses
SIGKILL Unbedingte Beendigung eines Prozesses
SIGBUS Bus error
SIGFPE Gleitkommafehler, zum Beispiel bei Division durch 0
SIGSEGV Speicherzugriffsfehler (segmentation violation)
SIGALRM Auslösen eines Wecksignals
SIGSTKFLT Stack-Überlauf oder anderer schwerer Stack-Fehler
SIGSTOP Anhalten des Prozesses, ausgelöst etwa durch Strg+z oder kill -STOP
SIGCONT Fortsetzung eines angehaltenen Prozesses
SIGUSR1 Benutzerdefiniertes Signal

Signale interpretieren

Analog zu den Fehlercodes gibt es auch bei den Signalen Funktionen, die den Zahlenwert in einen Text umwandeln. Da ein Signal aber nicht in einer globalen Variablen hinterlegt ist, müssen Sie der jeweiligen Funktion die Signalnummer mit übergeben. Zur Verfügung stehen:

void psignal(int sig, const char *s); 

const char *const sys_siglist[];      

char *strsignal(int sig);             

Die Verwendung erfolgt ganz analog zu den im letzten Abschnitt beschriebenen Funktionen.

Signale versenden

Ein Prozess kann Signale sowohl an sich selbst als auch an andere Prozesse schicken. An den eigenen Prozess geschieht das mit der Funktion

int raise(int sig);

Das macht natürlich im Allgemeinen nur Sinn, wenn der Prozess auch über einen Mechanismus verfügt, Signale abzufangen und damit darauf zu reagieren. Wie man einen solchen Mechanismus aufbaut, erkläre ich Ihnen gleich.

Wenn Sie ein Signal an einen anderen Prozess schicken wollen, benötigen Sie zunächst dessen Prozessnummer. Wie wir bereits auf Seite [*] und folgende gesehen haben, kann man diese entweder mittels des ps-Kommandos oder über die Funktion getpid() bestimmen. Diese übergeben Sie dann zusammen mit dem gewünschten Signal an die Funktion kill(), welche folgende Signatur hat:

int kill(pid_t pid, int sig);

Im Falle eines Fehlers gibt kill() -1 zurück und setzt errno. Mögliche Fehler sind unter anderem, dass kein Prozess mit der übergebenen Nummer existiert oder die Signalnummer nicht gültig ist.

Signale abfangen

Um ein Signal abzufangen, verwenden Sie die Funktion signal(). Diese ist deklariert als:

void (*signal(int signum,

                void (*handler)(int)))(int); 

Verstehen Sie, was damit gemeint ist? Diese Deklaration ist in der Tat reichlich komplex, was wieder einmal zeigt, dass C (und in gewisser Weise C++) viele Wege hat, um etwas kompliziert auszudrücken. Zerlegen wir also diese Deklaration, um sie besser verstehen zu können: Der Funktionsname, das dürfte klar sein, ist signal. Das erste Argument vom Typ int soll sicher die Nummer des Signals sein, das wir abfangen wollen. Was brauchen wir also noch? Eine Reaktion, wenn das Signal eintrifft; am besten sollte eine von uns bereitgestellte Funktion aufgerufen werden. Dieser Wunsch wird uns auch erfüllt; denn das zweite Argument ist ein Zeiger auf eine Funktion, die void zurückgibt und einen int-Wert erhält. Ein derartiger Funktionszeiger ist auch der Rückgabetyp von signal(), was die Deklaration zusätzlich verkompliziert. Sie liefert die Funktion, die vorher zur Behandlung des Signals verwendet wurde, oder eine der Konstanten SIG_IGN (Signal wird ignoriert) oder SIG_DFL (Behandlung wird auf Default-Verhalten zurückgesetzt). Beide Konstanten können Sie auch an signal() übergeben.

Beachten \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Sie, dass Sie unter Linux mit signal() immer jedes Auftreten des jeweiligen Signals abfangen. Möchten Sie das Standardverhalten des Systems in Kraft setzen, müssen Sie dies explizit anweisen. Für SIGINT geben Sie zu diesem Zweck beispielsweise an:

  signal(SIGINT, SIG__DFL);

Eine der häufigsten Anwendungen ist das Abfangen des Interrupt-Signals SIGINT. Denn in größeren Anwendungen möchte man nicht einfach aus dem Programm geworfen werden, sobald der Benutzer Strg+c drückt, sondern erst aufräumen und dann kontrolliert herunterfahren. Wir wollen ein kleines Programm betrachten, das sich nicht - wie sonst üblich - mit dieser Tastenkombination stoppen lässt.

// SignalHandler.cc

#include <iostream>

#include <csignal>

#include <unistd.h>

  

using namespace std;

 

// Reaktionsfunktion auf das Signal

void handler(int signal)

{

  if (signal == SIGINT)

    cout << "Mit Strg-C bin ich nicht"

         << " zu stoppen!" << endl;

  else

    psignal(signal, "Unbekanntes Signal: ");

}

 

// Hauptfunktion

int main()

{

  // Signal mit Reaktionsfunktion verbinden

  signal(SIGINT, handler);

  

  cout << "Ich arbeite endlos." << flush;

  

  while(1)

  {

    cout << "." << flush;

    sleep(1);

  }

  

  return 0;

}

Da die Bedingung der while-Schleife immer erfüllt ist, verspricht der Text also nicht zu viel. Eine kleine Pause ist in Form der sleep()-Funktion eingebaut. Diese wartet die als Parameter angegebene Zeit in Sekunden, bevor sie die Programmausführung fortsetzt. Bei diesem Beispiel sollte also jede Sekunde ein Punkt auf Ihrem Bildschirm erscheinen. Die Funktion sleep() ist übrigens in der Header-Datei unistd.h definiert.

Wie kann man das Programm aber trotzdem beenden? Es bleibt eigentlich nur noch das kill-Kommando, um das Programm abzuschießen. Die dazu nötige Prozessnummer könnte man sich aus der durch ps aux (oder Ähnliches) erhältlichen Liste heraussuchen. Es geht aber auch etwas einfacher. Als alternative Tastenkombination funktioniert nämlich Strg+z noch. Das damit verbundene Signal SIGSTOP kann nicht einmal abgefangen werden. Und ein Druck auf diese Tasten hält das Programm augenblicklich an. Dabei wird sogar die Prozessnummer ausgegeben, etwa:

     [1]  +   636 Suspended   SignalHandler

Nun brauchen wir nur noch diese Nummer für kill zu verwenden:

kill 636

Somit sind wir das ewige Programm endgültig los.

Wenn Sie die Signalbehandlung in einem größeren Programm einsetzen wollen, sollten Sie beachten, dass der Benutzer nicht nur ein Signal schickt, sondern (sogar meistens) mehrere hintereinander. Entsprechend oft wird dann auch Ihre Reaktionsfunktion aufgerufen. Sie sollten sich also merken, ob Sie bereits das Herunterfahren Ihres Programms initiiert haben oder nicht, damit Sie diesen Befehl nicht zweimal geben.


Allgemeine Utilities (cstdlib)

Die Header-Datei cstdlib bietet so vielseitige und häufig gebrauchte Funktionen, dass viele C-Programmierer sie schon nur auf Verdacht an die Spitze ihrer Programme geschrieben haben. Für einiges davon gibt es in C++ bessere Möglichkeiten, aber anderes ist auch für den C++-Programmierer noch recht praktisch.

Umwandlung von char-Strings in Zahlen

Wie Sie gesehen haben, kommen Sie um die Verwendung von char-Strings nie ganz herum. Da ist es sinnvoll, wenigstens Möglichkeiten der Konvertierung in andere Datentypen zu kennen. Für die Klasse string der C++-Standardbibliothek geht es über Konstruktoren und geeignete Operatoren; wir werden diese später noch genauer untersuchen. Für elementare Datentypen bietet diese Header-Datei die Deklarationen einiger Bibliotheksfunktionen an. Im Einzelnen sind das:

int atoi(const char *nptr); 

long atol(const char *nptr); 

double atof(const char *nptr); 

Am Typ des Rückgabewerts können Sie den Weg der Konvertierung erkennen. Die Verwendung ist so einfach, dass Sie damit sicher auch ohne Beispiel von mir zurechtkommen.

Im Grunde handelt es sich bei diesen Funktionen nur um Kapselungen der allgemeineren Funktionen strtol() und strtod(). Diese führen zwar noch eine zusätzliche Fehlerprüfung durch, haben aber eine so gewöhnungsbedürftige Syntax, dass ich sie nur Profis empfehlen würde. Wenn Sie möchten, können Sie auf den man-Seiten zu diesen Funktionen Näheres erfahren.


Zufallszahlenerzeugung

In vielen Programmen, beim Testen, bei Spielen oder bei Simulationen ist die Verwendung von zufällig erzeugten Werten sinnvoll. Die C-Bibliothek bietet Ihnen gleich zwei Funktionen dazu an: rand() und random(). Sie basieren zwar auf dem gleichen Grundalgorithmus, können aber dennoch leichte Unterschiede aufweisen; man sagt, dass bei rand() die kleineren Bits nicht in gleichem Maße zufällig verteilt sein können wie die größeren. Aber letztlich werden Sie diese Unterschiede wohl nur selten merken. (Wenn Sie mehr über die Theorie der Zufallszahlenerzeugung erfahren wollen, können Sie beispielsweise - wie bei vielen anderen numerischen Themen - bei [PRESS . 1990] nachschlagen.)

Die Funktion

long int random(void);

liefert eine zufällig ausgewählte Zahl im Bereich zwischen 0 und der Konstanten RAND_MAX. Diese ist im Allgemeinen genauso groß wie INT_MAX (siehe oben) und liegt unter Linux auf Intel32-Prozessoren bei circa 2,1 Milliarden. Wenn Sie etwa eine Zahl $x$ im Bereich zwischen $a$ und $b$ benötigen, können Sie diese wie folgt bestimmen (nach [PRESS . 1990]):

x = a+ (int) ((float)b*random()/(RAND_MAX+1.0)); 

Auf diese Weise vermeiden Sie Ungleichverteilungen auf Grund von Rundungen.

Sie wissen sicher, dass Zufallszahlenalgorithmen nur pseudo-zufällig arbeiten. Sie sind stets abhängig von einem Initialwert. Ist dieser gleich, so werden zur gleichen Zeit auch die gleichen Zufallszahlen erzeugt. Diesen Initialwert können Sie mit der Funktion srandom() setzen. Wenn Sie also ganz sicher gehen wollen, dass bei jedem Programmlauf etwas anderes erzeugt wird, initialisieren Sie den Zufallsgenerator am besten mit der Uhrzeit:

  srandom(time(0));

Da time() die Sekunden seit dem 01.01.1970 liefert, haben Sie wirklich zufällige Werte - vorausgesetzt, sie starten Ihr Programm nicht innerhalb einer Sekunde mehrmals. Wenn Sie so etwas vorhaben, müssen Sie auf eine genauere Timer-Funktion wie ftime() oder gettimeofday() zurückgreifen.

Als Beispiel nehmen wir uns ein Feld aus sechs Elementen und füllen es mit Zahlen zwischen 1 und 49 - wie beim Lotto. Wir müssen nur aufpassen, dass keine Zahl doppelt erscheint. Als einfache Vorsichtsmaßnahme überprüfen wir daher die erzeugte Zahl mit den bereits vorhandenen und generieren gegebenenfalls eine neue Zahl.

UINT neue_zufallszahl(UINT* iarr, UINT idx)

{

  UINT z;

  // gibt es die neue Zahl schon?

  bool exists;

 

  do

  {

    exists = false;

 

    // Zufallszahl zwischen 1 und 49

    z = 1 + (UINT)(49.0*random()/(RAND_MAX+1.0));

 

    // pruefe ob bereits vorhanden

    for(UINT j=0; j<idx; j++)

      if (z == iarr[j])

      {

        exists = true;

        break;

      }

  } while (exists);

 

  return z;

}

Diese Funktion, die wir später in einer Schleife zum Füllen des Feldes verwenden wollen, sollte für Sie keine großen Schwierigkeiten beinhalten. Möglicherweise fragen Sie sich aber, woher ein Datentyp namens UINT kommt. Sie haben sich sicher schon gedacht, dass dies als Abkürzung für unsigned int gemeint ist. Solche Kurzschreibweisen definiert man in C++ mit dem Schlüsselwort typedef. Diese steht am sinnvollsten außerhalb aller Funktionen, eventuell in einer Header-Datei, damit sie überall bekannt und verfügbar ist. Zum Beispiel:

typedef unsigned int UINT;

Mit solchen Abkürzungen machen Sie Ihren Code nicht nur insgesamt übersichtlicher und sparen sich Tipparbeit, Sie behalten sich damit auch die Möglichkeit vor, einen Datentyp global zu ändern, etwa um die Genauigkeit zu erhöhen oder den Speicherbedarf zu verringern. Hier könnten Sie etwa von int auf long umschalten, ohne dass der restliche Code geändert werden müsste.

Den Rest des Programms zur Lottozahlen-Ermittlung sehen wir uns im nächsten Abschnitt an.

Quick-Sort

Ein Feld zu sortieren ist eine häufig vorkommende Aufgabe. Von den verschiedenen Verfahren, die es dafür gibt, ist der Quick-Sort-Algorithmus einer der effizientesten. Eine Implementierung dafür finden Sie sowohl in der C-Bibliothek als auch in der C++-Standardbibliothek. Erstere werden wir hier verwenden, auch wenn ich Ihnen für Ihre langfristige Programmierarbeit eher zur anderen Version rate.

Die Funktion qsort() ist ebenfalls in cstdlib deklariert. Um möglichst universell zu bleiben, wurden zwei Verrenkungen gemacht: Zum einen arbeitet die Funktion auf dem Feld, das durch einen Zeiger auf void definiert ist. Da sich jeder Zeiger auf einen beliebigen Datentyp darauf konvertieren lässt, ist größtmögliche Allgemeinheit garantiert. Zum anderen wollten die Autoren der Funktion die Anwendung nicht auf Objekte beschränken, für die der Vergleich mittels < standardmäßig definiert ist. Daher muss der Benutzer selbst eine Vergleichsfunktion schreiben und als Argument übergeben. Der Prototyp ist damit:

void qsort(void* base, size_t nmemb, size_t size,

           int(*comp)(const void*, const void*))

Die Argumente sind also: Zeiger auf das Feld, Anzahl der Elemente, Größe eines Elements, Vergleichsfunktion. Wie Sie sehen, muss diese int zurückliefern und zwei Feldelemente in Form von konstanten Zeigern auf void verarbeiten können. Für Ganzzahlen können wir diese folgendermaßen implementieren:

int vergleiche(const void* a, const void* b)

{

  int a1 = *((int*)a);

  int b1 = *((int*)b);

  return a1-b1;

}

Die Differenz der beiden Zahlen ist genau der Rückgabewert mit der erforderlichen Bedeutung. Er ist kleiner als 0, wenn das erste Argument kleiner als das zweite ist, gleich 0 bei Gleichheit und $>0$ bei $a>b$.

Anwenden wollen wir die Funktion auf unser Feld mit den Lottozahlen. Diese erzeugen wir zwar dank obiger Funktion neue_zufallszahl() paarweise unterschiedlich, haben sie aber immer noch in beliebiger Reihenfolge vorliegen. Daher setzen wir qsort() ein, um sie in die gewohnte Form zu bringen.

int main()

{

  const UINT anzahl = 6;

  UINT iarr[anzahl];

  UINT zusatzz;

 

  // Initialisiere Zufallszahlengenerator

  srandom(time(0));

 

  // Bestimme 6 Zufallszahlen

  for(UINT i=0; i<anzahl; i++)

    iarr[i] = neue_zufallszahl(iarr, i);

    

  // und die Zusatzzahl

  zusatzz = neue_zufallszahl(iarr, anzahl);

 

  // Sortiere das Feld

  qsort( iarr, anzahl, sizeof(UINT), vergleiche);

 

  // Gib das Ergebnis aus

  cout << "Die Lottozahlen: ";

  for(UINT i=0; i<anzahl; i++)

    cout << setw(3) << iarr[i];

  cout << " (" << zusatzz << ")" << endl;

 

  return 0;

}

Die Feldgröße ist dabei als Konstante definiert, kann also leicht auf andere Spielsysteme angepasst werden.

Programmbeendigung

Besonders bei einfachen Programmen, die keine großen Anforderungen an Robustheit erfüllen müssen, will man nicht jeden Fehler in einer Unterfunktion bis zu main() zurückreichen, sondern gleich das Programm beenden. Auch dafür gibt es in cstdlib deklarierte Funktionen. Man unterscheidet dabei zwischen der normalen Programmbeendigung und der abnormalen. Für erste (und das ist die empfohlene Methode!) verwenden Sie die Funktion

void exit(int status);

Der Wert, den Sie dieser Funktion übergeben, ist dann der Rückgabewert Ihres Programms an die Shell, genauso wie der Wert bei einem return aus main(). Für ein abnormales Ende gibt es noch

void abort(void);

Im Allgemeinen (wenn Sie nicht zu systemnah programmieren wollen) sollte Ihnen aber exit() ausreichen.

Zugriff auf Umgebungsvariablen

Bereits auf Seite [*] haben wir gesehen, wie man auf Shell-Variablen aus einem Programm zugreift. Daher an dieser Stelle nur als Gedächtnisstütze: Es handelt sich dabei um die Funktion

char *getenv(const char *name);

Wenn eine Variable mit dem angegebenen Namen übrigens nicht existiert, erhalten Sie einen Nullzeiger zurück.


Ein- und Ausgabefunktionen (cstdio)

Diese Header-Datei enthält die Prototypen für die C-typischen Funktionen für Ein- und Ausgabe. Dazu gehört das Lesen und Schreiben von Dateien. Da diese Funktionalität vollständig durch die Stream-Klassen aus C++ (ab Seite [*]) abgedeckt wird, werden Sie kaum noch Bedarf für weitere Funktionen haben. Falls Sie dennoch mehr wissen wollen, können Sie Details den man- und info-Seiten zu diesen Funktionen sowie C-Büchern wie dem Klassiker [KERNIGHAN . RITCHIE 1990] entnehmen.


Zugriff auf und Manipulation von char-Strings (cstring)

Zeichenketten können in C++ auf zweierlei Art repräsentiert werden: einmal als Felder aus char-Elementen oder als Objekte vom Typ string. Ich hatte Ihnen schon mehrfach empfohlen, wo immer es geht, string-Objekte zu verwenden, da dabei die Gefahr von Fehlern wesentlich geringer ist. Manchmal ist die Verwendung von char-Strings aber leider unumgänglich, besonders im Zusammenhang mit Funktionen der C-Bibliothek und des Betriebssystems. In diesem Abschnitt will ich Ihnen daher einige nützliche Funktionen zum Zugriff auf solche Zeichenketten und zu deren Manipulation kurz vorstellen. Die meisten davon sind recht einfach, so dass Sie bei Bedarf sicher schnell damit zurechtkommen werden.

char* strcpy(char* dest, const char* src)

Kopiert den gegebenen String src nach dest (bis zur terminierenden 0). Dabei muss der Speicherplatz für dest schon bereitgestellt sein. Die Variante strncpy(char* dest, const char* src, size_t n) kopiert nur die ersten $n$ Zeichen.

char* strcat(char* dest, const char *src)

Hängt den String src an dest an, wobei die abschließende 0 überschrieben und ganz am Ende wieder angefügt wird. Auch hier muss bereits ausreichend Speicherplatz für dest vorhanden sein. Zudem dürfen sich die beiden Strings nicht schon vorher überlappen. Die Variante strncat() hängt nur die ersten $n$ Zeichen an.

int strcmp(const char* s1, const char* s2)

Vergleicht die beiden angegebenen Strings miteinander und hat als Rückgabewert 0 bei Gleichheit oder eine Zahl größer 0, falls s1 lexikografisch größer als s2, beziehungsweise eine Zahl kleiner 0, falls s1 kleiner als s2 ist. Die Variante strncmp() vergleicht nur die ersten $n$ Zeichen, während strcasecmp() den vollen Vergleich durchführt, jedoch Groß- und Kleinschreibung ignoriert.

char* strchr(const char* s, int c)

Sucht das Zeichen c im String s und gibt entweder einen Zeiger darauf (bei erfolgreicher Suche) oder 0 zurück.

char *strstr(const char* hayst, const char* ndle)

Dient zur Teilstringsuche und findet das erste Vorkommen der Nadel ndle im Heuhaufen hayst. Die Funktion liefert einen Zeiger auf den Beginn des Teilstrings zurück; wird dieser aber nicht gefunden, ist der Rückgabewert 0.

size_t strlen(const char* s)

Bestimmt die Länge des Strings, also die Anzahl der Zeichen ab Anfang bis zur abschließenden 0, wobei diese aber nicht mitzählt.

Auf Beispiele will ich an dieser Stelle verzichten - auch weil Sie diese Funktionen eher als Ausnahme denn als Regel verwenden sollten. Das Fehlerrisiko ist beim Umgang mit char-Strings einfach zu hoch. C++ bietet da mit der string-Klasse eine wesentlich bessere Alternative. Und wenn Sie diese Klasse für zu aufwändig halten und zu viel Overhead darin sehen, sollten Sie wenigstens eine eigene Klasse erstellen, um die Speicherverwaltung für diese Zeichenketten zu kapseln und damit halbwegs sicher zu machen.

Zusammenfassung

Ich empfehle Ihnen, sich folgende Aspekte aus diesem Abschnitt einzuprägen:

Übungsaufgaben

  1. Schreiben Sie ein Programm, das die Lösung einer quadratischen Gleichung $ax^2+bx+c$ nach der Formel

    \begin{displaymath}x_{1/2} = \frac{-b \pm \sqrt{b^2-4ac}}{2a}\end{displaymath}

    berechnet. Dabei soll der Benutzer die Koeffizienten eingeben können. Überprüfen Sie alle möglichen Fehlerfälle, auch die der verwendeten mathematischen Funktionen.

  2. Testen Sie Ihr in Aufgabe 1 erstelltes Programm auch mit reellen Koeffizienten, die Sie zufällig erzeugen.

  3. Stellen Sie sich vor, Sie arbeiten für einen Hersteller von Aquarien. Dieser bestimmt den Preis seiner Produkte, indem er das Volumen mit einem konstanten Faktor preisfaktor multipliziert. Sie repräsentieren jede Produktkategorie durch die Klasse

    class AquariumKlasse

    {

    public:

      float laenge;

      float breite;

      float hoehe;

      

      AquariumKlasse();

      float Volumen();

      // ...

    };

    Schreiben Sie ein Programm, in dem Sie ein Feld aus mehreren Objekten dieser Klasse anlegen und dieses Feld nach dem Preis (wie angegeben zu berechnen) mittels der Funktion qsort() sortieren lassen.


Eigene Bibliotheken

In all unseren Beispielen waren wir bislang davon ausgegangen, dass alle Teile eines Projekts gemeinsam übersetzt und dann verlinkt werden. Dies ist in der Praxis jedoch nur bei kleinen Projekten der Normalfall. Bei allen größeren teilt man das Programm in verschiedene Komponenten oder Module auf, die jeweils eigene Teilprojekte bilden (siehe auch Abschnitt 2.1.1 ab Seite [*]). Sinn dieser Aufteilung ist es unter anderem, dass damit voneinander abgegrenzte Einheiten geschaffen werden, die von verschiedenen anderen Komponenten und Programmen genutzt werden können. Handelt es sich um recht allgemeine Aufgaben, die von einer Komponente gelöst werden, so kann diese unter Umständen in mehreren Projekten eingesetzt, also wiederverwendet werden.

Um nicht immer mit allen Objektdateien einer solchen Komponente hantieren zu müssen, fasst man diese zu einer Bibliothek zusammen. In Abbildung 1.3 auf Seite [*] sahen Sie, wie Bibliotheken in die Erstellung eines Programms einfließen. Im dortigen Abschnitt haben wir auch bereits zwei Typen von Bibliotheken kennen gelernt:

Beide Arten von Bibliotheken haben ihre Vor- und Nachteile, auf die ich in den jeweiligen Abschnitten eingehen will. Heute haben statische Bibliotheken einiges an Bedeutung eingebüßt, da viele Entwickler die dynamischen bevorzugen. Dementsprechend arbeiten die meisten Applikationen, die Sie unter Linux verwenden, mit dynamischen Bibliotheken. Es gibt jedoch immer noch viele gute Gelegenheiten, wo Sie eine statische Bibliothek erzeugen und verwenden können, so dass diese nicht als bedeutungslos abgestempelt werden darf.

In diesem Abschnitt wollen wir uns ansehen, wie Sie unter Linux sowohl statische als auch dynamische Bibliotheken erzeugen können. Mehr zur Arbeit mit Bibliotheken erfahren Sie übrigens in [] und [].


Statische Bibliotheken

Eine statische Bibliothek ist im Grunde nichts anderes als ein Archiv, in das alle zugehörigen Objektdateien kopiert wurden und das die Endung .a hat. Für die Verwaltung eines solchen Archivs steht uns das Werkzeug ar zur Verfügung. (Übrigens kann ar nicht nur Objektdateien zusammenfassen, sondern ganz beliebige Dateien, also beispielsweise auch Texte. Allerdings verwendet man es eigentlich nur für Objektdateien, in allen anderen Fällen kommt meist tar zum Einsatz.)

Figure: Eine statische Bibliothek ist wie ein Vorratsrucksack, den jedes Programm individuell schultern muss.



\resizebox*{2.5cm}{!}{\includegraphics{images/rucksack.eps}}



Wenn Sie die man-Seite von ar aufrufen, werden Sie feststellten, dass dieses Werkzeug eine Vielzahl von Optionen versteht. Für das Erzeugen einer statischen Bibliothek brauchen Sie davon zum Glück nur wenige. Ähnlich wie bei tar geben Sie auch hier die Liste der Optionen als erstes Kommandozeilenargument an; das Minus davor darf entfallen.

Eine wichtige Eigenschaft von solchen Archiven ist ihr Index. In diesem werden die Symbole aus den enthaltenen Objektdateien abgelegt. Der Linker benutzt diesen Index, um die passenden Einsprungpunkte für die jeweiligen Funktionen zu finden. Ohne einen solchen Index wäre eine Bibliothek für den Linker unbrauchbar. Der Archiver ar sorgt dafür, dass der Index auch dann aktuell bleibt, wenn Sie einzelne Dateien des Archivs austauschen, löschen oder neu einfügen. Um ein Archiv mit Index zu erzeugen, geben Sie beim Kommando r die Option s an.

Damit haben wir auch schon die Kombination von Optionen, die Sie normalerweise für die Erzeugung einer statischen Bibliothek verwenden: rcs. Das nächste Argument ist dann der Name der Bibliothek, gefolgt von einer oder mehreren Objektdateien. Damit lautet das Standardkommando

ar rcs libxy.a x.o y.o

um die beiden Dateien x.o und y.o zum Archiv libxy.a zusammenzufügen.

Sie können eine Bibliothek in einem Befehl erzeugen, indem Sie alle Objektdateien, die enthalten sein sollen, hintereinander angeben. Sie können jedoch ar auch mehrfach für die gleiche Bibliothek aufrufen, etwa um eine Datei nach der anderen hinzuzufügen. Somit ist

ar rcs libxy.a x.o

ar rcs libxy.a y.o

im Ergebnis gleichwertig mit obigem Kommando. Normalerweise werden Sie ar auch nicht als Shell-Befehl eingeben, sondern aus Ihrer Make-Datei aufrufen (siehe Seite [*]).

Ein \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} häufiger Fehler bei der Verwendung eigener Bibliotheken ist, die Angabe des Suchpfades zu vergessen. Wenn Sie unsere Bibliothek libxy.a zu einem Programm testprog dazubinden wollen, wobei beide im selben Verzeichnis stehen sollen, so müssen Sie etwa angeben:

g++ -o testprog testprog.o -L. -lxy

Auch müssen Sie sich bewusst machen, dass die Linker-Option -l erwartet, dass der Name der Bibliothek mit lib beginnt und auf .a endet. Nur die Buchstaben zwischen diesen beiden Teilen werden an -l übergeben; so wird aus libxy.a ein -lxy.

Natürlich können Sie aber auch eine Bibliothek als Ganzes dem Linker übergeben wie eine normale Objektdatei, also unter Umgehung der Option -l. Das Kommando von eben könnten Sie auch schreiben als

g++ -o testprog testprog.o libxy.a

Mein Tipp dazu: Handelt es sich um eine Bibliothek, die Sie lediglich zur Organisation Ihres Codes anlegen und die nicht in verschiedenen Projekten verwendet werden soll, ist diese zweite Variante völlig ausreichend. (Dann fällt beispielsweise auch die Namenskonvention weg.) Wollen Sie die Bibliothek jedoch anderen zur Verfügung stellen und sie eventuell gar in einem Systemverzeichnis wie usr/local/lib ablegen, so sollten Sie die erste Möglichkeit über -l wählen.

Wie bei allen anderen Funktionen so müssen Sie auch Bibliotheksfunktionen erst deklarieren, bevor Sie sie verwenden können (siehe Seite [*]). Wie üblich erfolgt die Angabe des Prototyps beziehungsweise der Klassendeklaration in einer Header-Datei. Wenn Sie also Ihre Bibliothek in mehreren Projekten verwenden möchten, müssen Sie dafür sorgen, dass die Header der Bibliotheksfunktionen in jedem Projekt erreichbar sind (entweder durch die Pfadangabe mit der Compiler-Option -I, durch symbolische Links im Dateisystem oder durch einfaches Kopieren). Möchten Sie Ihre Bibliothek für das gesamte System bereitstellen, indem Sie sie in das Verzeichnis /usr/local/lib kopieren, sollten Sie die Header parallel dazu in /usr/local/include ablegen.

Wenn Sie wissen möchten, welche Funktionen und andere Symbole sich in einer Bibliothek befinden, können Sie das Kommando nm verwenden. Dieses funktioniert sowohl für statische als auch für dynamische Bibliotheken. Die Ausgaben, die es produziert, sind etwas unübersichtlich, daher sollten Sie diese Ausgaben am besten in eine Datei lenken oder über eine Pipe an Kommandos wie grep weiterreichen. Wenn Sie beispielsweise wissen möchten, ob eine Funktion namens getEntry in der Bibliothek libcppdir.a enthalten ist, geben Sie ein:

nm libcppdir.a | grep getEntry

Für das Beispiel, auf das ich gleich noch näher eingehe und dessen Code Sie auf beiliegender CD-ROM finden, lautet die Ausgabe dieses Befehls:

00000540 T _ZNK9Directory8getEntryEj

Sie sehen daran übrigens auch, wie der GCC Methoden von Klassen in seiner Symboltabelle organisiert. Klassenname und Methodenname werden - getrennt durch eine Zahl - aneinander gehängt. Ist die Methode überladen, das heißt, gibt es mehrere Methoden mit gleichem Namen, aber verschiedenen Parametern, wird für jede Methode ein eigenes Symbol erzeugt und die Parameterliste in kodierter Form an das Symbol angehängt.

Der häufigste Einsatz von nm ist in der Tat die Suche. Daher ist es auch sehr praktisch, dass dieses Tool auch mehrere Bibliotheken gleichzeitig durchsuchen kann, entweder jeden Namen einzeln hintereinander in der Kommandozeile oder ganze Verzeichnisse mittels des Jokers *. Wenn Sie beispielsweise nur noch den Namen einer Funktion wissen und nicht mehr die Bibliothek, in der sie definiert ist, können Sie etwa das Verzeichnis /usr/local/lib wie folgt durchsuchen:

nm -o /usr/local/lib/*.a | grep myFunct

Die Option -o dient hier dazu, jeder Ausgabezeile den Dateinamen voranzustellen, da Sie ansonsten wieder nicht erfahren würden, in welcher Datei sich myFunct nun eigentlich befindet.

Mehr Informationen zu nm finden Sie auf der zugehörigen man- oder info-Seite.

Warum verwendet man eigentlich statische Bibliotheken? Dafür gibt es mehrere Gründe:

Wenn Sie sich jedoch dafür entscheiden, Ihre Bibliothek als Open Source freizugeben, müssen Sie sich noch ein passendes Lizenzmodell auswählen. Wenn Sie nämlich beispielsweise dafür die GNU General Public License (GPL) zugrunde legen, heißt das, dass jeder Nutzer seine eigene Software auch wieder unter der GPL veröffentlichen muss. Das kann auf viele potenzielle Nutzer abschreckend wirken. Moderater ist da die Library General Public License (LGPL), die nur für den Code der Bibliothek selbst die strengen Open-Source-Maßstäbe anlegt, für darauf aufbauende Software aber alle Lizenzmodelle, also auch die klassische Closed-Source-Variante, erlaubt. Näheres dazu erfahren Sie auf Seite [*].


Beispiel: Zugriff auf Verzeichnisse

Auch in diesem Abschnitt möchte ich Ihnen die Vorgehensweise wieder anhand eines Beispiels erklären. In diesem werden wir vier weitere Funktionen der C-Bibliothek kennen lernen, die nicht zu den Gruppen aus Abschnitt [*] gehören. Sie dienen dazu, die Einträge von Verzeichnissen zu bestimmen und auszuwerten.

Die Funktion opendir() öffnet das Verzeichnis, das als Parameter angegeben ist, und gibt einen Zeiger darauf zurück. Mit Hilfe dieses Zeigers können wir uns Zugriff auf die weiteren Einträge verschaffen. Ist die Rückgabe ein Nullzeiger, ist die Funktion fehlgeschlagen - wahrscheinlich gibt es das angegebene Verzeichnis einfach nicht oder das Programm hat nicht die nötigen Zugriffsrechte.

Mit readdir() können wir ein Element nach dem anderen auslesen. Solange die Funktion einen Wert ungleich 0 zurückgibt, hat sie einen neuen Eintrag gefunden. Der C++-Programmierer denkt da gleich an eine while-Schleife: Wiederhole den Aufruf von readdir() so oft, bis der Rückgabewert 0 ist. Der Rückgabewert selber ist ein Zeiger auf eine Struktur vom Typ dirent, die in einer eigenen Header-Datei dirent.h definiert ist.

struct dirent

{

  __ino_t d_ino;

  __off_t d_off;

  unsigned short int d_reclen;

  unsigned char d_type;

  char d_name[256];           

};

Am interessantesten daran ist d_name, das den Namen der Datei beziehungsweise des Verzeichniseintrags enthält. Weiterhin ist d_reclen die Anzahl der Zeichen, die dieser Name tatsächlich hat, d_ino die Seriennummer der Datei und d_type der Typ, also unter anderem normale Datei (DT_REG), Unterverzeichnis (DT_DIR) oder Gerät (DT_BLK).

Beachten Sie, dass Sie mit readdir() nur die Namen und Typen der Verzeichniseinträge ermitteln können, nicht aber die Attribute wie Größe oder Benutzerzugehörigkeiten. Diese Informationen sind direkt mit der jeweiligen Datei verknüpft; über die Funktion stat() können wir sie bestimmen. Dabei schreibt stat() sie in eine Struktur vom Typ stat. Von dieser brauchen wir hier nur die Elemente st_uid und st_gid für Benutzer- und Gruppen-ID sowie st_size für die Dateigröße in Bytes. Um stat() zu verwenden, müssen Sie die Header-Datei sys/stat.h einbinden. Die weiteren Details erfahren Sie am einfachsten, wenn Sie man stat eingeben.

In unserem Beispielprogramm wollen wir diese Funktionen in C++-Klassen kapseln, so dass nicht jeder Programmierer auf diese Schnittstelle angewiesen ist. Die Informationen speichern wir dabei in einem Objekt vom Typ DirEntry:

class DirEntry

{

private:

  std::string m_name;

  long m_userid;

  long m_groupid;

  unsigned long m_size;

public:

  DirEntry() : 

    m_userid(0L), m_groupid(0L),

    m_size(0L)

    {}

 

  DirEntry(const std::string& name, long userid,

           long groupid, unsigned long size) :

    m_name(name), m_userid(userid), 

    m_groupid(groupid), m_size(size)

    {}

 

  void print() const;

};

In einer Klasse Directory speichern wir alle Einträge eines Verzeichnisses. Dazu verwenden wir ein Objekt vom Typ std::vector, das einen einfachen Vektor darstellt, also eine geordnete Menge von Elementen, bei dem uns aber die Probleme mit der Speicherverwaltung abgenommen werden. Mehr dazu finden Sie ab Seite [*].

class Directory

{

private:

  DIR*  m_dir;

  std::vector<DirEntry> m_vector;

  DirEntry   m_dummy;

 

public:

  Directory() {}

  Directory(const std::string& path);

 

  int setDir(const std::string& path);

  unsigned int numberOfEntries() const;

  const DirEntry& getEntry(unsigned int number) const;

};

An die Methode setDir() wird ein Pfad übergeben; sie liest sodann die Elemente dieses Verzeichnisses ein und speichert sie in m_vector. Die Anzahl der Elemente erfahren Sie aus numberOfEntries(), ein Element selber gibt es bei getEntry(). Dort gibt es auch einen kleinen Schönheitsfehler: Wenn nämlich der übergebene Index größer ist als die Zahl der Elemente, müsste eigentlich eine Fehlermeldung ausgegeben werden. Die Deklaration der Methode verlangt aber auch, dass eine Referenz auf ein DirEntry-Objekt zurückgeliefert wird. Um diesem Ansinnen nachkommen zu können, gibt es noch das Attribut m_dummy, das in diesem Fall zurückgegeben wird. Auf Seite [*] erfahren Sie, wie Sie dieses Problem eleganter mit Hilfe von Ausnahmen lösen können.

Interessant ist also eigentlich nur die Methode setDir(), denn hier setzen wir readdir() und stat() auch wirklich ein.

int Directory::setDir(const string& path)

{

  // Lösche evtl. Inhalte des Vektors

  m_vector.clear();

 

  // Öffne das Verzeichnis

  m_dir = opendir(path.c_str());

 

  // Nicht vorhanden -> return

  if (!m_dir)

    return 0;

 

  dirent* entry;

  struct stat file_state;

 

  // Schleife über alle Einträge

  entry = readdir(m_dir);

  while (entry)

  {

    stat(entry->d_name, &file_state);

    DirEntry d(entry->d_name, 

               file_state.st_uid,

               file_state.st_gid,

               file_state.st_size);

    m_vector.push_back(d);

    

    entry = readdir(m_dir);

  }

 

  // Gib Anzahl der Einträge zurück

  return (m_vector.size());

}

Nach den Vorbemerkungen über die Benutzungsweise dieser Funktionen dürfte dieser Codeausschnitt für Sie nicht schwer zu verstehen sein.

Eine ähnliche Funktion wie readdir(), allerdings mit einer etwas anderen Aufgabe und anderen Schnittstelle, ist glob(). Hiermit können Sie die Einträge eines Verzeichnisses auf einmal einlesen; diese werden in einem Puffer abgelegt. Das Besondere ist, dass Sie die Einträge nach einem Muster auswählen können - in gleicher Weise, wie Sie das auch bei ls oder rm machen. Der erste Parameter von glob() ist daher auch dieses Muster (beispielsweise *.cc), dann folgt ein Flag, ein Zeiger auf eine Fehlerbehandlungsroutine und schließlich der Puffer, der den Typ glob_t hat. Im Gegensatz zu readdir() bezieht sich glob() immer auf das aktuelle Verzeichnis; wollen Sie ein anderes untersuchen, müssen Sie erst in dieses wechseln (mittels chdir()).

Wir bauen also wieder eine Klasse, analog zu Directory, und nennen sie Globber. Die Methode runMatch() sucht nach Verzeichniseinträgen und speichert die gefundenen in einen Vektor ab. Anschließend gibt sie den Puffer wieder frei.

int Globber::runMatch(const string& pattern)

{

  // starte den Mustervergleich

  int res = glob(pattern.c_str(), 0, NULL, &m_globbuf);

 

  // Keine Einträge erfüllen das Muster

  if (res)

    return 0;

 

  // Speichere Einträge

  for (int i=0; i<m_globbuf.gl_pathc; i++)

    m_vector.push_back(m_globbuf.gl_pathv[i]);

 

  // Gib den Glob-Puffer frei

  globfree(&m_globbuf);

 

  return m_vector.size();

}

Die Klasse Globber selbst ist genau wie Directory aufgebaut, so dass ich hier wohl auf einen Abdruck verzichten kann.

Beide Klassen zusammen wollen wir als Bibliothek zur Verfügung stellen. Dazu sind beim Übersetzen die folgenden drei Befehle notwendig:

g++ -Wall -c -o directory.o directory.cc

g++ -Wall -c -o globber.o globber.cc

ar rcs libcppdir.a globber.o directory.o

Damit ist die Bibliothek libcppdir.a fertig. Wenn Sie sie in einem Programm nutzen möchten, brauchen Sie dazu diese Bibliothek selbst, aber auch die Header-Dateien directory.h und globber.h.

Zum Linken der Bibliothek verwenden wir die Option -l, so dass der dazu nötige Befehl lautet:

g++ -o libtest libtest.o -L. -lcppdir

In unserem Testprogramm werden Objekte von beiden Klassen erzeugt und die entsprechenden Methoden aufgerufen.

In der Praxis geben Sie diese ganzen Befehle natürlich nicht immer von Hand ein, sondern schreiben sie in eine Make-Datei. Mehr dazu erfahren Sie ab Seite [*]. Auch bei den Beispieldateien zu diesem Kapitel auf beiliegender CD-ROM finden Sie ein passendes Makefile.

Dynamische Bibliotheken (shared libraries)

Haben Sie auf Ihrem System mehrere Programme, welche dieselbe Bibliothek verwenden, so sind statische Bibliotheken natürlich reichlich ineffizient: Zu jedem Programm wird die Bibliothek hinzugebungen, was einer Verschwendung von Festplattenplatz gleichkommt. Aus diesem Grund gibt es noch eine andere Art, Bibliotheken zu nutzen, nämlich dynamisch. Das bedeutet, dass erst zur Laufzeit Verbindung mit der Bibliothek aufgenommen wird; diese befindet sich dabei in einer separaten Datei, die von der Programmdatei getrennt ist. Dynamische Bibliotheken können daher von mehreren Programmen gemeinsam verwendet werden. Im Folgenden wollen wir uns ansehen, wie Sie dynamische Bibliotheken erzeugen, auffinden und nutzen können. Ich beschränke mich hierbei auf die so genannten shared libraries; es gibt unter Linux noch eine andere Art von dynamischen Bibliotheken, die wir in diesem Rahmen aber leider nicht behandeln können.

Figure: Eine dynamische Bibliothek gehört niemandem allein; wie beim Fernseher können alle gleichzeitig reinschauen.



\resizebox*{2.8cm}{!}{\includegraphics{images/fernseher.eps}}

Das gravierendste Problem bei dynamischen Bibliotheken ist die Versionierung. Genauso wie Programme werden auch Bibliotheken weiterentwickelt und neue Versionen entstehen. Befindet sich auf Ihrem System ein Programm, das - sagen wir - die Version 1.0 der Bibliothek libxy.so braucht, und gleichzeitig ein anderes, das auf Version 2.0 von libxy.so aufsetzt, so ergibt sich ein Konflikt. Unter Windows kennen Sie diese Situation sicher: Kaum hat man ein Programm installiert, schon funktioniert ein anderes nicht mehr. Dort bezeichnet man dieses Dilemma als DLL-Hölle. Unter Linux ist die Situation zum Glück nicht so dramatisch. Hier können mehrere Versionen einer Bibliothek nebeneinander existieren und von ihren jeweiligen Programmen genutzt werden. Das erfordert allerdings Disziplin beim Entwickler - und Verständnis für die Mechanismen.

Der Dateiname einer dynamischen Bibliothek (shared library) muss einige Konventionen erfüllen, damit dieses Versionierungskonzept funktioniert. Noch schlimmer: Es gibt sogar mehrere Namen für eine Datei. Doch der Reihe nach: Die Konvention besagt, dass der Name folgendermaßen zusammengesetzt sein muss:

lib<name>.so.<Versionsnr>.<Unterversionsnr>.<Release-Nr>
Dabei kann die Release-Nummer auch weggelassen werden. Die Versionsnummer wird immer dann erhöht, wenn sich die Schnittstelle der Bibliothek ändert. Sind nur Fehler beseitigt oder Implementierungen verbessert worden, sollte lediglich die Unterversionsnummer erhöht werden. Dadurch wird erreicht, dass ein Programm, das für eine bestimmte Version einer Bibliothek geschrieben wurde, auch mit einer neueren Version noch läuft, sofern diese dieselbe Schnittstelle hat. (Mit Schnittstelle ist hier nicht nur die Syntax gemeint, also die Signaturen der Funktionen, sondern auch die Semantik, das heißt die Bedeutung der Funktionen und ihrer Parameter.) In unserem Beispiel etwa legen wir die Datei libcppdir.so.1.0 an.

Neben diesem eigentlichen Namen gibt es noch den so genannten so -Namen. Darunter versteht man den Dateinamen ohne Unterversions- und Releasenummer, also libcppdir.1 in unserem Fall. Programme sollten sich auf diesen Namen beziehen, wenn sie eine dynamische Bibliothek benötigen. Normalerweise ist der so-Name nur ein symbolischer Link auf die eigentlichen Datei.

Wenn Sie selber ein Programm schreiben, das eine dynamische Bibliothek benötigt, so müssen Sie beim Linken ebenfalls einen Dateinamen der Bibliothek angeben. Auch wenn die Bibliothek erst zur Laufzeit tatsächlich verwendet wird, muss sie bereits beim Linken des Programms verfügbar sein, damit der Linker entsprechende Sprungbefehle zu den Bibliotheksfunktionen im ausführbaren Programm vorbereiten kann. Für den Linker gibt man den Namen der Datei wie üblich mittels der Option -l an. Das heißt aber auch, dass es noch einen weiteren Namen einer dynamischen Bibliothek geben muss, nämlich den für den Linker. Dieser entspricht dem so-Namen, allerdings ohne die Versionsnummer, also beispielsweise einfach libcppdir.so. In der Praxis wird dies durch einen weiteren symbolischen Link realisiert, diesmal auf den so-Namen (bei Bedarf aber auch auf die eigentliche Bibliothek, gegebenenfalls in einer anderen Version).

Wie findet das System nun die richtige dynamische Bibliothek, die ein Programm benötigt? Wenn Sie unter Linux ein Programm starten, so starten Sie gleichzeitig auch den so genannten Loader, der die von Ihrem Programm benötigten Bibliotheken findet und in den Speicher lädt. Dazu durchsucht er alle Verzeichnisse, die in der Konfigurationsdatei /etc/ld.so.conf angegeben sind. Wenn Sie sich diese Datei ansehen, werden Sie feststellen, dass darin die wichtigsten Verzeichnisse vorkommen, in denen dynamische Bibliotheken zu finden sind. Die Datei kann bei der Installation von Programmpaketen zum Beispiel durch rpm angepasst werden.

Es wäre allerdings sehr ineffizient, bei jedem Programmstart alle diese Verzeichnisse zu durchsuchen. Daher legt das System einen Cache an, eine leicht zu durchsuchende Merkliste, welche Datei sich wo befindet. Diese Liste steht in /etc/ld.so.cache. Sie wird bei der Installation von Bibliotheken, bei Bedarf aber auch beim Systemstart oder beim Mounten zusätzlicher Laufwerke aktualisiert.

Das Werkzeug dazu ist ldconfig. Es analysiert alle Verzeichnisse, die in der Konfigurationsdatei /etc/ld.so.conf angegeben sind, außerdem /lib und /usr/lib sowie ein beim Aufruf übergebenes Verzeichnis. Dabei aktualisiert es im Normalfall die Links von den so-Namen auf die eigentlichen Dateinamen, legt solche an, wo sie noch nicht vorhanden sind, und bringt den ld-Cache auf den neuesten Stand. Wir werden ldconfig nur mit der Option -n verwenden. Damit durchsucht es ausschließlich das angegebene Verzeichnis und nimmt auch keine Veränderungen am ld-Cache vor.

Möchten Sie erreichen, dass außer den Verzeichnissen, die in /etc/ld.so.conf enthalten sind, noch weitere durchsucht werden, so können Sie diese in der Umgebungsvariablen LD_LIBRARY_PATH angeben. Die dort aufgeführten Verzeichnisse werden noch vor den konfigurierten durchsucht. Diese Technik sollte nicht zum Standard werden; für die Entwicklung und Tests eignet sie sich indessen sehr gut.

Daraus können Sie auch schon erkennen, wo Sie dynamische Bibliotheken ablegen sollten: in einem der Pfade, die in /etc/ld.so.conf enthalten sind, nach Möglichkeit aber in /usr/local/lib für Eigenentwicklungen und /usr/lib für Produktivversionen. Nur für große Projekte wie KDE lohnt es sich, dafür ein eigenes Verzeichnis festzulegen und in /etc/ld.so.conf einzutragen.

Nun können wir uns endlich daran machen und eine dynamische Bibliothek erzeugen. Dabei müssen wir aber gleich wieder aufpassen, und zwar aus folgendem Grund: Normalerweise - bei ausführbaren Programmen und statischen Bibliotheken - kennt der Linker alle Speicheradressen im Programm und kann die Zugriffe und Sprungbefehle entsprechend an die richtige Stelle zeigen lassen. Bei dynamischen Bibliotheken passiert das erst zur Laufzeit des Programms. Daher darf die Bibliothek keinen festen Adressraum verwenden, denn der könnte schon vom Programm, das sie nutzt, belegt sein. Der Code für dynamische Bibliotheken muss daher positionsunabhängig sein (position independent); das heißt, dass alle Speicheradressen über eine globale Offset-Tabelle bestimmt werden. Dies erreichen wir beim GCC durch Angabe der Option -fPIC. Alle Quelltextdateien für eine dynamische Bibliothek müssen mit dieser Option übersetzt werden. In unserem Beispiel aus Abschnitt [*] müssen wir daher folgende Kommandos ausführen:

g++ -Wall -c -fPIC directory.cc

g++ -Wall -c -fPIC globber.cc

Beim Linken müssen wir abermals ein paar bislang unbekannte Optionen bemühen. Zum einen ist es -shared. Damit wird angegeben, dass eine dynamische Bibliothek (shared library) erzeugt werden soll. Außerdem müssen wir noch dem Linker den gewünschten so-Namen mitteilen. Die Option -Wl erlaubt die Weitergabe von Optionen an den Linker. Bei dieser weiterzureichenden Optionenliste müssen jedoch die Leerzeichen durch Kommas ersetzt werden, um sie von den Optionen, die direkt für den GCC selbst bestimmt sind, unterscheiden zu können. Der gesamte Befehl lautet dann:

g++ -shared -Wl,-soname,libcppdir.so.1 

-o libcppdir.so.1.0 globber.o directory.o

Damit wird allerdings nur die Bibliothek mit diesem so-Namen intern gekennzeichnet. Im Dateisystem haben wir diesen damit noch nicht. Das erledigt ein Aufruf von ldconfig (mit -n, um nicht auch noch die Systempfade zu aktualisieren):

/sbin/ldconfig -n .

Sicherheitshalber gebe ich hier den Pfad zu ldconfig selber an, da /sbin bei normalen Benutzern nicht im Suchpfad sein sollte.

Anschließend legen wir noch den Link des Linker-Namens an (hier wird recht viel gelinkt; lassen Sie sich dadurch nicht verwirren!):

ln -sf libcppdir.so.1 libcppdir.so

Damit haben wir die Bibliothek in allen drei Ausführungen fertig, also eine Datei und zwei Links. Ein Blick ins aktuelle Verzeichnis bestätigt das:

lrwxrwxrwx 1    14 libcppdir.so -> libcppdir.so.1

lrwxrwxrwx 1    16 libcppdir.so.1 -> libcppdir.so.1.0

-rwxr-xr-x 1 96830 libcppdir.so.1.0

Nun brauchen wir nur noch unser Testprogramm übersetzen und linken. Dabei können wir exakt genauso vorgehen wie bei einer statischen Bibliothek:

g++ -o libtest libtest.o -L. -lcppdir

Wenn Sie es jetzt nicht mehr abwarten können und das Programm gleich starten, erleben Sie eine Enttäuschung:

libtest: error while loading shared libraries: 

libcppdir.so.1: cannot load shared object file: 

No such file or directory

Offenbar haben meine Ausführungen über Suchpfade und Merklisten für dynamische Bibliotheken noch nicht so viel gefruchtet ... Um die dynamische Bibliothek erreichbar werden zu lassen, setzen Sie am besten die Umgebungsvariable LD_LIBRARY_PATH auf das aktuelle Verzeichnis. In der C-Shell heißt das:

setenv LD_LIBRARY_PATH .:$LD_LIBRARY_PATH

Nun sollte das Programm libtest problemlos laufen.

Noch ein Hinweis auf ein weiteres Tool: Mit ldd können Sie sich alle dynamischen Bibliotheken auflisten lassen, die ein Programm benötigt und die vom Loader auch tatsächlich geladen werden. Versuchen wir es:

ldd libtest

  libcppdir.so.1 => ./libcppdir.so.1 (0x40017000)

  libstdc++.so.3 => /usr/lib/libstdc++.so.3 (0x4002c000)

  libm.so.6 => /lib/libm.so.6 (0x400da000)

  libgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x400f9000)

  libc.so.6 => /lib/libc.so.6 (0x40102000)

  /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)

Sie sehen daran, dass bereits einfache C++-Programme verschiedene dynamische Bibliotheken benötigen. Das sollten Sie beachten, wenn Sie Ihre Programme weitergeben.

Auch wenn Sie alle diese Konventionen beachten, kann es vorkommen, dass ein Programm nicht mehr mit einer neuen Version einer dynamischen Bibliothek zusammenarbeitet. Es sollte mittlerweile klar sein, dass eine Kompatibilität bei einer veränderten Schnittstelle sicher nicht mehr gewährleistet werden kann, wenn also Funktionen weggefallen sind, ihre Signaturen modifiziert wurden oder ihre Bedeutung beziehungsweise die ihrer Parameter sich geändert haben. Aber auch weniger offensichtliche Änderungen können zu Inkonsistenzen führen. Die Firma Trolltech hat für ihre Qt-Bibliothek in einer FAQ-Liste eine Reihe möglicher Gründe identifiziert (vollständige Liste unter http://www.trolltech.com/developer/faq/tech.html). Dazu zählen:

Sie sehen also, dass Sie mit Änderungen an dynamischen Bibliotheken sehr vorsichtig sein müssen. Leider ergeben sich nämlich Inkompatibilitäten sehr viel schneller, als man denkt. Gerade bei C++-Code liegen einige Fallen aus, die nur schwer zu entdecken sind. Wenn Sie dynamische Bibliotheken erstellen, sollten Sie also die Versionierung sehr behutsam und umsichtig angehen und durch viele Tests absichern.

Zusammenfassung

In diesem Abschnitt ging es um die Erstellung von statischen und dynamischen Bibliotheken.

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Erweitern Sie das Beispiel aus Abschnitt [*] um weitere Klassen und Methoden, um auch den Namen des Benutzers und seiner Gruppe ausgeben und die Zugriffsrechte bestimmen zu können. In einer weiteren Ausbaustufe sollten diese Daten auch geändert werden können, sofern der Aufrufer über die notwendigen Rechte verfügt.
  3. Das Programm webtest aus Abschnitt [*] (Seite [*]) verwendet eine Liste als dynamische Datenstruktur. Ändern Sie das Programm, indem Sie die Implementierung der Liste

    1. in eine statische Bibliothek
    2. in eine dynamische Bibliothek

    ablegen und das Programm mit der jeweiligen Bibliothek neu übersetzen und testen.


Tipps und Konventionen

Bisher haben wir uns vorwiegend damit beschäftigt, wie die Programmiersprache C++ aufgebaut ist und nach welchen Regeln Programme mit ihr erstellt werden müssen. Sie haben dabei sicher auch schon gemerkt, dass die Möglichkeiten, C++ zu benutzen, äußerst vielfältig sind. Die Freiheiten, die die Sprache dem Programmierer lässt, sind aber bei der Programmentwicklung im Team eher eine Gefahr. Wenn jeder nach seinem Geschmack arbeitet, kann bald keiner mehr den Code eines anderen verstehen. Aus diesem Grund ist es gerade bei C++ besonders wichtig, dass einheitliche Richtlinien formuliert werden, nach denen alle Programmierer arbeiten. Aber auch für einen Einzelkämpfer können solche Konventionen hilfreich sein, um den eigenen Code nach einiger Zeit noch zu durchschauen. Wie solche Richtlinien zur Namensgebung etc. genau aussehen, ist weniger wichtig, als dass sich alle Beteiligten daran halten. In Firmen und im Internet kursieren eine Menge von Dokumenten mit Kodierungskonventionen. Entscheidend ist vor allem, dass für ein Projekt ein solches Dokument überhaupt angelegt und berücksichtigt wird.

Sie sollten sich stets vor Augen halten, dass Software Engineering die einzige Ingenieursdisziplin ist, die nicht auf Naturgesetze Rücksicht nehmen muss. Ein Softwaresystem zu entwerfen ist folglich wie ein eigenes Universum zu schaffen. Das macht die Faszination, aber auch die Versuchung beim Programmieren aus.

Regeln für die Benennung von Klassen, Variablen und so weiter sind zwar für die Lesbarkeit und damit für die Wartbarkeit von großer Bedeutung, für den Compiler, also für die Programmqualität, aber kaum. Ich habe Ihnen bereits an einigen Stellen Empfehlungen gegeben, welche Sprachelemente Sie wie in Ihren Programmen einsetzen sollten. Solche Tipps sind meist Konzentrate der Erfahrungen, die viele Entwickler bei ihrer Arbeit mit der Sprache gemacht haben. Scott Meyers hat in seinen Büchern [MEYERS 1996] und [MEYERS 1997] fast 100 dieser Ratschläge zusammengefasst und ausführlich erläutert. Wenn Sie etwas sicherer im Umgang mit C++ geworden sind, kann ich Ihnen diese Lektüre nur empfehlen. (Der GCC unterstützt selbst diese Styleguides, indem er bei Verletzung der Ratschläge Warnungen ausgibt. Dieses Verhalten aktivieren Sie mit dem Compiler-Schalter -Weffc++.) Gute Hinweise finden Sie auch in [BOEHM 2000], [BUSCHMANN . 1998], [ECKEL 1995], [GAMMA . 1996] und [LACOS 1996].

Ich kann natürlich in diesem Rahmen nicht so detailliert auf alle Problembereiche eingehen. Vielmehr will ich Ihnen an dieser Stelle einige Tipps und Hinweise in komprimierter Form vorstellen, um Sie noch ein wenig für die dahinter stehende Problematik zu sensibilisieren.

Namenskonventionen

Sicher sind die Regelungen für die Benennung von Bezeichnern diejenigen, die am willkürlichsten getroffen werden können und die sich daher zwischen den einzelnen Projekten am stärksten unterscheiden. Die folgenden Regeln sind daher lediglich eine Möglichkeit von vielen, orientieren sich aber an mehreren verbreiteten Standards.

Projektorganisation

Auch bei der Organisation Ihres Projekts können Sie einiges falsch machen. Wenn Sie sich an nachfolgende Tipps halten, dürften Sie jedoch in den meisten Fällen keine Probleme haben.

Programmierstil

Wenn Sie sich von Anfang an einen übersichtlichen und klar strukturierten Stil angewöhnen, wird Ihr Code später leichter von Ihnen und anderen verstanden und kann damit auch leichter gewartet werden. Natürlich sind einige der folgenden Ratschläge die reine Lehre, die in der Praxis nicht immer so uneingeschränkt berücksichtigt werden kann; bei einem Großteil Ihrer Programme sollte es jedoch gelingen, sie zu beherzigen.

Sicheres Programmieren

C++-Programme leiden zuweilen darunter, dass die Freiheit, die die Sprache bietet, auch viele Risiken mit sich bringt. Indem Sie ein paar elementare Regeln beachten, können Sie aber bereits viel dazu beitragen, Ihre Programme sicherer zu machen.

C++-Programmierstil

Die Empfehlungen der letzten Abschnitte waren eher allgemeiner Natur und könnten so fast auch für C-Programme gelten. Im Folgenden will ich Ihnen noch ein paar C++-spezifische Ratschläge geben.

Zusammenfassung

In diesem Abschnitt möchte ich auf eine Zusammenfassung verzichten, denn eigentlich ist bereits der gesamte Abschnitt eine Zusammenfassung, nämlich wichtiger Konventionen, Ratschläge und Empfehlungen. Sie haben sicher erkannt, dass das Erlernen des Programmierens mit C++ nicht nur eine Frage der Syntax ist, sondern dass auch das Design und der geschickte Umgang mit den Sprachelementen entscheidet. Auf Ihrem Linux-Rechner können Sie viele Anwendungen im Sourcecode installieren, von denen eine Reihe in C++ geschrieben ist. Nicht alle diese Programme halten sich an alle Tipps aus diesem Abschnitt; Sie werden jedoch überraschend viele davon berücksichtigt finden und überdies am Durchgehen dieses Codes einiges lernen. Begeben Sie sich doch mal auf die Suche!


next up previous contents index
Next: Fortgeschrittenes C++ Up: C++-Entwicklung mit Linux Previous: Grundlagen der objektorientierten Programmierung   Contents   Index
thomas@cpp-entwicklung.de