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:
getopt()-Funktion (ab Seite
298).
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.
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++:
Anweisung;
Wenn Sie auch etwas tun möchten, wenn die Bedingung nicht erfüllt ist, sieht das Ganze so aus:
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:
{
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:
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.
Überlegen Sie bitte bei jedem der folgenden Beispiele erst, wo der Fehler stecken könnte, bevor Sie die Erklärung lesen:
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.
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".
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.
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.
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.
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: | } |
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.
{
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.
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:
{
case Konstante1: Anweisung1; break;
case Konstante2: Anweisung2; break;
// ...
default: StandardAnweisung;
}
Dabei müssen Sie Folgendes beachten:
char, int oder long. Es geht mit switch also nicht,
dass Sie Verzweigungen in Abhängigkeit von Gleitkommazahlen,
ganzen Textpassagen oder Objekten erstellen.
if nur eine Anweisung
stehen darf (mehrere Anweisungen hinter if erfordern die
Zusammenfassung zu einem Block), sind bei case beliebig viele
erlaubt. Eine Klammerung ist nicht zwingend erforderlich, in
einigen Fällen indes sinnvoll.
case-Befehl. Sie wird so lange fortgesetzt, bis der
switch-Block zu Ende ist oder eine break-Anweisung auftritt.
Sie können break auch bei anderen Abläufen mit Blöcken
finden. Diese Anweisung bedeutet stets: Beende die laufende
Abarbeitung und verlasse den aktuellen Block, in unserem
Fall den switch-Block (siehe auch Seite 310). Sie sollten alle
case-Anweisungsfolgen mit break abschließen - sonst laufen Sie
direkt in die Anweisungen des nächsten Falls hinein.
switch ein Ergebnis liefert, das mit
keiner der angegebenen Konstanten übereinstimmt, wird die
Standardanweisung ausgeführt,
die hinter default steht. Prinzipiell können Sie den default-Teil
auch weglassen. Ich empfehle Ihnen aber nachdrücklich, bei jeder
switch-Konstruktion einen solchen Anweisungsteil anzugeben.
Selbst wenn Sie nicht erwarten, dass jemals der Ausdruck
einen anderen als die vorgesehenen Werte annimmt, ist er Ihre
Absicherung dafür, wenn es doch einmal passieren sollte. An
dieser Stelle bietet sich dann die Ausgabe einer Fehlermeldung
an, damit Sie gleich sehen, dass etwas mit Ihrem Programm nicht
in Ordnung ist. 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 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 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.
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:
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: | } |
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.
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:
{
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:
{
_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.
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.
Bei der ersten Form wird die Abbruchbedingung zuerst überprüft, bevor der Schleifenblock betreten wird. Die Syntax lautet:
{
// 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.
{
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.
Das Gegenstück zur Schleife mit Anfangsprüfung ist die Schleife mit do
und while.
{
// 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.
|
|
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

{
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. 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:
{
// 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
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:
// 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.
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.
{
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) =
im Bereich von -10 bis 10 mit
Schrittweite 1 ausgeben wollen, können Sie schreiben:
{
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.
Aus diesem Abschnitt sollten Sie sich Folgendes merken:
if-Anweisung ausgedrückt. Hinter dieser folgt eine einzelne
Anweisung oder ein Block. Anweisungen für den Fall, dass die
Bedingung nicht erfüllt ist, können in einem else-Teil angegeben
werden. Auch sind Verschachtelungen von Bedingungen möglich.
true
beziehungsweise ungleich 0 nicht explizit hinschreiben. Eine
Formulierung in der Form if(variable) ... ist ebenso zulässig.
if-Anweisungen sind die falsche
Schreibweise des Gleichheitsoperators als Zulassungsoperator
(ein = wird vergessen), ein überflüssiges Semikolon hinter
der Bedingung, Mehrdeutigkeiten durch falschen Schreibstil bei
Verschachtelungen sowie die gemischte Verwendung von int und
unsigned in einer Bedingung.
switch-Anweisung kann eine Auswahl unter mehreren
getroffen werden. Dabei muss allerdings der Ausdruck ein
Ergebnis haben, das nach int umwandelbar ist. Sein Resultat
wird dann mit den bei case angegebenen Konstanten verglichen.
Ein Fall default ist zwar optional, sollte aber immer angegeben
werden.
break-Anweisung, mit der der switch-Block verlassen
wird, werden alle weiteren Anweisungen bis zum nächsten break
oder dem Ende des Blocks ausgeführt.
while erstellen Sie eine Schleife, in der die Bedingung zu
Beginn jedes Durchlaufs überprüft wird. Ist sie schon beim
ersten nicht erfüllt, wird der Schleifenkörper erst gar nicht
betreten. Die gegenteilige Überprüfung erreichen Sie mit der
do/while-Kombination, bei der am Ende einer Iteration die
Bedingung geprüft wird.
for-Schleife beinhaltet die drei Teile Initialisierung,
Bedingung und Anpassungsanweisung. Von diesen darf aber auch
jeder Teil leer bleiben. Diese Schleifen lassen sich stets auch in
while-Schleifen umformulieren.
break-Anweisung
in einer Schleife bewirkt, dass diese bei Erreichen abgebrochen
wird. Dagegen sorgt die continue-Anweisung dafür, dass der Rest
des Schleifenkörpers übersprungen und die Ausführung bei der
nächsten Iteration fortgesetzt wird.
for (i=0; i<10; i++)
x += i;
cout << x << `` `` << i << endl;

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.
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.
n als Parameter
übergeben bekommt und die dann alle Fibonacci-Zahlen F1,...,Fn
berechnet und ausgibt. Testen Sie die Funktion auf geeignete
Weise.
kann unter anderem durch die folgende unendliche Reihe
berechnet werden:

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.
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
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.
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.
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):
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.
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.
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:
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:
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:
Auf diese Weise wird eine Bildschirmausgabe vollständig unterdrückt.
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:
ofstream für die Ausgabe in eine Datei
ifstream für die Eingabe aus einer Datei
fstream für Ein- und AusgabeWenn 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 <fstream>
using namespace std;
Zum Öffnen einer Datei gibt es zwei Möglichkeiten. Zur Identifikation dient in beiden Fällen der Dateiname.
open() verwenden. Diese
verlangt ebenfalls den Dateinamen als Argument.
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.
| |||||||||||||||||||||||||||||||||||
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:
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.
{
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.
// ... verschiedene Ausgaben
resfile.close();
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:
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.
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:
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.
ifstream in(``myfile.txt'');
// ...
getline(in, buffer);
Dazu müssen Sie natürlich die Header-Datei string in Ihr Programm einbinden.
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.
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:
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:
fail(), das bei fehlerhafter Ein-/Ausgabe, bei ungültiger
Operation und bei sonstigen nicht behebbaren Fehlern einen
Wert ungleich 0 liefert, sowie
eof(), das am Dateiende ungleich 0 zurückgibt (end of file).Obige Schleife heißt damit etwa:
// ...
}
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: | } |
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).
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:
ios_base::beg (ab Anfang des Streams)
ios_base::cur (ab der aktuellen Position)
ios_base::end (ab dem Ende des Streams) 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.
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
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.
out << ``Debug-Ausgabe: `` << endl;
out.flush(); // Puffer sofort leeren
cout << ``Schritt 2 erreicht! `` << endl << flush;
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.
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.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:
<< 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.
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 << 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.
<< 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.precision(4);
cout << 1234.56789012345 << endl;
// ergibt: 1.2346e+03
Ü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.
| ||||||||||||||||||||||||||||||||
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: | } |
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?
--------------------------------------------
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: | } |
In diesem Programm dürften gleich einige Dinge für Sie neu sein. Betrachten wir es also etwas genauer.
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
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
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.
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.
Auch dieses Beispiel enthielt einige Zusatzinformationen über die Anwendung der Streams hinaus. Sie haben gelernt,
stringstream-Objekte zum Aufbau von Strings im
Speicher einsetzt,
getpid() die ID des Prozesses
bestimmt,
system() aus dem eigenen
starten kann.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.
Vom gesamten Abschnitt sollten Sie Folgendes in Erinnerung behalten:
cin für die Standardeingabe (Tastatur),
cout für die Standardausgabe (Bildschirm) und cerr für
die Standardfehlerausgabe (Bildschirm). Alle Standardkanäle
können auch umgeleitet werden.
¿¿-Operator. Es werden
so lange Zeichen akzeptiert, bis der Benutzer die Eingabetaste
drückt.
¡¡-Operator. Mehrere Ausgaben
unterschiedlichen Typs können darüber miteinander verkettet
werden. Es werden alle auszugebenden Zeichen hintereinander
gefügt; der Ausdruck endl sorgt für einen Zeilenumbruch.
ofstream (Ausgabe in eine Datei), ifstream (Eingabe aus
einer Datei) und fstream (Ein- und Ausgabe). Um Zugriff auf
eine Datei zu haben, muss man ein Objekt einer dieser Klassen
anlegen. Dazu ist das Einbinden der Header-Dateien iostream
und fstream notwendig.
open() auf.
Das Schließen erfolgt automatisch im Destruktor, kann aber auch
mit close() erreicht werden.
fail()
beziehungsweise eof() aufrufen.
seekp() für einen ostream und seekg
für einen istream. Um eine Position zu bestimmen, stehen die
Methoden tellp() beziehungsweise tellg() zur Verfügung.
width() oder der Manipulator setw() bestimmen
die Breite der unmittelbar folgenden Ausgabe. Die Anzahl der
Ziffern von Gleitkommazahlen wird mit der Methode precision()
oder dem Manipulator setprecision() gesteuert. Eine Festlegung
ist bis zum nächsten Aufruf von precision() gültig. Weitere
Ausgabeflags können über die Methode setf() gesetzt werden.
ofstream oder ifstream
immer mit einem expliziten Aufruf von close() schließen?
ostringstream?
main()-Methode sollen zwei
Kommandozeilenargumente, nämlich die Namen von Quelldatei und
Zieldatei, verarbeitet und der Filtervorgang damit aufgerufen
werden.
Datum auch eine Klasse Uhrzeit.
Diese soll folgende Funktionsmerkmale umfassen:
long-Zahl, welche die Sekunden seit 0:00 Uhr angibt, die
Uhrzeit bestimmt.
enthalten, welche die Differenz zwischen zwei Zeitpunkten in Sekunden berechnet.
Hinweis: Bestimmen Sie die Größe der Quelldatei durch Positionierung des Dateizeigers auf das Ende.
/8 zwischen 0 und 2
formatiert ausgibt.
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.)
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.
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:
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.)
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[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.
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:
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:
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 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:
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.
{
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.
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:
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
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:
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.
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.
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.
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.
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.
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.
Man deklariert einen Zeiger auf ein Objekt vom Typ T, indem man den
*-Operator hinter den Typ setzt, etwa
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:
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!):
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:
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* 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.
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.
(*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:
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_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.
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.
Bevor Sie auf einen Zeiger zugreifen, sollten Sie sicherstellen, dass er auf ein existierendes Objekt zeigt.
*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.
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 *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
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 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:
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.
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.
| |||||||||||||||||||||||||||||||||||||
Das Anlegen von Objekten auf dem Heap hat aber auch noch einen
weiteren Vorteil: Sie können Objekte Ã
ber den Gültigkeitsbereich einer
Funktion oder Klasse hinaus weiterleben lassen. Da Sie die dynamisch
erzeugten Objekte selbst wieder freigeben müssen, heißt das natürlich auch,
dass diese so lange existieren, bis sie ausdrücklich freigegeben werden -
eben auch über das Ende einer Funktion hinaus.
Wann sollte man also was nehmen? Immer, wenn Sie beim Schreiben des Programms genau wissen, wie groß ein Feld sein wird, können Sie dieses statisch reservieren - sofern nicht allzu viele davon bei Ihnen angelegt werden müssen. In den meisten Fällen ist es aber leider so, dass die Größe im Voraus nicht genau bestimmbar ist. Dann bleibt Ihnen nichts anderes übrig, als Ihr Array auf dem Heap anzulegen. Dasselbe gilt, wenn es sich um sehr viele Elemente handelt. Es ist jedoch sinnvoll, den Umgang mit Feldern in Klassen zu kapseln, damit nicht jeder Programmabschnitt mit dynamischem Speicher hantieren muss, sondern das von den Methoden der entsprechenden Klasse komplett erledigt wird.
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:
Wollen wir also ein Objekt vom Typ Button anlegen und damit arbeiten,
können wir schreiben:
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
{
public:
Datum();
Datum(unsigned int _t,
unsigned int _m, unsigned int _j);
// ...
};
Wenn wir Objekte dieses Typs dynamisch anlegen, können wir beispielsweise schreiben:
{
// Standardkonstruktor
Datum* pHeute = new Datum;
// Konstruktor mit 3 Argumenten
Datum* pOstern = new Datum(4,4,1999);
// ...
}
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.
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.
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
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.
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* 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.
// ...
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.
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:
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.
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.
Für eine Matrix, also ein zweidimensionales Feld, hat das etwa folgende Form:
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:
Zum Freigeben müssen Sie in der umgekehrten Reihenfolge vorgehen:
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.
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:
{
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:
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.
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.
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.
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.
{
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 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.
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
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.
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.
{
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.
{
private:
ListElement* erstes;
ListElement* letztes;
int anzahl;
Die Methoden bieten genau die Funktionalität, die wir von der Klasse erwarten.
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.
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().
{
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.
{
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.
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.
Folgende Aspekte aus diesem Abschnitt sollten Sie im Gedächtnis behalten:
[]-Operator. Die Größe eines Feldes muss immer durch eine
Konstante angegeben werden.
char
dargestellt. Jede Zeichenkette enthält als letztes Zeichen den
Wert 0, der das Ende des Strings anzeigt. In C++ sollte man
aber lieber die Klasse string der Standardbibliothek verwenden.
* hinter dem Grundtyp an, etwa int*.
Die Adresse einer Variablen erhalten Sie, indem Sie den
Adressoperator & davorsetzen. Auf den Inhalt der Speicherstelle,
auf die ein Zeiger verweist, können Sie durch einen * vor
der Zeigervariablen zugreifen (Dereferenzierung genannt). Bei
Zeigern auf Objekte können Sie zum Zugriff auf einzelne
Elemente auch den Pfeiloperator -¿ verwenden, zum Beispiel
ListElement-¿naechstes.
[]-Operator können Sie auch das Feld wie einen Zeiger
dereferenzieren.
0 gesetzt werden. Dereferenzierungen
von Nullzeigern führen zum Programmabsturz.
new reserviert Speicher für das danach angegebene
Objekt und liefert einen Zeiger darauf zurück. Der Operator
delete zum Freigeben braucht einen solchen Zeiger als Argument.
Der Speicher ist unabhängig vom aktuellen Gültigkeitsbereich,
wie Block oder Methode, belegt, bis er durch delete wieder
freigegeben wird.
new muss es ein zugehöriges delete geben, sonst
entstehen Speicherlöcher. Allerdings darf delete nur einmal auf
ein Objekt angewendet werden, sonst stürzt das Programm ab.
Die Anwendung von delete auf Nullzeiger ist ohne Wirkung.
a[10] keinen Wert zuweisen,
wenn das Feld a zehn Elemente hat?
-¿?
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: }

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.
Stack, die einen einfachen
Stack (dt. Stapel oder Kellerspeicher) realisiert. Dieser soll folgende
Eigenschaften haben:
double).
push())
und wieder entnommen werden (Methode pop()). Dabei soll
die Reihenfolge der Entnahme entgegengesetzt zur Ablage
sein (last in - first out).
isEmpty(),
isFull(), getSize(), clear(), push(), pop() sowie einen
Standardkonstruktor. 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.
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

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