Kapitel 3
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:

3.1 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.

3.1.1 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 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 2.8.7 (ab Seite 272). 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 352). 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 Umrechnung 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.

3.1.2 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 Konstante1: Anweisung1; break;

  case Konstante2: Anweisung2; 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

Bereits auf Seite 178 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 123 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.

3.1.3 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 3.1 auf Seite 306). 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 zweieinhalbtausend 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.



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

PIC


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

 integral  a
   x2dx
 b
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 ( Initialisierung; Bedingung; Anpassung)

{

  // 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.

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) = 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.

3.1.4 Zusammenfassung

Aus diesem Abschnitt sollten Sie sich Folgendes merken:

3.1.5 Ü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. Der Geistliche Christoph Zeller hat 1885 eine Formel aufgestellt, mit der sich für ein gegebenes Kalenderdatum der Wochentag berechnen lässt. Sie lautet:
        (   (m + 1).26   5j  h        )
w =  t+ ----10---- + 4-+ -4- 2h -1  mod 7
    Dabei ist t der Tag des Monats (zwischen 1 und 31), m der Monat selbst (1 bis 12, wobei Januar und Februar als Monate 13 und 14 des Vorjahres eingesetzt werden müssen), j das Jahr im Jahrhundert (also zwischen 0 und 99) sowie h das Jahrhundert (zweistellig, z.B. 19 oder 20). Alle Divisionen sind dabei rein ganzzahlig zu verstehen.

    Schreiben Sie ein Programm, das drei Zahlen von der Kommandozeile einliest, nämlich Tag, Monat und Jahr. Anschließend soll es den Wochentag als ausgeschriebenes deutsches Wort ausgeben. In der Formel steht dabei 0 für Sonntag, 1 für Montag usw.

  4. Schreiben Sie ein C++-Programm, das ermittelt, wann ein Freitag auf den 13. eines Monats fällt. Überlegen Sie sich, von welchem Parameter es abhängt, in welchem Monat ein Freitag, der 13. auftritt. Ihr Programm soll für alle Möglichkeiten auflisten, in welchem Jahr wie viele Freitage, der 13. auftreten und in welche Monate sie fallen.

    Hinweis: Da das zu lösende Problem vom ersten Tag des Jahres abhängt, können nur sieben verschiedene Fälle in Betracht kommen. Berücksichtigt man noch die Existenz von Schaltjahren, verdoppelt sich die Anzahl der zu untersuchenden Fälle.

  5. Die Fibonacci-Zahlen sind eine rekursiv definierte Zahlenfolge. Dabei ist F0 = 0, F1 = 1 und Fn = Fn-1 + Fn-2. Damit kann man beispielsweise das Wachstum einer Tierpopulation gut annähern. Schreiben Sie eine C++-Funktion, welche die Zahl n als Parameter übergeben bekommt und die dann alle Fibonacci-Zahlen F1,...,Fn berechnet und ausgibt. Testen Sie die Funktion auf geeignete Weise.
  6. Welche Probleme verbergen sich in folgenden Schleifen:

    1. while (i¿0) k *= 2;
    2. while (i!=0) i -= 2;
    3. while (n!=i) n = 2*++i;

  7. Die Zahl p kann unter anderem durch die folgende unendliche Reihe berechnet werden:
    p = 4- 4 + 4 - 4+  4- -4 + ...
       3   5   7   9  11
    Schreiben Sie ein C++-Programm, das die Zahl p approximiert, indem Sie immer einen weiteren Term dieser Reihe hinzunehmen. Formulieren Sie Ihr Programm sowohl mit einer for-Schleife als auch mit einer do-Schleife, die mit break abbricht, wenn die gewünschte Genauigkeit erreicht ist.
  8. Pythagoräische Tripel sind jeweils drei ganze Zahlen (a,b,c), die die Gleichung a2 + b2 = c2 erfüllen, z.B. (3,4,5). Schreiben Sie ein Programm, das alle pythagoräischen Tripel findet, für die a + b + c < 500 gilt.
  9. Ein sehr einfaches Verfahren, um Texte zu verschlüsseln, ist die Caesar-Chiffrierung. Dabei wird jeder Buchstabe des Eingabetextes durch den Buchstaben ersetzt, der in einem festgelegten Abstand dahinter im Alphabet steht. Wir wollen dabei nur Groß- und Kleinbuchstaben von A bis Z bzw. a bis z verschlüsseln, keine Umlaute oder Sonderzeichen. Falls die Verschiebung über Z bzw. z hinaus geht, soll wieder bei A bzw. a begonnen werden.

    Schreiben Sie eine Klasse Caesar, die diese Chiffrierung implementiert. Dabei soll der Abstand als Argument dem Konstruktor übergeben werden. Fehlt dieser Parameter, soll als Vorgabewert 6 angenommen werden. Die Klasse soll zudem über eine Methode

    char Caesar::encode(char _input)

    verfügen, die ein unverschlüsseltes Zeichen erhält und das verschlüsselte Gegenstück zurückliefert. Außerdem soll natürlich noch das Pendant Caesar::decode() verfügbar sein. Testen Sie Ihre Klasse an einem Beispiel, das mittels std::cin über die Tastatur vom Benutzer eingegeben wird.

3.2 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.

3.2.1 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 310. 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.

3.2.2 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 Seiten 164 und 401), also etwa:

#include <iostream>

#include <fstream>

  

using namespace std;



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

PIC


Öffnen der Datei

Zum Öffnen einer Datei 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 die Spezifizierer aus Tabelle 3.1 verwenden.



Tabelle 3.1: Die wichtigsten Spezifizierer beim Öffnen von Dateien


Spezifizierer

Beschreibung



ios_base::app

Datei wird geöffnet, neue Daten werden an das Ende angehängt

ios_base::ate

Dateizeiger wird beim Öffnen auf das Ende positioniert

ios_base::binary

Datei wird im Binärmodus geöffnet (Voreinstellung ist Textmodus)

ios_base::nocreate

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



  


Hier noch eine wichtige Anmerkung: Der 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(``temp.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 562.

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 Steuersequenzen sind:



Sequenz Bedeutung


\b Backspace
\n neue Zeile (entspricht endl)
\r Wagenrücklauf (carriage return)
\" Anführungszeichen
\\ Backslash


  

 

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 178). Der Datentyp char** ist ein Feld von Zeigern; wir werden ab Seite 352 genauer darüber sprechen. Fehlt in der Kommandozeile eine Angabe, so brechen wir gleich ab (Zeilen 9-13). Ebenso verfahren wir, wenn sich eine Datei mit diesem Namen nicht finden beziehungsweise öffnen lässt (Zeilen 16-20). 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).

3.2.3 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'');

  in.seekg(0, ios_base::end);       // Zum Ende der Datei  

  streampos sp = in.tellg();        // Aktuelle Position (=Ende) 

  in.seekg(-sp/2, ios_base::cur);   // Zur Mitte der Datei 

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;

3.2.4 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 . 10n oder kurz xe + 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 3.2 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.



Tabelle 3.2: Mit diesen Flags lassen sich weitere Ausgabeeigenschaften festlegen


Flag

Beschreibung



ios_base::left

linksbündige Ausgabe

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

Exponentialformat

io_bases::fixed

Festkommaformat (precision() gibt dann Zahl der gültigen Ziffern an)



  

3.2.5 Beispiel: Umrechnung Dollar - Euro

Als Beispiel wollen wir eine kleine Tabelle erzeugen, die uns die immer noch nötige Umrechnerei von Dollar 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.1968; 
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) << "US-$" << setw(10)  
15:         << "Euro" << " | " << setw(10)  
16:         << "Euro" << setw(10) << "US-$" << 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.

Den Wechselkurs legen wir als Konstante in Zeile 9 fest. Sie können diese Zahl selbstverständlich jederzeit auf den jeweils aktuellen Kurs anpassen. Ich habe für dieses Beispiel den Kurs von 1,1968 vom 14.06.2004 genommen.

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?

      US-$      Euro |       Euro      US-$

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

      1.00      0.84 |       1.00      1.20

      2.00      1.67 |       2.00      2.39

      3.00      2.51 |       3.00      3.59

      4.00      3.34 |       4.00      4.79

      5.00      4.18 |       5.00      5.98

     10.00      8.36 |      10.00     11.97

     20.00     16.71 |      20.00     23.94 ...

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.1968; 
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*{Umrechnungstabelle" 
24:        << " Dollar -- Euro}" << endl; 
25:    out << "\\begin{tabular}{rr|rr}" << endl; 
26:    out << "US-\\$ & EUR & EUR & US-\\$ \\\\ \\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 3.3 (denn auch dieses Buch ist mit LATEX erstellt).

Tabelle 3.3: Die formatierte Umrechnungstabelle sieht im Druckbild wesentlich ansprechender aus als die unformatierte.
US-$ EUR EUR US-$




1.00 0.84 1.00 1.20
2.00 1.67 2.00 2.39
3.00 2.51 3.00 3.59
4.00 3.34 4.00 4.79
5.00 4.18 5.00 5.98
10.00 8.36 10.00 11.97
20.00 16.71 20.00 23.94
30.00 25.07 30.00 35.90
40.00 33.42 40.00 47.87
50.00 41.78 50.00 59.84
100.00 83.56 100.00 119.68
200.00 167.11 200.00 239.36
300.00 250.67 300.00 359.04
400.00 334.22 400.00 478.72
500.00 417.78 500.00 598.40
1000.00 835.56 1000.00 1196.80

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 508 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 316). 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()

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-Rechner 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 50).

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 im Rahmen dieses Buches leider nicht auf dieses Thema eingehen. Wenn Sie mehr wissen wollen, können Sie beispielsweise den Klassiker [HETZE et al. 1997] 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 47 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.

3.2.6 Zusammenfassung

Vom gesamten Abschnitt sollten Sie Folgendes in Erinnerung behalten:

3.2.7 Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Schreiben Sie ein Programm, das HTML-Code (der Einfachheit halber alles, was sich zwischen einem Paar spitzer Klammern befindet) aus einer Datei entfernt. Die Filterung selbst soll in einer eigenen Klasse erfolgen. In der main()-Methode sollen zwei Kommandozeilenargumente, nämlich die Namen von Quelldatei und Zieldatei, verarbeitet und der Filtervorgang damit aufgerufen werden.
  3. Entwerfen Sie analog zur Klasse Datum auch eine Klasse Uhrzeit. Diese soll folgende Funktionsmerkmale umfassen:

    1. Sie soll Attributsvariablen für Stunde, Minute und Sekunde haben.
    2. Die Klasse soll beim Standardkonstruktor mit der aktuellen Uhrzeit initialisiert werden.
    3. Es soll aber auch ein Konstruktor mit Argumenten möglich sein, der eine bestimmte Zeit vorgibt.
    4. Außerdem soll es einen Konstruktor geben, der aus einer long-Zahl, welche die Sekunden seit 0:00 Uhr angibt, die Uhrzeit bestimmt.
    5. Für Stunden, Minuten und Sekunden sollen Zugriffsmethoden zum Lesen und Schreiben vorhanden sein.
    6. Die Klasse soll eine Methode

      long Uhrzeit::diff (Uhrzeit& _andere);

      enthalten, welche die Differenz zwischen zwei Zeitpunkten in Sekunden berechnet.

  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.
  5. Schreiben Sie ein Programm, das den Benutzer einen Dateinamen und eine Zahl eingeben lässt. Es soll dann diese Datei in mehrere Dateien der angegebenen Größe aufspalten. Die Zahl soll dabei die Größe in Kilobytes angeben. Beispiel: Es wird "myfile.txt" und "8" eingegeben. Ihr Programm stellt fest, dass die Ausgangsdatei myfile.txt genau 36800 Bytes groß ist. Dann soll das Programm fünf Dateien myfile.txt.1 bis myfile.txt.5 erzeugen. Die ersten vier sollen dabei 8192 Bytes, die letzte 4032 Bytes umfassen.

    Hinweis: Bestimmen Sie die Größe der Quelldatei durch Positionierung des Dateizeigers auf das Ende.

  6. Schreiben Sie ein Programm, das die Werte der Sinusfunktionen an den Vielfachen von p/8 zwischen 0 und 2p formatiert ausgibt.
  7. In den Übungen zu Abschnitt 3.1.5 (ab Seite 313) haben Sie die Caesar-Verschlüsselung kennen gelernt. Schreiben Sie ein Programm, das als Kommandozeilenargumente den Namen einer Textdatei sowie eine Zahl (den Abstand) akzeptiert. Es soll sodann die Datei mit diesem Abstand chiffrieren und das Ergebnis in einer anderen Datei speichern. Verwenden Sie daher Ihre Klasse Caesar aus Abschnitt 3.1.5. (Hinweis: Für die Umwandlung des Kommandozeilenarguments in eine Zahl können Sie die Funktion atoi() einsetzen. Dafür müssen Sie die Header-Datei cstdlib einbinden.)

3.3 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.

3.3.1 Felder (Arrays)

Ein Feld (engl. 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.)



Abbildung 3.3: Ein Feld ist eine Menge gleichartiger Variablen hintereinander.

PIC


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, ..., 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 × 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 506.

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.

3.3.2 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 508). 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 Zeichenfelder mit variabler Größe anzulegen.

3.3.3 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.



Abbildung 3.4: Zeiger sind Verweise auf Speicheradressen.

PIC


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;

  Button* pStartButton;

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 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;

 

  Button StB;

  Button* pStartButton = &StB;

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.

  Button* pStartButton = &StB;

  (*pStartButton).setHoehe(45);

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:

  pStartButton->setHoehe(45);

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 0. Ein mit 0 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 0 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 0 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.

3.3.4 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 dabei sind in Tabelle 3.4 dargestellt.



Tabelle 3.4: Die Vor- und Nachteile der Speichernutzung auf Stack und Heap



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 (==> Fehlerquelle!)



  


Das Anlegen von Objekten auf dem Heap hat aber auch noch einen weiteren Vorteil: Sie können Objekte Ã1
4ber 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 vom Typ Button anlegen und damit arbeiten, können wir schreiben:

  Button* b = 0;   //Zeiger initialisieren

  r = new Button;  //Speicher reservieren

  //Objekt verwenden

  r->setHoehe(20);

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 2.7 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.

  Button* b = 0;         //Zeiger initialisieren

  b = new Button;        //Speicher reservieren

  // ..                    Objekt verwenden

  delete b;              //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 575) 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.

  Button* r = new Button;

  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 weiterzukommen, 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 0 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.



Abbildung 3.5: Eine Matrix ist ein Zeiger auf ein Feld von Zeigern.

PIC


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.

3.3.5 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 0, 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 Objekt 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 0 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.

3.3.6 Beispiel: Verkettete Listen

Neben dem Vektor gibt es noch eine Reihe weiterer dynamischer Datenstrukturen, die so konstruiert sind, dass sie genauso viel Speicherplatz in Anspruch nehmen, wie sie Elemente enthalten. Bekannt sind etwa



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

PIC


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 3.7). 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 0 hat.



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

PIC


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 Beispiel wollen wir eine Liste erstellen, die Paare von Zeichenketten verwalten kann, nämlich Schlüssel und zugehörige Werte. Derartige Paare treten zum Beispiel bei der Verarbeitung von Argumentlisten oder Konfigurationsdaten auf.

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 werfen wir noch einen Blick auf eine mögliche Verwendung der Liste. 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 0 steht.

  ListElement* tmp; 

  for(tmp = _liste.front(); tmp != 0; tmp = tmp->naechstes)

  {

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

    cout << tmp->wert << endl;

  }

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

Anhand der Listenklasse sollte Ihnen 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.

3.3.7 Zusammenfassung

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

3.3.8 Ü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
    |A|:= a11A11- a12A12 + a13A13 - + ...a1nA1n,
    wobei die Unterdeterminante Akl 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 3.3.6 in eine doppelt verkettete Liste um. Fügen Sie dazu auch entsprechende Zugriffsmethoden hinzu und definieren Sie einen Kopierkonstruktor.

    Welche zusätzlichen Methoden erlaubt diese Datenstruktur gegenüber der einfach verketteten Liste? Versuchen Sie, diese geeignet zu testen.

  5. Implementieren Sie eine C++-Klasse Stack, die einen einfachen Stack (dt. Stapel oder Kellerspeicher) realisiert. Dieser soll folgende Eigenschaften haben:

    Testen Sie Ihre Klasse geeignet. Versuchen Sie anschließend die Klasse so zu erweitern, dass auch Objekte (zum Beispiel von unserem Typ Datum, siehe Seite 204) auf dem Stack abgelegt werden können.

  6. In den Übungen zu Abschnitt 3.1.5 (ab Seite 313) haben Sie die Caesar-Verschlüsselung kennen gelernt. Es ist recht offensichtlich, dass diese relativ leicht zu knacken ist. Schwieriger und damit zuverlässiger ist die Vigenère-Chiffrierung, die der französische Diplomat Blaise de Vigenère im 16. Jahrhundert als Verbesserung der bis dahin gebräuchlichen Caesar-Chiffrierung entwickelt hat. Dabei wird als Schlüssel ein Wort festgelegt, genauer eine Zeichenfolge von n Zeichen. Zur Chiffrierung wird der Schlüssel nun hintereinander über den Klartext gelegt und dann die einzelnen Zeichen des Textes und des Schlüssels addiert. Wollen wir etwa den Text s =DIESISTEINSATZ verschlüsseln und wählen dafür den Schlüssel k =LINUX, so ergibt sich damit das Chiffre c =OQRMFDBRCKDIGT. Ordnet man den Buchstaben die Zahlen 0 bis 25 zu, so kann man die Zeichen mittels der Formel
    c = (s+ k)mod 26
    berechnen.

    Schreiben Sie ein Programm, das als Kommandozeilenargumente den Namen einer Textdatei sowie ein Schlüsselwort akzeptiert. Es soll sodann die Datei mit diesem Schlüssel chiffrieren und das Ergebnis in einer anderen Datei speichern. Implementieren Sie dabei die Chiffrierung in einer eigenen Klasse - analog zu der oben beschriebenen Klasse Caesar für das gleichnamige Verschlüsselungsverfahren.

3.4 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.

3.4.1 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 - dem Standard gemäß - 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 158). 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 158.

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.



Tabelle 3.5: Über die Header der Standardbibliothek erhalten Sie Zugriff auf viele wichtige Routinen.



Header

Inhalt

s. S.