char
-Strings
(cstring)
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:
getopt()
-Funktion (ab Seite
Zu den grundlegenden Sprachelementen, auf die ich bislang nicht eingegangen bin, gehören auf alle Fälle die Bedingungen und die Konstrollstrukturen. Ohne die 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, ansonsten etwa anderes. Die Wörtchen wenn und sonst heißen auf Englisch if und else und genauso heißen auch die Befehle in C++:
if (Bedingung)
Anweisung;
if (Bedingung)
Anweisung1;
else
Anweisung2;
if (Bedingung)
{
Anweisung1;
Anweisung2;
}
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. Vor else
darf dann kein Semikolon
mehr kommen, sonst hängt das else
in der Luft. Kompliziert? Ich
werde Ihnen gleich anhand einiger typischer Fehler noch ein paar Tipps geben.
Doch zunächst zur Frage: Was ist eigentlich eine Bedingung? Es muss sich dabei um
einen Ausdruck handeln, der einen Ganzzahltyp oder bool
ergibt. Dabei
können Sie alle Vergleichsoperatoren verwenden, also <
, >
, ==
und so weiter. Die Bedingung gilt als erfüllt, wenn sie true
oder verschieden
von 0 ist.
Die Ausdrücke dürfen auch logisch miteinander verknüpft werden, etwa durch die
Operatoren &&
für die UND-Verknüpfung, |
für die
ODER-Verknüpfung und |
!
für die Negation.
Beispiele sind:
if (a>0)
x = x/a;
if (p != 0 && x!=0)
x *= p;
else
x = 1;
if (player.isActive() == true)
player.stop();
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 da der Fehler stecken könnte, bevor Sie die Erklärung lesen:
if (x==10);
tuwas(x);
tuwas()
immer ausgeführt wird, das heißt unabhängig vom Wert von x
-- und das war ja nicht beabsichtigt.
if (x=10)
tuwas(x);
=
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 (i==10)
if (k==i)
cout << ``i und k: 10!'' << endl;
else
cout << ``i ist nicht 10!'' << endl;
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.
int c = -1;
unsigned int k = 0;
if (k<c)
cout << ``k ist kleiner'' << endl;
-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 zehn Pfennige und sonst sechs Pfennige pro Minute. Am Wochenende kostet
die Minute Ferngespräch einheitlich fünf Pfennig.
1:In diesem Programm finden Sie ein Beispiel für das Konzept der abstrakten Klasse aus Abschnitt#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 = 10.0;
62:}
63:else
64:{
65:// Abends und nachts
66:minutenpreis = 6.0;
67:}
68:}
69:else
70:{
71:// Wochenende
72:minutenpreis = 5.0;
73:}
74:75:
// Gib Ergebnis aus
76:cout << "Ein " << _minuten << "-minü"
77:<< "tiges Gespräch kostet bei "
78:<< name << " jetzt " <<
79:<< minutenpreis * _minuten
80:<< " Pfennige." << endl;
81:82:
// Gib Berechnung zurueck
83:return minutenpreis * _minuten;
84:}
85:86:
//-------------------------------------
87:int main()
88:{
89:SiriusCom carrier;
90:91:
float x = carrier.berechneGebuehr(3);
92:cout << "Gesamtkosten: " << x/100
93:<< " DM." << endl;
94:95:
return 0;
96:}
97:98:
Eine davon ist SiriusCom
. Sie implementiert nur einen Konstruktor, in
dem sie den Basisklassenkonstruktor aufruft (Zeile 37), um ihren Namen zu speichern,
und die Berechnungsmethode berechneGebuehr()
.
Frage: Warum ist SiriusCom
eigentlich eine Ableitung von Telefongesellschaft
und nicht ein Objekt davon? Die Klasse Telefongesellschaft
stellt doch eine
Schablone dar, für die SiriusCom
eine konkrete Ausprägung ist. Das ist
in diesem Fall aber nur die halbe Wahrheit. SiriusCom
kann kein Objekt
sein, weil es nicht vollständig auf Methoden von Telefongesellschaft
zurückgreifen kann, sondern auch eigene implementieren muss, in diesem Fall
für die Tarifberechnung. Die abstrakte Klasse ist hier dazu da, die Schnittstelle
festzulegen, aber nicht die Implementierung vorzugeben. Die Ableitung ist dabei
die Spezialisierung, welche die Vorgaben aufnimmt und ihren eigenen Algorithmus
hinzufügt. Mit einem Objekt wäre das nicht möglich (jedenfalls nicht, wenn man
streng objektorientiert vorgeht und keine Tricks aus C versucht).
In der Methode SiriusCom::berechneGebuehr()
sehen Sie in den Zeilen 48/49
zunächst, wie Sie in Ihren C++-Programmen die aktuelle Uhrzeit
und das Datum vom Linux-System erfragen können. Vielleicht durchschauen Sie
die Details der verwendeten Syntax momentan noch nicht; man muss dabei nämlich
etwas mit Zeigern hantieren, die ich erst in einem der nächsten Abschnitte besprechen
werde (Seite ). Zumindest können Sie sich aber merken,
dass sich bei der Abfrage am Ende eine so genannte Struktur ergibt. Unter einer
Struktur versteht man eine Klasse, bei der alle Elemente, für die keine
Zugriffsbeschränkung ausdrücklich angegeben ist,
public
sind. Man kennzeichnet
sie mit dem Schlüsselwort struct
. In diesem Fall hat die Struktur tm
,
die in der Header-Datei ctime definiert ist (Zeile 3!), sogar ausschließlich
Attribute und keine Methoden.
struct tm
{
int tm_sec; // Sekunden [0-60]
// (1 Schaltsekunde!)
int tm_min; // Minuten [0-59]
int tm_hour; // Stunden [0-23]
int tm_mday; // Tag [1-31]
int tm_mon; // Monat [0-11]
int tm_year; // Jahr - 1900.
int tm_wday; // Wochentag [0-6]
int tm_yday; // Tag des Jahres [0-365]
int tm_isdst; // Sommerzeitverschiebung
// [-1/0/1]
};
time()
ist nichts anderes als die Zahl der Sekunden, die seitdem vergangen sind. Mit
diesem Wert kann ein Benutzer aber nichts anfangen. Daher gibt es noch ein paar
zusätzliche Funktionen, um die Umrechung in gebräuchliche Einheiten vorzunehmen.
Doch zurück zu unserem Programm: Zwei der Attribute der Struktur tm
sind
tm_hour
für die aktuelle Stunde und tm_wday
, das den Wochentag
angibt, wobei die Zählung mit 0 am Sonntag beginnt. Diese Attribute kopieren
wir in den Zeilen 51/52 in lokale Variablen. Von Zeile 55 bis 73 geht es dann
ganz um die Tarifbedingungen. Die äußere Bedingung (Zeile 55) ist erfüllt, wenn
wochentag
zwischen 1 und 5, also zwischen Montag und Freitag, liegt. In
diesem Fall wird der Block von Zeile 56 bis 68 abgearbeitet. Die Anweisungen
fürs Wochenende finden sich im else
-Teil ab Zeile 70. Gemäß dem Tarif
müssen wir an Werktagen noch die Uhrzeit überprüfen, was in Zeile 58 geschieht.
Der Rest des Programms besteht aus einer Ausgabe des Ergebnisses und natürlich
der main()
-Funktion. Damit sollten Sie keine Schwierigkeiten haben.
Das Beispiel zeigt Ihnen nicht nur die Verwendung der if
-Anweisung, sondern
auch, wie über Vererbung Zusammenhänge in einer Software repräsentiert werden
können. Versuchen Sie am besten, das Programm um andere Anbieter zu erweitern
und zusätzliche Funktionen oder Kriterien hinzuzufügen. Machen Sie sich auch
die Beziehungen zwischen den von Ihnen verwendeten Klassen klar.
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,
was zu sehr undurchsichtigem Code führen kann. Besser ist es da, einen Befehl
zu verwenden, der mehrere Möglichkeiten zulässt. Dieser heißt in
C++
switch
und hat folgende Syntax:
switch(Ausdruck)
{
case Konstante1: Anweisung1; break;
case Konstante2: Anweisung2; break;
// ...
default: StandardAnweisung;
}
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. Wie oben angegeben, sollten Sie also 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.
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 beispielsweise zu folgenden
Code kommen:
int berechne(const string& name)
{
int zulage = 0;
if (name == ``Andrea'')
zulage = 100;
else
if (name == ``Barbara'')
zulage = 150;
else
if (name = ``Christa'')
zulage = 200;
else
if (name == ``Doris'')
zulage = 400;
else
// ...
}
switch
-Konstrukt Gebrauch machen. Bei
diesem Beispiel ist ein möglicher, wenn auch ziemlich unschöner Trick zur Uminterpretation,
sich an den Anfangsbuchstaben der Damen zu orientieren, also etwa
int berechne(const string& name)
{
int zulage = 0;
switch (name[0])
// ist ein char, also ganzzahlig
{
case 65: zulage = 100; // Buchstabe 'A'
break;
case 66: zulage = 150;
break;
case 67: zulage = 200;
break;
case 68: zulage = 400;
break;
// ..
}
}
BereitsSec:Kommandozeile haben wir
die Parameter, die der Anwender über die Kommandozeile an ein Programm übergeben
kann, ausgewertet und angezeigt. Die Funktion main()
kann über die Argumente
argc
und argv
verfügen, wobei argc
die Anzahl der Parameter
ist (der Programmname zählt als erster Parameter) und argv
ein Feld mit
den eigentlichen Angaben. Nun wollen wir noch einen Schritt weiter gehen und
Optionen berücksichtigen, die aus einem Strich und einem Buchstaben, eventuell
mit einem Argument dahinter, bestehen -- wie etwa -o
beim GCC.
Dazu können wir die Funktion getopt()
aus der GNU-C-Bibliothek
nutzen (dafür ist das Einbinden der Header-Datei unistd.h erforderlich).
Diese ist ein sehr praktisches und elegantes Hilfsmittel, um Kommandozeilenoptionen
auszuwerten (zu parsen, wie der Fachmann sagt). Man übergibt ihr die beiden
Parameter der main()
-Funktion, also argc
und argv
, sowie
eine Zeichenkette, in der die Buchstaben, die als Optionen erkannt werden sollen,
aufgelistet sind. Falls hinter einer Option noch ein Argument folgen soll, muss
hinter dem Buchstaben in der Zeichenkette ein Doppelpunkt stehen.
Über den Aufruf von getopt()
wird immer ein Parameter ausgelesen und
in der Variablen opt
gespeichert. Darin steht entweder das Zeichen, das
die Funktion gefunden hat, also der Buchstabe hinter dem Strich (etwa
das d
bei der Option -d
), oder ?
, wenn eine unbekannte Option verwendet
wurde, oder -1, falls das Ende der Kommandozeile erreicht ist.
Normalerweise lassen sich Programme über Kommandozeilenoptionen konfigurieren oder zu einem
bestimmten Verhalten veranlassen. Beim GCC etwa haben Sie dafür auf Seite
eine Reihe von Möglichkeiten gesehen. Hier wollen wir jedoch nur ein Übungsbeispiel
erstellen. Darin soll ein Text ausgegeben werden, den der Benutzer hinter der
Option
-d
(für description) oder -t
(für text) angeben
kann. Zusätzlich wollen wir eine Zeilen- oder Fehlernummer aufnehmen, die der
Anwender anschließend an -l
(für line) oder -n
(für number)
übergeben kann. Schließlich besteht noch die Möglichkeit, im Anschluss an die
Textausgabe eine zusätzliche Leerzeile einzufügen, indem man die Option -s
(für space) wählt. Der Aufruf von getopt()
lautet damit:
getopt(argc, argv, "d:l:n:t:s");
1:#include <iostream>
2:#include <cstdlib>
3:#include <string>
4:5:
// Systembibliothek
6:#include <unistd.h>
7:8:
class Configuration
9:{
10:public:
11:string text;
12:unsigned short line;
13:bool space;
14:15:
Configuration() :
16:line(0), space(false)
17:{}
18:};
19:20:
int main(int argc, char* argv[])
21:{
22:int opt=0;
23:Configuration config;
24:25:
while((opt = getopt(argc, argv,
26:"d:l:n:t:s") )!= -1)
27:{
28:switch(opt)
29:{
30:case 'd':
31:cout << "Argument d gegeben!" << endl;
32:33:
case 't':
34:cout << "Argument t gegeben!" << endl;
35:config.text = optarg;
36:break;
37:38:
case 's':
39:cout << "Argument s gegeben!" << endl;
40:config.space = true;
41:break;
42:43:
case 'l':
44:case 'n':
45:config.line = atoi(optarg);
46:break;
47:48:
default:
49:cout << "Unbekannte Option "
50:<< (char)optopt << endl;
51:break;
52:}
53:}
54:55:
cout << "---------------" << endl;
56:57:
if (config.line)
58:cout << config.line << ": ";
59:60:
cout << config.text << endl;
61:62:
if (config.space)
63:cout << endl;
64:65:
cout << "----------------" << endl;
66:67:
return 0;
68:}
Das Programm beginnt mit einer Klasse, welche die übergebenen Konfigurationsinformationen aufnehmen soll. Für diese Aufgabe eine eigene Klasse einzusetzen, hat mehrere Vorteile: Zum einen sind damit die Parameter zusammengefasst, so dass ihre Zusammengehörigkeit sofort deutlich wird. Zum anderen bietet ein Standardkonstruktor die Möglichkeit, die Elemente automatisch mit Vorgabewerten zu belegen. Der Entwickler, der die Klasse benutzt, muss dann nicht mehr alle Variablen einzeln initialisieren, sondern kann sich darauf beschränken, die davon abweichenden Werte zu setzen. Darüber hinaus können Sie durch Ableitungen ganze Konfigurationsbäume aufbauen, mit denen sich Ihre Daten hierarchisch gliedern lassen.
Die Schleife über alle angegebenen Argumente in der Kommandozeile läuft von Zeile
25 bis Zeile 53. 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 28-52), 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 38 bis 41 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 53, also mit der Schleife fort.
Anders sieht es aus beim Argument d in Zeile 30/31. 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 43 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 45 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:
void Document::print(const Printer& _rPrinter)
{
switch(_rPrinter.type_id) {
case LASER_PRINTER:
((LaserPrinter&)_rPrinter).warmupToner();
_rPrinter.print(LASER_PRINTER, myText);
break;
case INK_JET:
((InkJet&)_rPrinter).selectColor(BLACK);
_rPrinter.print(INK_JET, myText);
break;
case FAX:
((Fax&)_rPrinter).disconnect();
_rPrinter.print(FAX, myText);
break;
}
}
init()
hinzuzufügen, die die
notwendigen Vorverarbeitungsschritte durchführt. Dann lässt sich obige Operation
nämlich ganz kurz schreiben:
void Document::print(const Printer& _rPrinter)
{
_rPrinter.init();
_rPrinter.print(myText);
}
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 sicherlich 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:
while (Bedingung)
{
// Anweisungen
}
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 Endlos-Schleife, wenn die Bedingung erfüllt ist und sich nicht
selbst modifiziert (etwa durch einen Inkrementoperator).
Ein einfaches Beispiel ist der euklidische Algorithmus. Mit ihm berechnet man seit fast zweieinhalb Tausend Jahren den größten gemeinsamen Teiler (ggT) zweier ganzer Zahlen. Eine Anwendung ist das Kürzen in der Bruchrechung; dort ist es genau der ggT von Zähler und Nenner, mit dem man einen vollständig gekürzten Bruch erhält, indem man nämlich sowohl Zähler als auch Nenner durch den ggT dividiert.
int ggT(int x, int y)
{
while(y)
{
int r = x % y; // Rest der Division
x = y;
y = r;
}
return (x);
}
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
.
do
{
// Anweisungen
}
while (Bedingung);
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 aber konstant sein
soll. Streng mathematisch ausgedrückt, berechnen wir also eine Näherung des
bestimmten Integrals
int main()
{
float a, b, h, flaeche = 0.0;
int n;
cout << "Linke Grenze ? "; cin >> a;
cout << "Rechte Grenze ? "; cin >> b;
cout << "Anzahl der Rechtecke ? "; cin >> n;
h = (b-a)/n; float x = a;
do
{
flaeche += h*x*x;
x += h;
} while (x < b);
cout << "Fläche unter Parabel ist "
<< flaeche << endl;
return 0;
}
for
.
In vielen anderen Programmiersprache, wo es diese Anweisung ebenfalls gibt,
dient sie ausschließlich dazu, eine feste Anzahl von Wiederholungen zu durchlaufen,
ausgehend von einer Zählervariablen, die bei jedem Durchlauf um eins erhöht
oder vermindert wird. Das ist zwar mit for
genauso möglich; sie arbeitet
aber viel allgemeiner und kann daher noch für viele andere Zwecke eingesetzt
werden. Die Syntax lautet:
for ( Initialisierung; Bedingung; Anpassung)
{
// Anweisungen
}
Der Standardfall ist das Hochzählen einer Schleifenvariablen innerhalb fester Grenzen, etwa
for (int i=65; i<90; i++)
cout << i << `` `` << char(i) << endl;
{
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 zum Glück diese Situation und wendet die alte Konvention
an, wenn Sie versuchen, hinter der Schleife die Variable noch zu verwenden.
Er unterrichtet Sie davon mit den Warnungen name lookup of `i' changed
for new ANSI `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 -te Element und eine Methode
vector::size()
, welche die Anzahl
der Elemente zurückliefert. Dann können wir die Summe aller Elemente unter anderem
wie folgt berechnen:
vector v;
// fülle v
...
// berechne Summe der Elemente
int k = v.size(), sum = 0;
for (; k; sum += v.at(-k));
cout << ``Summe ist `` << sum << endl;
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.
Eine häufige Anwendung von while
-Schleifen ist die Behandlung von Ereignissen.
Wenn ein Programm nicht sequenziell eine Aufgabe zu erfüllen hat, sondern mit
einem anderen Programm oder einem Benutzer interagieren soll, muss es auf Ereignisse
reagieren können, die der Partner auslöst. Die Behandlung der verschiedenen
Ereignisse findet dabei üblicherweise in einer while
-Schleife statt.
Solange kein Ende oder Abbruch signalisiert wurde, soll das Programm auf eine
Nachricht des Partners warten, diese dann in Abhängigkeit
vom Inhalt der Nachricht bearbeiten und wieder auf die nächste Nachricht warten.
Vielleicht erinnern Sie sich: Auf Seite ff. haben
wir ganz allgemein festgestellt, dass ein Grundprinzip der objektorientierten
Programmierung die Kommunikation der Objekte miteinander über Nachrichten ist.
In der Folgezeit haben wir zwar immer Nachrichten in Form von Methodenaufrufen
verschickt. Jetzt nehmen wir das Konzept aber mal wörtlich.
Nachrichten (messages) stellen unter Linux eine Möglichkeit dar, Informationen zwischen zwei Prozessen auszutauschen (also so genannte inter process communication, IPC, zu betreiben). Sie werden dabei in Nachrichtenschlangen (message queues) verwaltet. Der Absender stellt seine Nachricht in die Schlange, der Empfänger holt sie von dort ab.
Auf diese Weise können Sie einen Block von Daten zwischen zwei laufenden Programmen
austauschen. Natürlich kann das kein beliebig großer Block sein. Linux definiert
dabei die Konstanten MSGMAX = 4096
für die maximale Größe einer Nachricht
in Byte und MSGMNB = 16384
für die maximale Größe einer Nachrichtenschlange.
Unter einem anderen Unix können diese aber auch abweichende Werte haben.
Die Funktionen, die wir zur Nachrichtenübermittlung verwenden, stellt das Betriebssystem zur Verfügung. Sie sind in C geschrieben und haben mit Objektorientierung nichts am Hut. Der Aufruf wirkt daher auf den ersten Blick vielleicht etwas kryptisch. Wir werden folgende Funktionen einsetzen (für mehr Details ziehen Sie bitte die Linux-Dokumentation zu Rate; auch wenn die Schnittstellen dieser Funktionen noch etwas verwirrend aussehen, wird ihre Anwendung sicher an nachfolgendem Beispiel gleich klarer):
int msgget(key_t key, int msgflg)
:
msgflg
noch mit IPC_CREAT
verknüpfen (per
logischem ODER), wird eine neue Schlange erzeugt, wenn noch keine mit
dieser Identifikationsnummer existierte.
int msgsnd(int msgid, const void*
msg_ptr,
size_t msg_sz, int msgflag)
:
msgget()
; mit diesem wird
die Nachrichtenschlange identifiziert. Der zweite gibt die Speicherstelle an,
unter der die Nachricht abgelegt ist. Dabei ist neben der Höchstgrenze noch
zu beachten, dass das erste Datenelement der Nachricht immer eine long
-Zahl
sein muss, die dann vom Empfänger als Typ der Nachricht ausgewertet wird. Die
Nachrichtengröße, die im dritten Argument angegeben werden muss, ist ohne diese
long
-Zahl zu verstehen. An Ende steht noch ein Flag, das wir im Augenblick bei
Null belassen.
int msgrcv(int msgid, void* msg_ptr,
size_t msg_sz, long msgtype, int msgflag)
:
msgsnd()
. Der vierte Parameter ist eine Art Priorität
der Nachricht. Ist er Null, werden alle Nachrichten aus der Schlange abgefragt;
ist er größer, werden nur die Nachrichten geholt, die denselben Typ aufweisen.
Das Flag am Ende können wir wieder ignorieren.
int msgctl(int msgid, int command,
struct msgid_ds* buf)
:
IPC_RMID
an und setzen den letzten Parameter
auf 0. Wir müssen allerdings noch darauf achten, dass nur einer der beteiligten
Prozesse die Schlange wieder zerstören kann, nämlich entweder der
Absender oder der Empfänger. Anders als die Erzeugungsfunktion
bringt diese Funktion nämlich bei zweimaligem Aufruf eine Fehlermeldung. Ich habe diese
Aufgabe dem Empfänger zugewiesen.
Stellen wir uns weiter vor, wir wollen das Umsetzen nicht von Hand machen (wozu haben wir schließlich den Computer?), sondern uns eines Roboters bedienen. Der arbeitet hier zwar nur virtuell, was aber nicht bedeutet, dass Sie nicht auch einen echten Greifer anschließen dürften. Der Roboter wird vom Empfängerprozess gesteuert, das heißt, er wartet ständig auf Nachrichten, um diese gleich auszuführen.
Diese Aufgabe setzen wir in C++ folgendermaßen um: Wir trennen die einzelnen Funktionsbereiche
voneinander, indem wir für jeden eine eigene Klasse bilden. Der Absenderprozess
enthält ein Objekt der Klasse HanoiController
, die einen Algorithmus
implementiert, um das Problem zu lösen. Sie ist Unterklasse von RobotController
,
die das Senden der Befehle kapselt. Sowohl RobotController
als auch Robot
sind wiederum von der Klasse KommunikationsController
abgeleitet, in der Erzeugung und Beendigung von Nachrichtenschlangen realisiert
sind. Abbildung zeigt die Beziehungen zwischen den Klassen.
Vererbung ist in dieser Darstellungsweise (der so genannten Unified Modeling
Language, UML) durch einen dicken Pfeil von der Unter- zur Oberklasse
gekennzeichnet. Die gestrichelte Linie soll andeuten, dass diese Klasse von
Befehl
abhängig ist, sprich: ein Objekt davon verwendet.
In derTat ist Befehl
genau die Nachricht, die zwischen den Prozessen ausgetauscht
wird. Sie enthält aber nur wenige Daten. Daher kann sich die
entsprechende Klasse auch auf Konstruktoren und ein paar öffentliche
Attribute beschränken.
class Befehl
{
public:
long msg_typ;
RobotBefehl befehl;
short arg1;
short arg2;
Befehl() : msg_typ(1),
befehl(START), arg1(0), arg2(0){}
Befehl(RobotBefehl _befehl, short _arg1,
short _arg2) : msg_typ(1), befehl(_befehl),
arg1(_arg1), arg2(_arg2){}
};
RobotBefehl
ein Aufzählungstyp, der die Befehle START
,
BEWEGE
, BEWEGE_OBJEKT
und ENDE
enthält.
In der Klasse KommunikationsController
erstellen wir die Infrastruktur für
die Kommunikation, nämlich die Methoden für Auf- und Abbau der Nachrichtenschlange.
inline int KommunikationsController::initialisiereVerbindung()
{
// Initialisierung der Nachrichtenschlange
msgid = msgget((key_t)123, 0666 | IPC_CREAT);
if (msgid == -1)
{
cerr << "Nachrichtenschlange konnte nicht "
<< "erzeugt werden!" << endl;
return -1;
}
return 0;
}
inline int KommunikationsController::kappeVerbindung()
{
if (msgctl(msgid, IPC_RMID, 0) == -1)
{
cerr << "msgctl() fehlgeschlagen!" << endl;
return -1;
}
return 0;
}
RobotController
.
Von dort aus rufen wir besagte Funktion msgsnd()
auf.
int RobotController::sendeBefehl(
RobotBefehl _befehl, short arg1, short arg2)
{
Befehl einBefehl(_befehl, arg1, arg2);
int erg = msgsnd(msgid, (void*)&einBefehl,
sizeof(Befehl)-sizeof(long), 0);
if (erg == -1)
{
cerr << "msgsnd() fehlgeschlagen!" << endl;
return -1;
}
return 0;
}
Die Funktion sizeof()
hat den Zweck,
den Speicherverbrauch eines Datentyps oder einer Variablen zu bestimmen. In
diesem Beispiel liefert sizeof(long)
den Wert 4 und sizeof(Befehl)
den Speicherbedarf eines Objekts vom Typ Befehl
; die Differenz der beiden
ist also die Zahl der Bytes, die das Objekt außer der einen long
-Zahl
braucht.
Der Algorithmus zum Lösen des Problems, die Türme von Hanoi zu versetzen,
ist in der rekursiven
Methode HanoiController::bewegeScheiben()
untergebracht. Damit wird das
Problem, Scheiben zu verlagern, auf die Verlagerung von
Scheiben reduziert,
und zwar zunächst vom ersten auf den zweiten und den
dritten Stab. Am Ende befinden sich alle Scheiben auf dem zweiten Stab.
void HanoiController::bewegeScheiben(
short _tiefe, short _pos1,
short _pos2, short _pos3)
{
if (_tiefe > 1)
bewegeScheiben(_tiefe-1, _pos1, _pos3, _pos2);
sendeBefehl( BEWEGE_OBJEKT, _pos1, _pos2);
if (_tiefe > 1)
bewegeScheiben(_tiefe-1,_pos3, _pos2, _pos1);
}
int main()
{
HanoiController hanoi;
if (hanoi.initialisiereVerbindung() == -1)
return 1;
hanoi.bewegeScheiben(ANZAHL, 1, 2, 3);
hanoi.sendeBefehl(ENDE);
return 0;
}
Nun zum Empfänger: Die Methode Robot::verarbeiteNachrichten()
,
in der sich die Empfängerfunktion befindet, ist der eigentliche Grund dafür,
dass wir dieses Beispiel bei den while
-Schleifen besprechen. Sie stellt
nämlich die klassische Verarbeitungsroutine eines ereignisgesteuerten Programms
dar. Die Struktur ist letztlich immer die gleiche:
while
-Schleife, die so lange läuft, bis ein Ende
oder Abbruch signalisiert wurde.
switch
, über die die passende Reaktion
auf das Ereignis ausgelöst wird.
int Robot::verarbeiteNachrichten()
{
bool istEnde = false;
Befehl einBefehl;
while(!istEnde)
{
int erg = msgrcv(msgid, (void*)&einBefehl,
sizeof(Befehl)-sizeof(long), 0, 0);
if (erg == -1)
{
cerr << "msgrcv() fehlgeschlagen!" << endl;
return -1;
}
switch(einBefehl.befehl)
{
case START:
bewegeArm(1);
break;
case BEWEGE:
bewegeArm(einBefehl.arg1);
break;
case BEWEGE_OBJEKT:
bewegeScheibe(einBefehl.arg1,
einBefehl.arg2);
break;
case ENDE:
istEnde = true;
break;
}
}
return 0;
}
START
ignorieren wir sie völlig,
bei BEWEGE
sehen wir Argument 1 als Zielposition an und bei BEWEGE_OBJEKT
nehmen wir aus ihnen die Start- und die Zielposition.
Die main()
-Funktion des Empfängerprogramms ist der des Senderprogramms
so ähnlich, dass ich mir hier das Abdrucken spare. Der einzige Unterschied ist,
dass am Ende noch mit einem Aufruf von kappeVerbindung()
die Nachrichtenschlange
gelöscht wird.
Setzen wir die Anzahl der Scheiben auf drei, so erhalten wir folgende Ausgabe unseres Empfängerprogramms:
% receiver
Robot empfangsbereit!
Bewege Scheibe von 1 nach 2
Bewege Scheibe von 1 nach 3
Bewege Scheibe von 2 nach 3
Bewege Scheibe von 1 nach 2
Bewege Scheibe von 3 nach 1
Bewege Scheibe von 3 nach 2
Bewege Scheibe von 1 nach 2
%
while
-Schleifen in ereignisgesteuerten Programmen
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.
int i;
for (i=1; i<20; i++)
{
if (i*i*i == 1728)
break;
cout << i << `` ist es nicht...'' << endl;
}
cout << i << ``^3 = 1728!'' << endl;
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
im Bereich von -10 bis 10 mit Schrittweite 1 ausgeben wollen, können Sie schreiben:
for (int k=-10; k<=10; k++)
{
if (k == 0)
continue;
cout << ``f(`` << k << ``) = ``
<< 1.0/k << endl;
}
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 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. Eine 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.
int i, x=0;
for (i=0; i<10; i++)
x += i;
cout << x << `` `` << i << endl;
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 mal kennung.txt. Dann erfolgt der Aufruf folgendermaßen (C-Shell):
% protect < kennung.txt
cin
zugeordnet. Um die Metapher vom Fluss noch zu unterstreichen, verwendet man
für die Eingabe den Operator >>
. Dahinter geben Sie dann die Variable
an, die die Eingabe aufnehmen soll. Anhand des Typs erkennt der Stream, wie
er die Bytefolge interpretieren soll. Es werden so lange Zeichen akzeptiert,
bis der Benutzer die Eingabetaste drückt. In folgendem Beispiel wird eine Ganzzahl
abgefragt. Stehen mehrere in einer Zeile, getrennt durch Leerzeichen, hintereinander,
so wird die erste davon nach x
geschrieben.
int x;
cin >> x;
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 damit sich nicht darum kümmern
müssen, die Daten für die Ausgabe zu formatieren. Zum anderen ist es sehr praktisch,
dass mehrere Ausgaben unterschiedlichen Typs miteinander verkettet werden können,
wie wir es ja bereits mehrfach verwendet haben, etwa zuletzt auf Seite .
Wenn Sie die Ausgabe so verketten, werden alle auszugebenden Zeichen hintereinander gefügt;
um trennende Leerzeichen und so weiter müssen Sie sich natürlich selbst kümmern.
Um einen Zeilenumbruch einzufügen, schicken Sie
endl
in den Stream, einen
so genannten Manipulator, der kein Zeichen im
eigentlich Sinn ist, sondern nur auf das Format der Ausgabe Einfluss nimmt.
Der Stream für die Standardausgabe hat übrigens den Namen cout
.
int x=25;
float d=3.14;
cout << ``x ist `` << x << `` und d ist ``
<< d << endl;
% ls > 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 Ihrem Quelltext-File prog.cc
beispielsweise alle Stellen, an denen Sie das Objekt Konfiguration
verwendet
haben, so können Sie schreiben:
% cat prog.cc | grep Konfiguration
cerr
(oder auch clog
, was fast dasselbe ist) an. Ohne
Umlenkung bemerkt der Benutzer des Programms gar nicht, dass es sich um zwei
getrennte Ausgabewege handelt, da beide auf den Bildschirm schreiben. Erst wenn
die Standardausgabe umgelenkt wird und immer noch Text auf dem Bildschirm erscheint, wurde dieser
sicherlich in den Fehlerausgabekanal geschrieben. Wenn Sie Textausgaben programmieren,
sollten Sie sich also stets überlegen, ob Sie eine Nachricht ausgeben wollen,
die bei korrektem Programmablauf erscheinen soll, oder eine Fehlermeldung. Im
letzteren Fall sollten Sie dazu cerr
verwenden.
Wenn Programme im Hintergrund oder im Batch-Betrieb laufen, ist eine Bildschirmausgabe
meist nicht sinnvoll, oft sogar unmöglich. Daher sollten Sie in einer solchen
Situation beide Ausgabekanäle umleiten. Die Syntax der Shell-Anweisung
dafür variiert von Shell zu Shell ein wenig. In der C-Shell etwa verwendet man
die Zeichen >&
. Wenn Sie zum Beispiel alle Ausgaben des find-Kommandos,
das nach C++-Quelltextdateien sucht, in eine Datei namens gefunden.txt
speichern wollen, geben Sie ein:
% find . -name ``*.cc'' -print >& gefunden.txt
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 Ausgabe
#include <iostream>
#include <fstream>
Zum Öffnen gibt es zwei Möglichkeiten. Zur Identifikation dient in beiden Fällen der Dateiname.
Entweder Sie geben den Namen gleich als Argument des Konstruktors an. Dann wird das Öffnen der Datei zusammen mit der Erzeugung der Instanz erledigt, beispielsweise
ofstream outfile(``results.dat'');
open()
verwenden. Diese verlangt ebenfalls
den Dateinamen als Argument.
ifstream infile;
infile.open(``myfile.txt'');
Zuweilen müssen Sie auch angeben, was Sie mit der Datei vorhaben. Denn davon hängt es
ab, wo der Dateizeiger positioniert wird. Das Betriebssystem
merkt sich nämlich stets, an welcher Stelle in der Datei Sie gerade stehen,
das heißt wohin als Nächstes geschrieben beziehungsweise von wo als Nächstes
gelesen wird. Daneben gibt es noch ein paar weitere Modi, in denen Sie Dateien
bearbeiten können. Um einen solchen festzulegen, können Sie dem Konstruktor
oder der open()
-Methode noch ein zusätzliches Argument mitgeben. Unter
anderem können Sie folgende Spezifizierer verwenden:
Spezifizierer | Beschreibung |
---|---|
ios::app |
Datei wird geöffnet, neue Daten werden an das Ende angehängt |
|
Dateizeiger wird beim Öffnen auf das Ende positioniert |
ios::binary |
Datei wird im Binär-Modus geöffnet (Voreinstellung ist Text-Modus) |
|
Die Datei wird nicht bei Bedarf erzeugt; das Öffnen scheitert, wenn die Datei nicht bereits existiert |
Hier gleich noch eine wichtige Anmerkung:
Der neue ANSI/ISO-Standard schreibt vor, dass die Basisklasse aller Stream-Klassen den Namenios_base
hat. Leider ist die Implementierung der C++-Standardbibliothek im GCC 2.95 noch nicht so weit, dass sie dies berücksichtigt. Dort heißt die Basisklasse immer nochios
. Wenn Sie einen neueren Compiler verwenden, kann es sein, dass in diesem die Umstellung bereits erfolgt ist. Überall, wo Sie im Folgendenios
finden, muss es bei Ihnen dannios_base
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::app
angeben.
Wollen Sie mehrere dieser Spezifizierer gleichzeitig angeben, müssen Sie sie
durch eine ODER-Verknüpfung mittels |
miteinander verbinden, zum Beispiel:
ofstream outfile(``tep.dat'',
ios::app | ios::binary);
Wenn wir nun schon die Datei geöffnet haben, sollten wir sie der Ordnung halber eigentlich wieder schließen, wenn wir sie nicht mehr brauchen. Zum Glück weiß aber ein Stream-Objekt, ob mit ihm eine geöffnete Datei verbunden ist; wird das Stream-Objekt zerstört, das heißt wird sein Destruktor aufgerufen, schließt es selbstständig die Datei. Daher müssen Sie sich also nur dann selbst um das Schließen kümmern, wenn Sie dasselbe Stream-Objekt nochmals anderweitig verwenden wollen.
void ausgabe()
{
ofstream resfile(``results.log'');
resfile << ``The results are: `` << endl;
// ... weitere Ausgaben
}
resfile
. Es wird also dessen Destruktor
aufgerufen, in dem auch die Datei results.log geschlossen wird.
Wenn Sie das Schließen selbst übernehmen wollen, rufen Sie dazu die Methode
close()
auf.
ofstream resfile(``results.log'');
// ... verschiedene Ausgaben
resfile.close();
Für die Standarddatentypen wie int
, char
oder float
stellt
die C++-Standardbibliothek die Operatoren
>>
sowie <<
zur
Ein- und Ausgabe zur Verfügung, die Sie genauso verwenden wie bei der Tastatureingabe
beziehungsweise der Bildschirmausgabe. Beachten Sie dabei, dass beim Einlesen
Trennzeichen wie Leerzeichen, Tabulator oder Zeilenumbruch standardmäßig überlesen
werden.
Zur Ein- und Ausgabe von Objekten Ihrer eigenen Klassen können Sie die Operatoren
auch überladen. Ein Beispiel dazu finden Sie auf Seite .
Generell können Sie mehrere Operatoren hintereinander hängen und die Ausgabe
durch Kontrollstrukturen steuern. Wollen Sie beispielsweise die Ergebnisse einer
Funktion f()
an den Stellen 1 bis 10 in eine Datei schreiben, brauchen
Sie dazu den Code:
ofstream o(``ergebnis.dat'');
for(int i=1; i <= 10; i++)
o << i << `` \t'' << f(i) << endl;
\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.
Ein solche Zeichenkombination nennt man auch Escape-Sequenz.
Weitere nützliche Steuer-Sequenzen 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, da Sie damit auf
jedes Byte einzeln zugreifen können, da sie auch Trennzeichen liest.
char c;
ifstream in(``orb2234.dpr'', ios::binary);
in.get(c);
put()
. Auf diese Weise lässt
sich auch eine Kopierfunktion realisieren:
ifstream in(``myfile'');
ofstream out(``myfile.copy'');
char c;
while (in.get(c))
out.put(c);
getline()
greifen. Dabei werden alle Zeichen von der aktuellen Position
des Dateizeigers bis zum Zeilenende eingelesen. Das Zeilenende ist dabei durch
ein Newline (\n
) gekennzeichnet.
string buffer;
ifstream in(``myfile.txt'');
// ...
getline(in, buffer);
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, dass Sie beim Schreiben an die Grenze der Kapazität stoßen, nicht außer Acht lassen.
Ein sehr einfacher und eleganter Weg ist die Abfrage des Status eines Datei-Streams über das Objekt selbst. Es liefert nämlich 0 zurück, wenn ein Fehler aufgetreten ist.
ifstream infile(``readfile.asc'');
if (!infile) {
cerr << ``Datei readfile.asc kann nicht ``
<< ``geöffnet werden!'' << endl;
return -1;
}
string buff;
while (infile) {
getline(infile, buff);
cout << buff;
}
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 Werte
ungleich 0 liefert, sowie
eof()
, das am Dateiende ungleich 0 zurückgibt (end
of file).
while (!infile.eof()) {
// ...
}
1:#include <iostream>
2:#include <fstream>
3:#include <string>
4:5:
int main(int argc, char** argv)
6:{
7:if (argc<2) {
8:cerr << "Bitte einen Dateinamen als"
9:<< " Argument angeben!" << endl;
10:return -1;
11:}
12:13:
ifstream in(argv[1]);
14:if (!in) {
15:cerr << "Datei " << argv[1] << " kann "
16:<< "nicht geöffnet werden!" << endl;
17:return -2;
18:}
19:20:
string buff;
21:unsigned long l=0;
22:23:
while(!in.eof()) {
24:getline(in, buff);
25:l++;
26:}
27:28:
cout << "Zeilen: " << l-1 << endl;
29:return 0;
30:}
Das Programm übernimmt den Dateinamen als Argument von der Kommandozeile (siehe
auch Seite ). Der Datentyp
char**
ist ein
Feld von Zeigern; wir werden ab Seite genauer darüber
sprechen. Fehlt in der Kommandozeile eine Angabe, so brechen wir gleich ab (Zeilen
7-10). Ebenso verfahren wir, wenn sich eine Datei mit diesem Namen nicht finden
beziehungsweise öffnen lässt (Zeilen 14-17). 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 24/25). Da wir auf diese Weise eine Zeile zu viel zählen
(wissen Sie, warum?), müssen wir bei der Ausgabe noch eine abziehen (Zeile 28).
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::beg
(ab Anfang des Streams)
ios::cur
(ab der aktuellen Position)
ios::end
(ab dem Ende des Streams)
tellg()
aufrufen, entspricht dieser Wert genau der Größe der Datei. Anschließend verschieben
wir den Dateizeiger in die Mitte der Datei.
ifstream in(``myfile.txt'');
// Zum Ende der Datei
in.seekg(0, ios::end);
// Aktuelle Position
streampos sp = in.tellg();
in.seekg(-sp/2, ios::cur);
Der Grund, dass wir so leicht zwischen verschiedenen Positionen einer Datei
hin- und herspringen können, liegt darin, dass der Zugriff intern zwischengepuffert
wird. Bei Ausgabedateien werden die Zeichen zunächst nur in den Puffer geschrieben.
Erst wenn er voll ist oder der Stream geschlossen wird, erfolgt das tatsächliche
Schreiben in die Datei. Dieses Verhalten zeigen übrigens auch die Standardstreams
wie cout
und clog
.
Das kann manchmal unerwünschte Nebeneffekte haben. So geht man bei der Fehlersuche
in größeren Programmen oft so vor, dass man zwischen den einzelnen Schritten
jeweils eine Meldung mit cout
ausgibt. Bricht das Programm ab, weiß man
in etwa, wie weit es gekommen ist. Aufgrund der Pufferung kann es dabei jedoch
vorkommen, dass die letzten Ausgaben vor dem Absturz gar nicht mehr auf dem Bildschirm
erscheinen, da sie nur im Puffer standen und noch nicht rausgeschrieben wurden.
Somit sucht man zuweilen den Fehler an einer völlig falschen Stelle.
Um das zu verhinden, sollten Sie bei solchen Ausgaben stets das sofortige Leeren
des Puffers erzwingen. Dazu dienen sowohl der Manipulator flush
als
auch die Methode flush()
.
Ersterer ist meist eleganter, da er sich nahtlos in die übrigen Ausgaben einreiht.
ofstream out(``myfile.txt'');
out << ``Debug-Ausgabe: `` << endl;
out.flush(); // Puffer sofort leeren
cout << ``Schritt 2 erreicht! `` << endl << flush;
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.width(5);
cout.fill('0');
cout << 47 << ``-``;
cout << 11 << ``-``;
00047- -11- -
. Dabei zeigt sich eine
wichtige Eigenschaft der width()
-Methode: Sie wirkt immer nur auf
die unmittelbar darauf folgende Ausgabe! Für die Ausgabe der 11 in der vierten
Zeile gilt wieder der Vorgabewert 0.
Dasselbe Resultat können Sie übrigens auch mit Manipulatoren erreichen.
Dazu müssen Sie die Header-Datei iomanip in Ihr Programm einbinden.
Dann können Sie die Breite der folgenden Ausgabe mit setw()
(von set
width) bestimmen. Das ist dann keine Methode der Klasse ostream
, sondern
wird unmittelbar zwischen zwei Ausgabeoperatoren eingefügt. Analog dazu gibt
es den Manipulator setfill()
, um das Füllzeichen zu ändern. Das Beispiel
von eben hieße damit:
cout << setw(5) << setfill('0')
<< 47 << "-" << endl;
cout << 11 << "-" << endl;
Die Anzahl der Ziffern von Gleitkommazahlen wird mit precision()
gesteuert.
Anders als bei der Breite gilt diese Festlegung dauerhaft, das heißt bis zum
nächsten Aufruf von precision()
. Wenn die interne Darstellung mehr Ziffern
enthält, wird die Ausgabe passend gerundet.
cout.precision(6);
cout << 1234.5678 << endl; // ergibt: 1234.57
Auch hier haben Sie die Möglichkeit, die Einstellung der Ziffern über einen Manipulator
vorzunehmen, genauer gesagt über setprecision()
. Da dieser intern auf
die gerade beschriebene Methode zurückgeführt wird, gelten auch dessen Einstellungen
bis auf weiteres.
cout << setprecision(6)
<< 1234.5678 << endl; // ergibt: 1234.57
ios::scientific
setzen. Dann stellen Sie mit precision()
die Zahl der Nachkommastellen
ein.
cout.setf(ios::scientific);
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. Die folgende Tabelle zeigt entsprechende
Flags:
Flag | Beschreibung |
---|---|
ios::left |
linksbündige Ausgabe |
|
rechtsbündige Ausgabe |
|
zwischen Vorzeichen und Wert auffüllen |
|
Dezimalpunkt und nachfolgende Nullen ausgeben |
|
Exponential-Format |
|
Festkomma-Format (precision() gibt dann Zahl der
gültigen Ziffern an) |
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 anstehende Umrechnerei von DM in Euro und zurück erleichtern soll. Unsere Tabelle soll dabei von 1 bis 1000 reichen, wobei in jeder Zehnerpotenz immer nur die ersten fünf ganzen Vielfachen ausgegeben werden soll, 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:
main()
5:{
6:int i,j;
7:const double wk = 1.95583;
8:9:
cout.precision(2);
10:cout.setf(ios::right | ios::fixed,
11:ios::adjustfield | ios::floatfield);
12:cout << setw(10) << "DM" << setw(10)
13:<< "Euro" << " | " << setw(10)
14:<< "Euro" << setw(10) << "DM" << endl;
15:16:
for(i=1;i<=44;i++) cout << '-';
17:cout << endl;
18:19:
for(i=1;i<=1000;i*=10) {
20:for(j=1;j<=5 && i*j<=1000;j+=1) {
21:cout << setw(10) << (float)i*j
22:<< setw(10) << i*j/wk << " | "
23:<< setw(10) << (float)i*j
24:<< setw(10) << i*j*wk << endl;
25:}
26:}
27:return(0);
28:}
In diesem Programm verwenden wir von den oben beschriebenen Befehlen die Formatierung
mit fester Breite (Zeilen 12 ff. und 21 ff.) und die rechtsbündige Ausgabe (Zeile
10/11). Das zweite Argument von setf()
gibt lediglich die Menge aller
Bits an, unter denen wir etwas verändern können, damit mit diesem Befehl nicht
in einen völlig falschen Bereich etwas eingetragen wird.
Als Ausgabe erhalten wir die nachstehende Tabelle. Sie hat zwar alle gewünschten Inhalte und ist im Rahmen der Anforderungen und Möglichkeiten formatiert -- aber mal ehrlich: Möchten Sie die Tabelle in dieser Form ausdrucken, um Sie in Ihre Brieftasche zu stecken?
DM Euro | Euro DM
----------------------
1.00 0.51 | 1.00 1.96
2.00 1.02 | 2.00 3.91
3.00 1.53 | 3.00 5.87
4.00 2.05 | 4.00 7.82
5.00 2.56 | 5.00 9.78
10.00 5.11 | 10.00 19.56
20.00 10.23 | 20.00 39.12 ...
1:Das Ergebnis im Druck sehen Sie in Tabelle#include <iostream>
2:#include <fstream>
3:#include <strstream>
4:#include <cstdlib>
5:#include <unistd.h>
6:7:
main()
8:{
9:int i,j;
10:const double wk = 1.95583;
11:12:
ostrstream ostr;
13:ostr << "euro." << getpid()
14:<< ".tex" << ends;
15:16:
ofstream out(ostr.str());
17:out.precision(2);
18:out.setf(ios::fixed);
19:20:
out << "\\documentclass{article}" << endl;
21:out << "\\begin{document}" << endl;
22:out << "\\section*{Umrechungstabelle"
23:<< " DM -- Euro}" << endl;
24:out << "\\begin{tabular}{rr|rr}"
25:<< endl;
26:out << "DM & EUR & EUR & DM \\\\ \\hline"
27:<< endl;
28:29:
for(i=1;i<=1000;i*=10) {
30:for(j=1;j<=5 && i*j<=1000;j+=1) {
31:out << (float)i*j << " & "
32:<< i*j/wk << " & "
33:<< (float)i*j << " & "
34:<< i*j*wk << " \\\\" << endl;
35:}
36:}
37:out << "\\end{tabular}" << endl;
38:out << "\\end{document}" << endl;
39:40:
ostrstream ltcmd;
41:ltcmd << "latex " << ostr.str() << ends;
42:if (!system(ltcmd.str())) {
43:ostrstream dvicmd;
44:dvicmd << "dvips euro." << getpid()
45:<< ".dvi -o" << ends;
46:if (system(dvicmd.str()))
47:cerr << "PS-Datei konnte nicht"
48:<< " erzeugt werden!" << endl;
49:}
50:else
51:cerr << "DVI-Datei konnte nicht"
52:<< " erzeugt werden!" << endl;
53:return(0);
54:}
DM | EUR | EUR | DM |
1.00 | 0.51 | 1.00 | 1.96 |
2.00 | 1.02 | 2.00 | 3.91 |
3.00 | 1.53 | 3.00 | 5.87 |
4.00 | 2.05 | 4.00 | 7.82 |
5.00 | 2.56 | 5.00 | 9.78 |
10.00 | 5.11 | 10.00 | 19.56 |
20.00 | 10.23 | 20.00 | 39.12 |
30.00 | 15.34 | 30.00 | 58.67 |
40.00 | 20.45 | 40.00 | 78.23 |
50.00 | 25.56 | 50.00 | 97.79 |
100.00 | 51.13 | 100.00 | 195.58 |
200.00 | 102.26 | 200.00 | 391.17 |
300.00 | 153.39 | 300.00 | 586.75 |
400.00 | 204.52 | 400.00 | 782.33 |
500.00 | 255.65 | 500.00 | 977.91 |
1000.00 | 511.29 | 1000.00 | 1955.83 |
In diesem Programm dürften gleich einige Dinge für Sie neu sein. Betrachten wir es also etwas genauer.
Jeder Stream verfügt über einen Puffer im Speicher, vom dem er erst einmal liest beziehungsweise in den er erst einmal schreibt, bevor ein Zugriff auf die Festplatte erfolgt. Da liegt es nahe, Streams zu definieren, die nur aus diesem Puffer bestehen. Solche bezeichnet man als String-Streams. Um sie zu verwenden, müssen Sie die Datei strstream in Ihren Code einbinden.
In den Zeilen 12/13 verwenden wir einen solchen ostrstream
. Wie Sie sehen,
schreiben Sie dort genauso hinein wie in einen anderen
Ausgabestream (vgl. S. ). Der einzige
Unterschied ist, dass Sie das Ende Ihrer Einträge kennzeichnen müssen, indem
Sie als Letztes den Manipulator
ends
ausgeben. Auch das hat mit einem
Relikt aus C zu tun: Zeichenketten müssen immer mit einem Byte vom Wert 0 abgeschlossen
sein (man spricht auch von 0-terminiert). Bei String-Streams sorgt besagter
Manipulator dafür, dass sich Ihr String nicht in unabsehbarer Länge über den
Speicher erstreckt, sondern dort aufhört, wo auch Ihre Ausgaben beendet sind.
Wenn Sie den Inhalt des String-Streams an anderer Stelle verwenden wollen, etwa
als Dateiname wie in Zeile 16 oder als Aufrufargument wie in Zeile 42, müssen
Sie ihn in einen normalen String umwandeln. Was das genau ist, werden wir im
nächsten Abschnitt besprechen; momentan können Sie mir glauben, dass die Methode
str()
genau das bewerkstelligt, was wir beabsichtigen.
strstream
-Klassen
durch stringstream
-Klassen ersetzt, die eine ähnliche Funktionalität,
aber eine etwas einfachere Schnittstelle bieten (mehr dazu können Sie
etwa in [STROUSTRUP 1998] nachlesen).
Sie sind über die Header-Datei sstream nutzbar zu machen. Die Implementierung
der Standardbibliothek,
wie sie dem GCC in der Version 2.95 und früher beiliegt, enthält diese Klassen
jedoch nicht, so dass ich hier auf die älteren zurückgegriffen habe. Wenn Sie
mittlerweile über eine neuere Version des Compilers verfügen, sehen Sie doch
einfach mal nach, ob es diese Header-Datei mit den
Klassen istringstream
, ostringstream
etc. dort gibt, und versuchen Sie gegebenenfalls das Programm entsprechend umzuschreiben.
Da Linux ein Multitasking/Multiuser-Betriebssystem ist, sollte sich eigentlich jede Anwendung mit dem Gedanken vertraut machen, dass der oder die Benutzer sie auch mehrfach gleichzeitig ablaufen lassen können. Das bedeutet insbesondere für die Ausgabe in Dateien, dass bei einem festen Namen die verschiedenen Prozesse im schlimmsten Fall zugleich darauf zugreifen könnten und so ein Kauderwelsch entstehen ließen. Bei einfachen Tools macht man sich meist nicht die Mühe, diesen Fall zu berücksichtigen, sondern vertraut darauf, dass die Anwendung ohnehin stets in getrennten Verzeichnissen ausgeführt wird.
Wie aber könnte man das Problem doch in den Griff bekommen? Haben Sie schon eine Idee? Der Standardweg ist, den Dateinamen so zu ergänzen, dass er eindeutig wird. Und zur Feststellung der Eindeutigkeit eignet sich die Identifikationsnummer (ID) des Prozesses besonders gut. Schließlich kann ja das Betriebssystem anhand dieser Nummer die einzelnen Programme auch auseinander halten. Da liegt es nahe, diesen Wert für eine ähnliche Aufgabe ebenfalls einzusetzen. Wenn Sie etwa das Kommando
% ps aux
In diesem Zusammenhang stellt sich natürlich die Frage: Was ist ein
Prozess? Auch wenn Sie keine systemnahe Programmierung
machen wollen, sollten Sie eine ungefähre Vorstellung davon haben. Auf einem
Linux-Recher können jederzeit mehrere unabhängige Programme von verschiedenen
Benutzern laufen; auch das Betriebssystem startet und unterhält mehrere solcher
Programme, um die Benutzer und die Systemressourcen zu verwalten. Ein Prozess
ist eines dieser Programme. Er besteht damit aus Programmcode, einigen Daten
im Speicher, eventuell offenen Dateien oder anderen Ein-/Ausgabekanälen
und manchmal auch aus Umgebungsvariablen. Er läuft in einem eigenen Adressraum,
kann also nicht auf den Speicherbereich zugreifen, den andere Programme verwenden.
Es ist allerdings möglich, dass Programmcode in Form von dynamischen Bibliotheken
in mehreren Prozessen gleichzeitig verwendet wird (siehe auch Seite ).
Zur Verwaltung aller Prozesse legt Unix eine Prozesstabelle an. Dort sind alle notwendigen
Informationen über die gerade laufenden Prozesse hinterlegt. Um die Prozesse
eindeutig unterscheiden zu können, vergibt Linux bei deren Start eine Prozessnummer.
Weitere Daten über einen Prozess sind Name und Pfad des Programms, zugeordneter
Benutzer (bei Systemprozessen root
), CPU- und Speicherverbrauch, Start-
und Laufzeit, Terminal und Status. Das ps
-Kommando listet Ihnen alle
Prozesse zu den angegebenen Kriterien auf (Näheres etwa unter man ps
).
Im Gegensatz dazu bietet Ihnen das top
-Kommando eine Liste der Prozesse,
die am meisten Rechenzeit verbrauchen. Diese Liste ist zudem nach dieser Eigenschaft absteigend
sortiert und wird fortlaufend in bestimmten Intervallen aktualisiert. Mit
dem angegebenen Prozentwert ist dabei gemeint, wie viele Arbeitsschritte (Takte)
der CPU für diesen Prozess benutzt werden. Denn Multitasking bedeutet auf Rechnern
mit einer CPU auch unter Unix lediglich, dass immer nur ein Prozess gleichzeitig die
CPU verwendet, aber sehr oft zwischen den verschiedenen Prozessen umgeschaltet
wird, so dass der Eindruck der Gleichzeitigkeit entsteht.
Tiefer kann ich in diesem Rahmen leider nicht auf dieses Thema eingehen. Wenn Sie mehr wissen wollen, können Sie beispielsweise den Klassiker [HETZE . 1996] zu Rate ziehen.
Nun zurück zu unserem Beispiel: In einem C++-Programm können Sie die ID des Prozesses, in dem das Programm gerade
läuft, über die Funktion getpid()
erfahren.
Damit der Compiler diese kennt, müssen Sie die Unix-System-Header-Datei unistd.h
einbinden.
Wir verwenden sie beispielsweise in Zeile 13, 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 41 das Kommando aus Programmname und
Argument (hier: Dateiname) zusammen und rufen in Zeile 42 system()
. Wenn
diese Funktion 0 zurückgibt, die Abarbeitung also fehlerfrei war, können wir in Zeile
46 mit dvips (wird in Zeile 44 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,
strstream
-Objekte zum Aufbau von Strings im Speicher einzusetzt,
getpid()
die ID des Prozesses bestimmt,
system()
aus dem eigenen starten kann.
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
mit einem expliziten Aufruf von close()
schließen?
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 (englisch array) ist nichts anderes als eine durchnummerierte Menge von Variablen gleichen Typs. Sie können von jedem elementaren und von jedem selbst definierten Datentyp Felder bilden. Dazu geben Sie bei der Deklaration lediglich außer Typ und Namen die Anzahl der Elemente an. Für ein Feld von Ganzzahlen mit 10 Elementen etwa schreiben Sie:
int a[10];
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 Indizierungsoperator,
der aus einem Paar eckiger Klammern []
besteht. In diesen geben Sie den
Index des Elements an, das Sie bearbeiten möchten, zum Beispiel:
a[0] = 3;
a[1] = 5;
// ...
a[9] = 13;
Die Indizierung eines Feldes mitElementen läuft grundsätzlich von 0 bis
. 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 Bereichnicht verlassen.
Was geschieht eigentlich, wenn Sie über die Größe Ihres Feldes hinausschreiben? Das Programm legt zumeist seine Variablen hintereinander an, nach Möglichkeit ohne Lücken. Wenn Sie also beispielsweise nur zehn Elemente reserviert haben und auf ein elftes zugreifen, ändern Sie damit den Wert einer anderen Variablen. Im schlimmsten Fall stehen dort aber Variablen eines anderen Prozesses oder gar Programmanweisungen. Das heißt, Sie ändern den Speicherinhalt an einer kaum vorhersagbaren Stelle. Entsprechend unvorhersehbar sind die Folgen. Meist führen solche Fehler leider nicht sofort zu Abstürzen, sondern erst einige Zeit später, an einer Stelle mit völlig korrektem Code, der eben auf den zerstörten Speicherbereich zugreifen will. Ihr Programm endet dann abrupt mit der Meldung: Segmentation fault. Bei einem solchen Fehler sollten Sie daher immer zuerst an unerlaubte Speicherzugriffe denken.
Ich habe Sie schon bei einfachen Variablen gewarnt, dass diese nach einer Deklaration
völlig undefinierte Werte haben können und Sie daher stets so früh wie möglich
für eine Initialisierung sorgen sollten. Bei Feldern vervielfacht sich Ihr Problem
lediglich. Aber auch hier können Sie gleichzeitig mit der Deklaration das Feld
initialisieren. Dabei geben Sie die gewünschten Inhalte als Liste in geschweiften
Klammern {}
an, getrennt durch Kommas.
int x[3] = {3, 7, 11};
Wenn Sie jetzt besonders ökonomisch denken, werden Sie sagen: Damit gebe ich doch die Anzahl der Elemente zweimal an, einmal als explizite Größenangabe und einmal implizit durch die Zahl der Initialisierungswerte! In der Tat müssen Sie nämlich für alle deklarierten Elemente auch einen Wert in der Initialisierungsliste eintragen; also könnte man doch diese Anzahl gleich als Größenangabe verwenden. Der Compiler unterstützt solche Überlegungen sehr wohl:
int x[] = {3, 7, 11};
Felder können auch mehrere Dimensionen haben. Dazu fügen Sie einfach weitere Größenangaben
in eckigen Klammern an die Deklaration an. Eine -Matrix etwa können
Sie deklarieren als:
double matrix[3][4];
for(int i=0; i<3; i++)
for(int j=0; j<4; j++)
matrix[i][j] = 0.0;
Ein weiterer Nachteil von Feldern ist, dass Sie bereits im Code die genaue Zahl der Elemente angeben müssen. Der Wert muss dabei auf alle Fälle eine Konstante sein, die während des Kompilierens bestimmbar ist. Es ist also nicht möglich, in dieser Form ein Feld zu definieren, dessen Größe Sie erst während der Laufzeit des Programms ermitteln, zum Beispiel
unsigned int n=5;
int a[n]; // compiler error
Wenn wir bislang Zeichenketten für Namen oder Beschriftungen brauchten, haben
wir immer Objekte der Klasse string
der C++-Standardbibliothek verwendet
(Genaueres ab Seite ). Das ist eine robuste und sichere
Vorgehensweise; allerdings gehört diese Klasse erst seit dem ANSI/ISO-Standard
von 1998 verbindlich zu C++. Der traditionelle Weg, Zeichenketten zu speichern,
ist der von C übernommene: in Form von Feldern des Typs
char
. Eigentlich
bräuchte man sich heute damit gar nicht mehr zu beschäftigen, wenn es nicht
viele Systemfunktionen gäbe, die als Argumente oder Rückgabewerte gerade ein
solches Zeichen-Array haben. Und da diese Funktionen sämtlich in C geschrieben
sind, wird das auch noch länger so bleiben.
Die einfachste Möglichkeit, ein char
-Feld zu definieren, ist diejenige mit impliziter
Größenangabe. Da selten auf einzelne Elemente zuzugreifen ist, wird dieser Weg
relativ häufig eingesetzt:
char txt[] = ``Unser Text.'';
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
txt
durch
txt[4] = 0;
Bei der Eingabe können Sie Zeichen-Arrays wie andere einfache Datentypen behandeln. Dabei ist allerdings die begrenzte Länge zu beachten:
char eingabe[20];
cin >> eingabe;
getline
zu verwenden, bei der Sie die Maximalgröße der Eingabe zusätzlich übergeben.
Ist die Eingabe länger, wird sie abgeschnitten.
const int GROESSE = 100;
char eingabe[GROESSE];
cin.getline(eingabe, GROESSE);
GROESSE
als Konstante deklariert ist und sich daher zur
Laufzeit nicht ändern kann.)
Wenn Sie auf diese Weise ein Zeichenfeld definiert haben, dürfen Sie ihm nicht als Ganzes ein anderes zuweisen.
char txt[] = ``Was?'';
txt = eingabe; // Fehler
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
int* p1;
double* p2;
Raumfahrzeug* pUfo;
char** pp3;
float *pf;
Wenn
Sie mehrere Zeiger gleichzeitig deklarieren, gilt der Stern indessen
nur für den Ersten -- oder muss ausdrücklich vor jeden gesetzt werden:
// Deklariert einen Zeiger
// und eine int-Variable
int* p1, p2;
// Deklariert zwei Zeiger
int *p3, *p4;
Die Speicheradresse eines bestehenden Objekts können Sie sich
über den
Adressoperator &
verschaffen, beispielsweise:
int n;
int* p4 = &n;
Raumfahrzeug Rfz;
Raumfahrzeug* pUfo = &Rfz;
int* pi = &n;
if (*pi < 10)
*pi = 10;
n
verändert, indem
wir den Inhalt ihrer Speicherstelle modifizierten. In diesem Sinne ähneln Zeiger
sehr Referenzen; der Unterschied ist, dass bei Referenzen der Compiler bereits
für Bestimmung der Adresse und Indirektion sorgt.
Bei Zeigern auf Objekte müssen Sie beachten, dass der .
-Operator für den Zugriff
auf einzelne Elemente Vorrang vor dem Dereferenzierungsoperator hat. Wenn Sie
also ein Element verändern möchten, müssen Sie die Indirektion klammern.
Raumfahrzeug* pUfo = &Rfz;
(*pUfo).hoehe = 3890.5;
->
einsetzen. Äquivalent zu obiger Anweisung ist nämlich:
pUfo->hoehe = 3890.5;
bool res = pUfo->setGeschwindigkeit(4000);
Eine Feldvariable wird in C++ als ein Zeiger auf das erste Feldelement betrachtet (wobei sich der Compiler nur noch für die Initialisierung die Größe merkt). Damit können Sie ein Feld problemlos einem Zeiger zuweisen:
int a[6] = {3, 5, 7, 11, 13, 17};
int* a_ptr = a;
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 . Ein mit belegter Zeiger zeigt definitiv auf nichts.
Ebenso sollten Sie einen Zeiger, den Sie momentan nicht benötigen, weil er auf ein noch nicht oder nicht mehr existierendes Objekt verweist, mit belegen.
int* iptr = 0;
if (iptr)
*iptr = 7;
In C hat man ein Makro namens NULL
verwendet, um den Nullzeiger darzustellen.
Das ist in C++ nicht mehr nötig. Wenn Sie dafür einfach die Zahl benutzen,
sind Sie immer auf der sicheren Seite und bekommen zudem mit der Typprüfung
weniger Ärger.
Sie können Zeiger nicht nur statisch verwenden, sondern mit diesen auch rechnen. Dabei spielt der Typ eine sehr große Rolle: Wenn Sie einen Zeiger (um eins) inkrementieren oder dekrementieren, zeigt er anschließend nicht auf das nächste Byte im Speicher, sondern auf die Adresse der nächsten Variablen desselben Typs. Was heißt das genau? Der Zeiger wird um so viele Byte verändert, wie ein Objekt des Basistyps beansprucht. (Und das kann für ein und denselben Typ sogar von der Architektur des Rechners abhängen.) Versuchen Sie es mit folgendem Beispiel:
double d[10];
double *dp1 = d;
double *dp2 = dp1 + 1 ;
cout << "dp2 - dp1: " << dp2 - dp1 << endl;
cout << "int(dp2) - int(dp1): "
<< int(dp2) - int(dp1) << endl;
1
und für die zweite 8
. Wenn Sie anschließend den Zeiger dp2
weiter erhöhen, etwa
dp2 += 3;4
und 32
.
Wenn Sie direkt auf einzelne Bytes zugreifen müssen, sollten Sie als Zeigertyp
char*
wählen. Diese 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 geerbet, was aber noch nicht heißen soll,
dass dieser Stil für Sie die Regel werden sollte.
Ein beliebtes Beispiel für eine Anwendung der Zeigerarithmetik ist das Umkopieren eines Zeichenfeldes.
char quelle[] = ``Hier sind die Daten zuhause.'';
char ziel[50];
char* p= quelle, *q= ziel;
while (*q++ = *p++);
for(char* p= quelle, *q= ziel; *q++ = *p++; );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:
Aus Sicht des Programms bezeichnet man den gesamten restlichen freien Speicher der Maschine als Heap. Auf ihm können Sie Objekte und Felder dynamisch anlegen (also während der Laufzeit), wobei Sie nur die physikalische Speichergröße beschränkt.
Halten wir also fest: Der Stack ist der Teil des Arbeitsspeichers, der beim Start des Programms dafür frei gehalten wird und der alle lokalen Variablen sowie die Funktionsparameter enthält. Der Heap umfasst das gesamte restliche freie RAM und enthält die dynamisch angelegten Objekte und Felder. Die Vor- und Nachteile sind dabei:
Stack | Heap |
---|---|
+ schnelle Reservierung | + sehr große Speichermenge zur Verfügung |
- feste Größe | - Verwaltung schaft etwas Overhead |
- Der Programmierer muss den benötigten Speicher selbst reservieren und freigeben
(![]() |
|
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:
Typ* t = new Typ;
Raumfahrzeug
anlegen und damit arbeiten, können
wir schreiben:
Raumfahrzeug* r = 0; //Zeiger initialisieren
r = new Raumfahrzeug; //Speicher reservieren
//Objekt verwenden
r->setGeschwindigkeit(20000);
Sie können mit new
sowohl Variablen von einfachen Datentypen als auch von
selbst definierten Strukturen und Klassen anlegen. (Einzelne Variablen vom Typ int
oder float
dynamisch anzulegen, ist jedoch ziemlich unüblich.) Bei Objekten
kommt noch eine Besonderheit hinzu: der Konstruktor. Wenn Sie Instanzen auf
dem Stack anlegen, können Sie ja durch die Angabe von Argumenten einen überladenen
Konstruktor aufrufen. Das ist bei new
genauso möglich.
Erinnern Sie sich noch an die Klasse Datum
, die wir in Abschnitt
als Beispiel erstellt haben? Dort gab es etwa die Konstruktoren
class Datum
{
public:
Datum();
Datum(unsigned int _t,
unsigned int _m, unsigned int _j);
// ...
};
int main()
{
// 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.
Raumfahrzeug* r = 0; //Zeiger initialisieren
r = new Raumfahrzeug; //Speicher reservieren
// .. Objekt verwenden
delete r; //Speicher freigeben
Das Arbeiten mit dynamisch verwaltetem Speicher ist zwar in den meisten größeren Programmen unumgänglich, stellt aber auch eine Hauptfehlerquelle bei der Programmentwicklung mit C++ dar. Im Gegensatz zu einigen anderen Programmiersprachen (wie Java oder Fortran) ist C++ in diesem Punkt sogar besonders sensibel. Aus diesem Grund gibt es auch eine Reihe von kommerziell erhältlichen Werkzeugen -- auch unter Linux --, deren Hauptaufgabe es ist, Fehler bei der Speicherverwaltung ausfindig zu machen (Insure++ von ParaSoft ist beispielsweise in einer Demoversion Teil der SuSE-Distribution oder unter www.parasoft.com/products/insure zu finden). Der Programmierer ist gut beraten, wenn er sich bereits beim Schreiben des Codes darüber Gedanken macht und so mögliche Fehlerquellen von vornherein ausschließt. Darüber hinaus bietet gerade C++ mit seinem Konzept der Daten- und Prozessabstraktion die Möglichkeit, dynamische Speicherverwaltung an einigen wenigen Stellen im Gesamtprogramm zu kapseln und so die Gefahr, dass dabei Fehler unterlaufen, relativ klein zu halten.
Im Folgenden will ich Ihnen einige neuralgische Punkte vorstellen, auf die Sie bei der Programmierung besonders achten sollten. Da dieses Thema allein schon ganze Bücher füllt (zum Beispiel [BOEHM 2000] und die sehr empfehlenswerten [MEYERS 1996], [MEYERS 1997]), kann hier selbstverständlich kein Anspruch auf Vollständigkeit erhoben werden.
Zunächst ist zu bedenken, dass es auch bei new
selbst Probleme geben kann. Auch
die heutigen RAM-Größen stoßen irgendwann an ihre Grenzen. Der ANSI/ISO-Standard
sieht vor, dass der new
-Operator eine so genannte Ausnahme (namens bad_alloc
,
siehe Seite ) auslösen soll. Das ist zwar bei
der gegenwärtigen Version (2.95) des GCC schon eingebaut, aber noch auskommentiert
(wie Sie etwa in der Datei stl_alloc.h sehen können). Im Augenblick
zeigt der Compiler daher noch das aus C bekannte Verhalten: Ist nicht genügend
Speicher verfügbar, gibt
new
den Zeigerwert 0 zurück. Sie können daher,
wenn Sie Ihr Programm robust machen wollen, nach jeder Speicherreservierung die
Rückgabe überprüfen.
Raumfahrzeug* r = new Raumfahrzeug;
if (!r) {
cerr << ``Kein Speicher verfügbar!'' << endl;
exit(1);
}
new
-Operator
in Ihrem Programm eine Null zurückgibt.
Weiterhin müssen Sie darauf achten, dass Sie für jeden Bereich, den Sie mit new
anlegen, ein korrespondierendes delete
aufrufen. Wie ich Ihnen oben schon
erklärt habe, kann ein Versäumnis zu nicht mehr zugänglichen Speicherbereichen
führen.
Außerdem dürfen Sie kein delete
auf Speicher aufrufen, den Sie auf dem Stack,
also ohne new
angelegt haben.
Datum d;
Datum* pD = &d;
// ...
delete pD; //Absturz!
Ähnliches widerfährt Ihnen, wenn Sie versuchen, ein Objekt mehrmals zu löschen. Doppelt gelöscht ist endgültig weg gilt nämlich ganz und gar nicht.
Datum* pD = new Datum;
// ...
delete pD;
// ...
delete pD; //Absturz!
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:
cout << ``Größe angeben: ``;
int groesse = 0;
cin >> groesse;
int* pVector = new int[groesse];
delete
freigeben müssen, die ein Paar eckiger Klammern []
als Hinweis auf die Array-Eigenschaft enthält.
delete[] pVector;
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:
int z, s;
cout << ``Zeilen und Spalten eingeben: ``;
cin >> z >> s;
int** mat = new int*[z];
for(int i=0; i<z; i++)
mat[i] = new int[s];
mat[i][j] = 12;
// Zeilen freigeben
for(int i=0; i<z; i++)
delete[] mat[i];
// Feld mit Zeigern auf Zeilenanfänge freigeben
delete[] mat;
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:
class Vektor
{
private:
unsigned int size;
double* v;
public:
Vektor();
Vektor(unsigned int _size);
Vektor(Vektor& _vek);
~Vektor();
unsigned int getSize();
const double& at(unsigned int _i) const;
double& at(unsigned int _i);
};
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 , während der Ganzzahl-Konstruktor einen Vektor der angegebenen Größe anlegt.
Vektor::Vektor() :
size(0), v(0) {};
Vektor::Vektor(int _size) :
size(_size)
{
v = new double[size];
}
Vektor
-Objekte sind damit nicht mehr
unabhängig von einander verwendbar, da sie einen gemeinsamen Speicherbereich
referenzieren. Schreibzugriffe auf das eine Objekt wirken sich auch auf das andere
aus. Die Situation eskaliert, wenn etwa das erste Objekte vernichtet wird. Damit
wird nämlich auch sein Feld freigegeben, so dass das zweite auf einen völlig
undefinierten Speicherbereich zugreifen würde -- mit baldigem Totalabsturz.
Um das zu vermeiden, müssen Sie bei Klassen, die mit dynamisch erzeugtem Speicher arbeiten, stets Kopierkonstruktoren selbst schreiben. In diesen können Sie dann dafür Sorge tragen, dass der Inhalt so kopiert wird, wie man es erwarten würde. In unserem Beispiel hieße das, ein neues Feld anzulegen und nur die Werte des alten dorthin zu übertragen.
Vektor::Vektor(Vektor& _vek) :
size(_vek.size)
{
v = new double[size];
for(unsigned int i=0; i<size; i++)
v[i] = _vek.v[i];
}
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 aufweist,
mit dem wir ihn ja im Standardkonstruktor vorbelegt haben.
Vektor::~Vektor()
{
if (v)
delete[] v;
}
int lies_vektor()
{
int groesse;
ifstream eingdatei(``eingabe.dat'');
eingdatei >> groesse;
Vektor v(groesse); // Vektor anlegen
for(int i=0; i<groesse; i++)
{
eingdatei >> v.at(i);
if (eingdatei.eof())
{
cerr << ``Unerwartetes Dateiende!'' << endl;
return -1;
} // Hier wird v.~Vektor() aufgerufen!
}
// ...
}
v
gewährleistet ist.
Wer heute sich mit der Entwicklung von System- oder Anwendungssoftware beschäftigt, kennt immer auch zumindest die Grundlagen der Gestaltung von HTML-Seiten für Webserver. Überhaupt ist die Web-Programmierung einer der am schnellsten wachsenden Bereiche in der IT-Entwicklung. Der Siegeszug des Internets ist dabei allgegenwärtig.
Wenn Sie sich schon intensiver mit der Erstellung von Web-Seiten beschäftigt haben, werden Sie schnell auf ein grundlegendes Defizit von HTML gestoßen sein: Informationen auf HTML-Seiten sind von Haus aus statisch; sie werden beim Benutzer nur präsentiert, ohne dass dieser irgendwelche Einflussmöglichkeiten darauf hätte. Oftmals wollen Sie als Web-Autor aber gerade, dass der Betrachter mit Ihrem Server interagieren kann, etwa um seine Meinung zu hinterlassen, eine Anfrage oder Bestellung aufzugeben oder nur um nach einem Stichwort zu suchen. In diesem Abschnitt wollen wir uns folglich mit der Frage beschäftigen, wie wir das Senden von Informationen vom (Web-)Client zum Server realisieren können und wie der Server dynamisch auf die Anfragen reagieren kann.
Um diesem Problem zu begegnen, hat man schon sehr früh eine Programmierschnittstelle
geschaffen, das Common Gateway Interface, kurz CGI.
Darüber erstellte Programme laufen auf dem Server und sorgen für die Interaktion
mit dem Benutzer, das heißt, sie verarbeiten die von ihm eingegebenen Informationen
und senden ein für ihn speziell erzeugtes Antwortdokument zurück (Abbildung
). Über diese Schnittstelle können Sie im Grunde alle Arten
von Programmen an das Web anbinden, die nur über eine einfache Eingabe verfügen
und die in relativ kurzer Zeit ein für den Benutzer interessantes Resultat hervorbringen.
Denn als Web-Autor müssen Sie sich verschiedenen Herausforderungen in Bezug auf interaktive
Web-Seiten stellen:
Dabei haben jedoch die CGI-Interfaces nach meiner Meinung keineswegs ausgedient. Nicht jeder betreut ja eine Web-Site, auf der er mit einer Million Zugriffen und mehr am Tag rechnen muss. Gerade für Web-Autoren, die die Betreuung in ihrer Freizeit erledigen, ist CGI aufgrund seiner Einfachheit immer noch aktuell. Und auch wenn Perl dafür weit verbreitet ist, sollten wir bedenken, dass es nach wie vor möglich ist, ein ausführbares Programm über CGI aufzurufen - das beispielsweise in C++ geschrieben wurde. Ein solches wollen wir gleich erstellen.
Der weltweit meist verbreitete Webserver ist Apache. Über 60% der Server im Internet setzen ihn ein. Auch er ist freie Software und im Quelltext erhältlich (über www.apache.org). Neben vielen anderen Plattformen ist er natürlich auch unter Linux verfügbar und dort in den meisten Distributionen enthalten. Möglicherweise wurde er aber nicht standardmäßig bei Ihnen installiert, so dass Sie ihn erst von der CD auf Ihr System kopieren müssen.
Die Dateien sind bei Apache standardmäßig wie folgt organisiert:
Im Hauptverzeichnis gibt es verschiedene Unterverzeichnisse, unter anderem:
Das Mittel, um dem Benutzer Eingaben auf HTML-Seiten
zu erlauben, ist das <FORM>
-Tag. Mit dessen Hilfe kann der Browser Steuerelemente
wie Eingabefelder oder Listenfelder darstellen. Auch dazu kann ich Ihnen hier
leider nur ein paar Stichworte erklären. Grundkenntnisse in HTML kann ich bei
Ihnen ja sicher voraussetzen.
Das Element <FORM> umschließt die Angabe der Steuerelemente. Es trägt selbst zwei Attribute (ein drittes optionales lasse ich hier weg):
ACTION
geben Sie den Pfad oder die URL des Programms an, das die
Eingaben verarbeiten soll.
METHOD
spezifizieren Sie die Art der Übertragung, nämlich als GET
oder als POST
. Die beiden Typen unterscheiden die Art, wie die Informationen
auf dem Server bereitgestellt werden.
GET
werden die eingegebenen Daten als Kommandozeilenoptionen an das
aufgerufene Programm übergeben. Da dies in manchen Fällen Probleme bereitet,
wird die Kommandozeile zusätzlich in der Umgebungsvariablen QUERY_STRING
hinterlegt. GET
wird üblicherweise für einfachere Formulare verwendet.
POST
erhält das Programm alle Daten über den Standardeingabekanal.
Dieser Weg ist hauptsächlich für umfangreichere Formulare gedacht. Im Allgemeinen
steht Ihnen aber frei, welche Methode Sie verwenden.
<SELECT>
für Listenfelder und <TEXTAREA>
für mehrzeilige Textfelder verwendet man vor allem das <INPUT>
-Element.
Seine Funktionalität und sein Erscheinungsbild können Sie über das Attribut
TYPE
festlegen, wobei TEXT
der Vorgabewert ist und einem Eingabefeld
entspricht; es sind aber auch CHECKBOX
für Kontrollkästchen oder RADIO
für Radioknöpfe möglich. Die Schaltflächen zum Abschicken der Eingaben
beschreiben Sie mit INPUT
; der zugehörige Typ heißt SUBMIT
. Im
Attribut VALUE
, das sonst für Vorgabewerte dient, steht dann der Text
der Schaltfläche.
Sehen wir uns ein Beispiel an:
<H1>Übertragung von Daten mit
Hilfe der POST-Methode</H1>
<HR>
<FORM METHOD="POST" ACTION="/cgi-bin/webtest">
<H2>Bitte geben Sie die Daten Ihres
Rechners ein:<H2>
<P>
<PRE>
Name: <INPUT NAME="Name"
VALUE="">
Taktfrequenz: <INPUT NAME="Frequenz"
VALUE="300">
Linux-Kernel-Version: <INPUT NAME="Kernel"
VALUE="2.4.2">
</PRE>
<P>
Um die Daten zu übertragen, bitte
hier klicken:
<INPUT TYPE="submit" VALUE="Abschicken">
</FORM>
Wir wollen uns nun daran machen, diese Daten auszuwerten.
Wenn der Benutzer auf die Schaltfläche ABSCHICKEN klickt, verpackt der Browser die auf dem Formular eingegebenen Informationen und sendet sie wie üblich mit Hilfe des HTTP-Protokolls an den Server. Mit dem Verpacken sind einige Arbeitsschritte verbunden:
%hh
.
Frequenz=300
).
Die Schlüssel, also die Namen der Felder, werden dabei aus dem Attribut NAME
des INPUT
-Tag entnommen.
Name=Sittich&Frequenz=300&Kernel=2.2.10
METHOD
abhängt, nämlich als
Kommandozeilenoption oder in der Umgebungsvariablen QUERY_STRING
bei
GET
oder über den Standardeingabekanal bei POST
.
Da die Server-Anwendung sich nicht darauf verlassen sollte, dass der Autor der
HTML-Datei stets nur ein und dieselbe Methode verwendet, wollen wir Reaktionsmechanismen
für beide Arten implementieren.
Diese Umgebungsvariable ist übrigens nicht die einzige, die der Server für die CGI-Anwendung bereitstellt. Es sind sogar so viele, dass ich sie hier gar nicht alle aufzählen kann und deshalb nur ein paar wenige erwähne:
CONTENT_LENGTH
enthält die Größe der Anfrage in Bytes.
REMOTE_ADDR
stellt die IP-Adresse des Client-Rechners dar, auf dem der
Browser läuft und von dem die Anfrage kommt.
SCRIPT_NAME
ist der Name des CGI-Programms, das ausgeführt wird.
Was soll nun unsere CGI-Anwendung genau tun? Versuchen Sie vor dem Weiterlesen diese Frage selbst zu beantworten.
Das Programm, das ich Ihnen gleich vorstelle, geht in folgenden Schritten vor:
GET
oder POST
.
Content-type
gefolgt vom eigentlichen Typ und einer Leerzeile geschieht.
Für HTML, das ich auch im Beispiel verwende, sieht das folgendermaßen aus:
cout << "Content-type: text/html" <<endl<<endl;
html
durch plain
.
Unsere Anwendung hat damit eine recht kurze main()
-Funktion:
int main()
{
Liste liste;
if (analysiereAnfrage(liste) == false)
antwortFehler();
else
antworte(liste);
return 0;
}
Die Art der Anfrage ist in der Shell-Variablen REQUEST_METHOD
enthalten. Den Inhalt von Umgebungsvariablen
können Sie sehr leicht mit Hilfe der Funktion getenv()
bestimmen. Diese
erwartet den Namen der Variablen in Form eines Zeigers auf ein char
-Feld
und gibt deren Inhalt in derselben Form zurück. In unserem Programm heißt das
etwa
bool analysiereAnfrage(Liste& _liste)
{
// Bestimme die Anforderungsart
string request_method =
getenv("REQUEST_METHOD");
Anschließend müssen wir für beide Methoden jeweils einen Puffer aufbauen, der die Eingabe aufnimmt.
// Puffer fuer uebergebene Daten
char* buffer = 0;
unsigned int len;
// Behandle eine POST-Anforderung
if (request_method == "POST")
{
len = atoi(getenv("CONTENT_LENGTH"));
buffer = new char[len+1];
for(unsigned int i=0; i<len; i++)
cin.get(buffer[i]);
}
// Behandle eine GET-Anforderung
if (request_method == "GET")
{
len = strlen(getenv("QUERY_STRING"));
buffer = new char[len+1];
strcpy(buffer,getenv("QUERY_STRING"));
}
// Null-Zeichen zur Terminierung
buffer[len] = 0;
// Kopiere Puffer in String
string eingabe = buffer;
delete[] buffer;
Nun haben wir die Eingabe in Form einer langen, zusammenhängenden Zeichenkette
in der Variablen eingabe
vor uns. Jetzt stehen wir vor der Aufgabe, diese
in Paare aus Schlüsseln und Werten zu trennen. Dazu bedienen wir uns einiger
Methoden der Klasse string
, nämlich find()
, um einen Teilstring
ab einer gegebenen Position zu finden, length()
zur Bestimmung der Gesamtlänge
und substr(
, um einen Teilstring ab Position ,
)
mit
Zeichen
heraus zu ziehen. Damit können wir folgendermaßen vorgehen:
// Lokale Variablen zur Teilstringsuche
size_t pos = 0;
size_t old_pos = 0;
// Lies Schluessel/Wert-Paare
while(pos < len)
{
pos = eingabe.find("&", old_pos);
// einziges oder letztes Paar
if (pos == string::npos)
pos = eingabe.length();
// zerlege das Paar
string paar = eingabe.substr(old_pos,
pos-old_pos);
size_t eq_pos = paar.find("=");
string schluessel = paar.substr(0, eq_pos);
konvertiereLeerzeichen(schluessel);
string wert = paar.substr(eq_pos+1);
konvertiereLeerzeichen(wert);
// fuege Paar in Liste ein
_liste.push_back(schluessel, wert);
old_pos = pos+1;
}
Eine entsprechende Antwort lässt sich in unserem Fall leicht erzeugen, da wir dort lediglich die übergebenen Werte auflisten wollen. Wir müssen aber darauf achten, eine korrekte und vollständige HTML-Seite auszugeben, da sonst der Browser sie nicht darstellen kann.
void antworte(Liste& _liste)
{
cout << "Content-type: text/html" << endl;
cout << endl;
cout << "<HTML>" << endl;
cout << "<HEAD><TITLE>Eingabe verstanden";
cout << "</TITLE></HEAD><BODY>" << endl;
cout << "<H2>Sie hatten folgende Angaben"
<< " gemacht: </H2>"<<endl;
cout << "<UL>" << endl;
ListElement* tmp;
for(tmp = _liste.front(); tmp != 0;
tmp = tmp->naechstes)
{
cout << "<LI>" << tmp->schluessel << ": ";
cout << "<EM>" << tmp->wert
<< "</EM></LI>" << endl;
}
cout << "</UL>" << endl;
cout << "</BODY></HTML>" << endl;
}
Was jetzt noch fehlt, ist die Definition der Listenklasse. Diese führt uns vom Internet zurück zum zentralen Thema dieses Abschnitts, der dynamischen Datenverwaltung. Die Liste soll nämlich nur genauso viel Speicherplatz in Anspruch nehmen, wie sie Elemente enthält. Von diesem Typ gibt es noch einige weitere; bekannt sind etwa
Leider kann ich an dieser Stelle nicht näher auf das Thema dynamische Datenstrukturen eingehen. Ich empfehle Ihnen aber, wenn Sie davon noch wenig gehört haben, sich für Ihre weitere Programmierarbeit mit diesen vertraut zu machen, zum Beispiel anhand des Standardwerks [SEDGEWICK 1992]. Auch wenn Sie die meisten Typen nicht selbst implementieren werden, sondern auf die C++-Standardbibliothek zurückgreifen, ist es doch hilfreich, die zugrunde liegenden Prinzipien zu verstehen.
Eines der einfachsten und (vermutlich deshalb) bekanntesten
Beispiele für eine dynamische Datenstruktur ist die einfach verkettete
Liste (siehe auch Abbildung ). Dabei sind die einzelnen Elemente
über einen Zeiger miteinander verbunden. Jedes Element zeigt genau auf ein weiteres.
Auf diese Weise können Sie sich vom Anfang bis zum Ende wie an einer Kette durchhangeln.
Das Ende ist dadurch gekennzeichnet, dass der Zeiger den Wert hat.
Diese Listenart ist ausreichend, wenn es darum geht, eine vorher nicht bestimmbare Anzahl von Elementen im Speicher festzuhalten, ohne dass große Anforderungen an den Zugriffskomfort zu erfüllen sind. Ihr Nachteil ist nämlich, dass Sie nicht beliebig auf jedes Element zugreifen können, sondern immer vom Anfang zum Ende durchlaufen müssen. Als Verbesserung kann man beispielsweise doppelt verkettete Listen erstellen, bei denen jedes Element nicht nur auf seinen Nachfolger, sondern mit einem zweiten Zeiger auch auf seinen Vorgänger verweist. Somit ist zumindest der Durchlauf in zwei Richtungen möglich (siehe auch die entsprechende Übungsaufgabe!).
Als Ausgangspunkt für die Definition der Liste dient uns die Elementstruktur. Wir werden später noch sehen, wie man Listen und andere Datenstrukturen generisch, das heißt ohne konkreten Bezug zum Typ des Inhalts, definieren kann. An dieser Stelle aber legen wir eine Struktur für unsere spezielle Aufgabe, die übergebenen Paare aus Schlüsseln und Werten zu speichern, genau fest.
struct ListElement
{
ListElement* naechstes;
string schluessel;
string wert;
// Standardkonstruktor
ListElement() :
naechstes(0) {}
// Spezieller Konstruktor
ListElement(const string& _schluessel,
const string& _wert) :
naechstes(0),
schluessel(_schluessel),
wert(_wert)
{}
};
Liste
enthält als private Datenelemente neben der Anzahl der
Elemente in der Liste je einen Zeiger auf das erste sowie auf das letzte Element.
So können wir sowohl für Durchläufe auf den Anfang zugreifen als auch bequem
neue Elemente am Ende einfügen.
class Liste
{
private:
ListElement* erstes;
ListElement* letztes;
int anzahl;
public:
Liste() :
erstes(0), letztes(0), anzahl(0) {}
virtual ~Liste();
bool empty() const
{ return (anzahl == 0); }
int size() const
{ return anzahl; }
void push_back(const string& schluessel,
const string& wert);
void pop_front();
ListElement* front()
{ return erstes; }
};
Als Nächstes wollen wir uns ansehen, wie man Elemente an das Ende der Liste anfügt.
void Liste::push_back(const string& _schluessel,
const string& _wert)
{
ListElement* tmp =
new ListElement(_schluessel, _wert);
if (letztes != 0)
letztes->naechstes = tmp;
else
erstes = tmp;
letztes = tmp;
anzahl++;
}
ListElement
,
welches wir auch gleich über den speziellen Konstruktor mit den übergebenen
Werten initialisieren. Wenn die Liste bereits andere Elemente enthält, binden
wir das bisherige Ende an das neue Element an. Ansonsten ist dieses Element
auch gleichzeitig das erste. Anschließend sorgen wir noch dafür, dass der Zeiger
jetzt auf das neue Ende der Liste verweist, und erhöhen den Elementzähler um
Eins.
Das Gegenstück dazu, nämlich das Entfernen eines Elements vom Anfang der Liste,
bietet uns die Methode pop_front()
.
void Liste::pop_front()
{
if (anzahl == 0)
return;
ListElement* tmp = erstes;
erstes = tmp->naechstes;
if (erstes == 0)
letztes = 0;
delete tmp;
anzahl-;
}
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 die den Umgang mit dynamisch
verwaltetem Speicher nicht auf die konkrete Variable ankommt, sondern nur auf
den Zeiger auf die Speicherstelle. Dieser kann durchaus im Laufe des Programms
von verschiedenen Zeigervariablen gehalten werden; es genügt, wenn zum Zeitpunkt
der Freigabe in einer davon die Adresse vorhanden ist -- und natürlich die
Freigabe nur einmal erfolgt.
Mit dieser Methode ausgerüstet gerät unser Destruktor fast schon trivial. Wir müssen
nämlich lediglich pop_front()
so oft aufrufen, bis keine Elemente mehr
vorhanden sind.
Liste::~Liste()
{
while(anzahl != 0)
pop_front();
}
antworte()
(auf Seite
ListElement* tmp;
for(tmp = _liste.front(); tmp != 0;
tmp = tmp->naechstes)
{
cout << "<LI>" << tmp->schluessel << ": ";
cout << "<EM>" << tmp->wert
<< "</EM></LI>" << endl;
}
while
-Schleife umformulieren.
Im Grunde ist das Programm, das wir gerade vervollständigt haben, nicht besonders kompliziert -- es macht ja auch nicht sehr viel. Und doch hat es uns einige Mühen gekostet, die notwendigen Schritte zu verstehen und bereitzustellen. Wenn Sie auf dieser Grundlage eine größere Server-Anwendung erstellen wollen, kann ich Ihnen nur raten, den Aufwand nicht zu unterschätzen. Das, was für den Web-Surfer ganz spielerisch aussieht, bedeutet für den Web-Autor harte Arbeit, sowohl am Design der Seiten als auch bei der Programmierung der dynamischen Inhalte.
Nichtsdestotrotz sollten Sie Ihre Server-Anwendungen mit viel Sorgfalt planen. Denn sie unterscheiden sich in einiger Hinsicht von normalen interaktiven oder Hintergrund-Anwendungen:
system()
, da damit im schlimmsten Fall Shells, also unbeschränkte
Zugriffe auf Ihren Computer, ermöglicht werden.
Auch wenn das eigentliche Thema dieses Abschnitts, die dynamische Speicherverwaltung, unter den Details der CGI-Programmierung etwas verschwand, so sollte Ihnen doch anhand der Listenklasse klar geworden sein, welche Handgriffe zu erledigen und welche Stolperfallen zu beachten sind. Doch selbst wenn Sie alle Tipps verinnerlicht haben, werden Sie immer wieder einmal Abstürzen, falschen Speicherzugriffen und Speicherlecks begegnen. Selbst für erfahrene Programmierer birgt die dynamische Speicherverwaltung in C++ viele Fehlerquellen.
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.
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.
1: const int LEN=32;
2: typedef struct Foo
3: {
4: char *ding;
5: char *dong;
6: };
7: char* f1(void)
8: {
9: char carray[LEN];
10: carray[0] = 'a';
11: return carray;
12: }
13: char* f2(void)
14: {
15: char *cp;
16: int i = LEN;
17: cp = new char[LEN];
18: while (i)
19: cp[i-] = '\0';
20: return cp;
21: }
22: char* f3()
23: {
24: return new char[1024];
25: }
26: int main(void)
27: {
28: char* cp1;
29: char* cp2;
30: char* cp3;
31: Foo *f;
32: f = new Foo;
33: f->ding = new char[128];
34: f->dong = new char[128];
35: cp1 = f1();
36: cp1[0] = 0x01;
37: cp2 = f2();
38: cp3 = f3();
39: cp3 = NULL;
40: delete f;
41: return 0;
42: }
Liste
aus Abschnitt
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
#include <cstdlib>
std::
definiert sein, während die in den herkömmlichen sich im globalen
Namensraum befinden sollten. (Was ein Namensraum ist, können Sie auf Seite
% g++ -D__STL_NO_NAMESPACES ...
Tabelle gibt Ihnen einen ersten groben Überblick, welche Header-Dateien
Sie für welche Anwendungsbereiche einsetzen können. Wenn Sie einen Blick in
die jeweiligen Dateien werfen, werden Sie feststellen, dass die Prototypen teilweise
nur ziemlich schwer lesbar sind. Das liegt an den hohen Portabilitätsanforderungen
von C, durch die manche Definitionen etwas trickreich gestaltet sein müssen.
Darüber hinaus gibt es noch einige wichtige Funktionen, die nicht Teil der ISO-C-Bibliothek sind, da sie vom jeweiligen Betriebssystem abhängen und daher nicht portabel sind. Auf einer Unix-Plattform, die den POSIX-Standard erfüllt (wie Linux), finden Sie solche Routinen in unistd.h. Dort stehen unter anderem Header von Funktionen zur Bestimmung von Informationen über Rechner, Prozesse und Benutzer sowie zum Zugriff auf das Dateisystem.
Die enge Verbindung zwischen dem Kern des Betriebssystems und der Programmiersprache
C ist hinsichtlich der Dokumentation sicherlich ein Vorteil. So gibt es zu so
gut wie allen Bibliotheksfunktionen Beschreibungen, die Sie mittels des man-Kommandos
aufrufen können. Wenn Sie beispielsweise wissen möchten, wie die Funktion sqrt()
definiert ist und in welcher Header-Datei sie sich befindet, geben Sie ein:
% man sqrt
SQRT(3)Linux Programmer's ManualSQRT(3)
NAME
sqrt - square root function
SYNOPSIS
#include <math.h>
double sqrt(double x);
DESCRIPTION
The sqrt() function returns the non-nega-
tive square root of x. It fails and sets
errno to EDOM, if x is negative.
ERRORS
EDOM x is negative.
CONFORMING TO
SVID 3, POSIX, BSD 4.3, ISO 9899
SEE ALSO
hypot(3)
Alle so genannten man-Seiten haben unter Unix diese (oder eine ähnliche) Struktur. Sie unterteilen sich in folgende Abschnitte, wobei nicht alle zwingend erforderlich sein müssen:
NAME
erfahren Sie den oder die Namen der Funktion(en),
einschließlich einer kurzen Beschreibung.
SYNOPSIS
finden Sie die Definition der Schnittstelle, also
welchen Rückgabetyp und welche Argumente die Funktion hat. Außerdem steht hier
im Allgemeinen die C-Header-Datei, die den Prototyp enthält.
DESCRIPTION
gibt eine genauere Beschreibung der
Aufgabe und Arbeitsweise der Funktion. Hier wird neben der Bedeutung der Argumente
auch die Art des Rückgabewerts und das Verhalten im Fehlerfall erklärt.
ERRORS
.
Hier stehen die Fehlerkonstanten, die für den jeweiligen Fall in der C-Bibliothek
definiert sind.
CONFORMING TO
,
das auf die Übereinstimmung der Funktion mit bestimmten Standards hinweist.
SEE ALSO
ist fast überall vorhanden.
% man 3 time
Für die C-Bibliothek liefert der GCC selbstverständlich auch Info-Seiten
mit. Auf Seite haben Sie ja bereits gesehen, wie Sie
darauf zugreifen können. Die Dokumentation zu den hier diskutierten Funktionen
finden Sie unter der Rubrik Libc.
Wenn Sie in Ihren Programmen numerische Berechnungen vornehmen wollen, kommen Sie mit den Grundrechenarten schnell nicht mehr aus. Jeder bessere Taschenrechner kann trigonometrische Funktionen und beliebige Potenzierungen. Auch in Ihren C++-Programmen können Sie solche Funktionen dank der C-Bibliothek nutzen.
Zunächst ein Hinweis: Im Gegensatz zu anderen Programmiersprachen verfügt
C++ über keinen eigenen Operator für die Bildung von Potenzen,
also um
double pow(double x, double y);
double my_power(double x, int n)
{
if (n<0) {
if (x) return 1.0/my_power(x, -n);
else return 0.0; // Fehlerfall!
}
switch(n) {
case 0: return 1.0;
case 1: return x;
case 2: return x*x;
case 3: return x*x*x;
default: return x*my_power(x, n-1);
}
}
switch
-Anweisung
auch ohne break
auskommt und trotzdem voneinander getrennte Fälle hat.)
Diese Funktion dürfte gerade dann deutlich schneller sein, wenn in Ihrem Programm häufig kleine Exponenten auftauchen.
Weitere Funktionen in cmath sind:
double sin(double x)
double cos(double x)
double tan(double x)
sinh()
, asin()
und asinh()
)
double exp(double x)
exp10()
mit Basis 10)
double log(double x)
log10()
mit Basis 10)
double sqrt(double x)
double ceil(double x)
ceil(2.5)
ergibt 3)
double floor(double x)
floor(2.5)
ergibt 2)
double fabs(double x)
fabs(-3.7)
ergibt 3.7)
Daneben sind in dieser Header-Datei auch noch ein paar mathematische Konstanten definiert, zum Beispiel:
M_E |
![]() |
|
M_PI |
![]() |
|
M_PI_2 |
![]() |
|
M_1_PI |
![]() |
|
M_SQRT2 |
![]() |
Wichtig an den Limits ist, dass es sich dabei um die größten beziehungsweise kleinsten Werte handelt, die mit dem jeweiligen Datentyp dargestellt werden können. Wenn Sie also ganz sicher gehen wollen, dass Sie mit einer Zuweisung keinen Überlauf hervorrufen, prüfen Sie vorher den Wert ab, zum Beispiel:
int func(long l)
{
short s;
if (l >= SHRT_MIN && l <= SHRT_MAX)
s = l;
else
cerr << l << `` unzulässig!'';
// ...
}
Tabelle Tab:Limits zeigt Ihnen die wichtigsten Grenzwerte. Die obere Hälfte
enthält dabei die ganzzahligen Werte aus der Datei climits und die untere
die Gleitkommawerte aus cfloat.
Datentyp | Konstante | Wert auf Intel32 |
---|---|---|
SHRT_MIN |
-32.767 | |
short |
SHRT_MAX |
32.768 |
unsigned short |
USHRT_MAX |
65.535 |
INT_MIN |
-2.147.483.648 | |
int |
INT_MAX |
2.147.483.647 |
unsigned int |
UINT_MAX |
4.294.967.295 |
LONG_MIN |
-2.147.483.648 | |
long |
LONG_MAX |
2.147.483.647 |
unsigned long |
ULONG_MAX |
4.294.967.295 |
FLT_MIN |
![]() |
|
float |
FLT_MAX |
![]() |
DBL_MIN |
![]() |
|
double |
DBL_MAX |
![]() |
Bei vielen Funktionen der C-Bibliothek kann es vorkommen, dass der
Aufruf fehlschlägt,
weil beispielsweise einer der übergebenen Parameter einen ungültigen Wert hat und weil
ein referenziertes Objekt nicht existiert. Wie soll das aber das Programm erfahren,
wenn es einen Rückgabewert eines bestimmten Typs und keinen Fehlercode erwartet?
Die Antwort der C-Bibliothek auf dieses Problem ist eine globale Variable namens
errno
. Sehr viele Bibliotheksfunktionen geben, wenn sie ihre Arbeit nicht
zufriedenstellend erledigen können, selbst -1 zurück und setzen errno
auf einen konstanten Wert, der der Art des Fehlers entspricht. Die symbolischen
Konstanten für die Fehlercodes und die globale Variable errno
sind in der Datei cerrno definiert. Eine Liste aller Konstanten mit ihren
Bedeutungen erhalten Sie beispielsweise durch das Shell-Kommando
% man errno
void perror(const char *s); // aus cstdio
const char *sys_errlist[]; // aus cerrno
char *strerror(int errnum); // aus cstring
perror()
gibt zunächst den Text aus, den man ihr als Argument
mitgegeben hat, und druckt dann eine Beschreibung des aktuellen Wertes von errno
-- alles auf die Standard-Fehlerausgabe. Das Feld sys_errlist
lässt
sich über errno
indizieren und erlaubt damit den Zugriff auf den Fehlertext;
somit müssen Sie ihn nicht gleich ausgeben, sondern können ihn auch in eine
Log-Datei oder Ähnliches speichern. Die Funktion strerror()
ist lediglich
eine Kapselung für den Zugriff auf diese Tabelle. Im Extremfall kann es auch
vorkommen, dass eine Funktion einen Fehlercode setzt, der in der Liste nicht
enthalten ist. Da Sie dann einen Zeiger jenseits des Feldendes erhalten würden
-- mit all den bekannten Problemen --, sollten Sie vor dem Indizieren auf
Einhaltung der Listengrenze prüfen. Diese erfahren Sie aus der ebenfalls globalen
Variabalen sys_nerr
.
Betrachten wir als Beispiel die Bildung der Quadratwurzel aus einer negativen Zahl. Die C-Bibliothek rechnet nicht mit komplexen Zahlen, weshalb hier ein Fehler auftritt. Diesen wollen wir mit den gerade besprochenen Funktionen auswerten.
// fehler.cc
#include <iostream>
#include <cmath>
#include <cstdio>
#include <cerrno>
int main()
{
double s = sqrt(-1.0);
if (errno)
{
if (errno < sys_nerr)
cout << "Fehler aus sys_errlist: "
<< sys_errlist[errno]
<< endl;
perror("Fehler aus perror(): ");
}
return 0;
}
Noch ein Tipp: Fragen Sie den Inhalt von errno
immer sofort nach dem
Aufruf der Bibliotheksfunktion ab! Denn wenn Sie zwischendurch noch etwas anderes
tun, könnte es bereits den nächsten Fehler geben. Und selbst wenn alle Aufrufe
erfolgreich sind, können doch in dahinter liegenden Unterfunktionen Fehler aufgetreten
sein, die den Wert von errno
beeinflussen.
Signale werden unter Unix dazu verwendet, Ereignisse von Prozessen
anzuzeigen. Das können Fehlerzustände wie Speicherverletzungen oder Gleitkommafehler
sein, aber auch Befehle an den Prozess von außen wie Unterbrechung oder Beendigung.
Signale werden meist von der Shell erzeugt, teilweise gemäß einem Benutzerwunsch.
Wenn Sie beispielsweise ein Programm aus der Shell starten und während des Laufs
die Tastenkombination Strg+C drücken, wird an das Programm das
Signal SIGINT
geschickt. Hat dieses keine Vorkehrungen getroffen, um das
Signal abzufangen, hält die Shell es an. Von der Shell aus können Sie ein Signal
mit Hilfe des Kommandos kill
an einen Prozess schicken. Sicher kennen Sie die Verwendung von kill
ohne weitere Optionen, nur mit der Prozessnummer, um hängen gebliebene Prozesse
endgültig zu beenden.
Signale können aber auch von Prozessen absichtlich erzeugt werden, um andere
Prozesse damit zu benachrichtigen. Das Schicken von Signalen ist somit eine
einfache Form der Interprozesskommunikation
(siehe auch Seite ).
Die Datei csignal definiert Konstanten für alle Signale. Die wichtigsten
finden Sie in Tabelle Tab:Signale. An dieser sehen Sie auch, dass die
Ausnahmezustände, die durch Signale beschrieben werden, vielfach sehr schwerwiegend
sind und letzte Meldungen eines sterbenden Prozesses darstellen.
Signalkonstante | Beschreibung |
---|---|
SIGINT |
Unterbrechung (Interrupt), ausgelöst zum Beispiel durch Strg+c |
SIGTERM |
Normale Beendigung eines Prozesses |
SIGKILL |
Unbedingte Beendigung eines Prozesses |
SIGBUS |
Bus error |
SIGFPE |
Gleitkommafehler, zum Beispiel bei Division durch 0 |
SIGSEGV |
Speicherzugriffsfehler (segmentation violation) |
SIGALRM |
Auslösen eines Wecksignals |
SIGSTKFLT |
Stacküberlauf oder anderer schwerer Stack-Fehler |
SIGSTOP |
Anhalten des Prozesses, ausgelöst etwa durch Strg+z
oder kill -STOP |
SIGCONT |
Fortsetzung eines angehaltenen Prozesses |
SIGUSR1 |
Benutzerdefiniertes Signal |
Analog zu den Fehlercodes gibt es auch bei den Signalen Funktionen, die den Zahlenwert in einen Text umwandeln. Da ein Signal aber nicht in einer globalen Variablen hinterlegt ist, müssen Sie der jeweiligen Funktion die Signalnummer mit übergeben. Zur Verfügung stehen:
void psignal(int sig, const char *s);
const char *const sys_siglist[];
char *strsignal(int sig);
Ein Prozess kann Signale sowohl an sich selbst als auch an andere Prozesse schicken. An den eigenen Prozess geschieht das mit der Funktion
int raise(int sig);
Wenn Sie ein Signal an einen anderen Prozess schicken wollen, benötigen Sie zunächst
dessen Prozessnummer. Wie wir bereits auf Seite
und folgende gesehen haben, kann man diese entweder mittels des ps-Kommandos
oder über die Funktion
getpid()
bestimmen.
Diese übergeben Sie dann zusammen mit dem gewünschten Signal an die Funktion
kill()
, welche folgende Signatur hat:
int kill(pid_t pid, int sig);
kill()
-1 zurück und setzt errno
.
Mögliche Fehler sind unter anderem, dass kein Prozess mit der übergebenen Nummer
existiert oder die Signalnummer nicht gültig ist.
Um ein Signal abzufangen, verwenden Sie die Funktion signal()
. Diese
ist deklariert als:
void (*signal(int signum,
void (*handler)(int)))(int);
signal
. Das erste Argument vom Typ int
soll sicher die Nummer des Signals sein, das wir abfangen wollen. Was brauchen
wir also noch? Eine Reaktion, wenn das Signal eintrifft; am besten sollte eine
von uns bereitgestellte Funktion aufgerufen werden. Dieser Wunsch wird uns auch
erfüllt; denn das zweite Argument ist ein
Zeiger auf eine Funktion, die void
zurückgibt und einen int
-Wert
erhält. Ein derartiger Funktionszeiger ist auch der Rückgabetyp von signal()
,
was die Deklaration zusätzlich verkompliziert. Sie liefert die Funktion, die
vorher zur Behandlung des Signals verwendet wurde, oder eine der Konstanten
SIG_IGN
(Signal wird ignoriert) oder SIG_DFL
(Behandlung wird
auf Default-Verhalten zurückgesetzt). Beide Konstanten können Sie auch an signal()
übergeben.
Beachten
Sie, dass Sie unter Linux mit
signal()
immer jedes
Auftreten des jeweiligen Signals abfangen. Möchten Sie das Standardverhalten
des Systems in Kraft setzen, müssen Sie dies explizit anweisen. Für SIGINT
geben Sie zu diesem Zweck beispielsweise an:
signal(SIGINT, SIG__DFL);
SIGINT
.
Denn in größeren Anwendungen möchte man nicht einfach aus dem Programm geworfen
werden, sobald der Benutzer Strg+c drückt, sondern erst aufräumen
und dann kontrolliert herunterfahren. Wir wollen ein kleines Programm betrachten,
das sich nicht -- wie sonst üblich -- mit dieser Tastenkombination stoppen
lässt.
// SignalHandler.cc
#include <iostream>
#include <csignal>
#include <unistd.h>
// Reaktionsfunktion auf das Signal
void handler(int signal)
{
if (signal == SIGINT)
cout << "Mit Strg-C bin ich nicht"
<< " zu stoppen!" << endl;
else
psignal(signal, "Unbekanntes Signal: ");
}
// Hauptfunktion
int main()
{
// Signal mit Reaktionsfunktion verbinden
signal(SIGINT, handler);
cout << "Ich arbeite endlos." << flush;
while(1)
{
cout << "." << flush;
sleep(1);
}
return 0;
}
while
-Schleife immer erfüllt ist, verspricht der Text
also nicht zu viel. Eine kleine Pause ist in Form der sleep()
-Funktion
eingebaut. Diese wartet die als Parameter angegebene Zeit in Sekunden, bevor
sie die Programmausführung fortsetzt. Bei diesem Beispiel sollte also jede
Sekunde ein Punkt auf Ihrem Bildschirm erscheinen. Die Funktion sleep()
ist übrigens in der Header-Datei unistd.h definiert.
Wie kann man das Programm aber trotzdem beenden? Es bleibt eigentlich nur noch
das kill
-Kommando, um das Programm abzuschießen. Die dazu nötige
Prozessnummer könnte man sich aus der durch ps aux
(oder Ähnliches) erhältlichen
Liste heraussuchen. Es geht aber auch etwas einfacher. Als alternative Tastenkombination
funktioniert nämlich Strg+z noch. Das damit verbundene Signal
SIGSTOP
kann nicht einmal abgefangen werden. Und ein Druck auf diese
Tasten hält das Programm augenblicklich an. Dabei wird sogar die Prozessnummer
ausgegeben, etwa:
[1] + 636 Suspended SignalHandler
kill
zu verwenden:
% kill 636
Wenn Sie die Signalbehandlung in einem größeren Programm einsetzen wollen, sollten Sie beachten, dass der Benutzer nicht nur ein Signal schickt, sondern (sogar meistens) mehrere hintereinander. Entsprechend oft wird dann auch Ihre Reaktionsfunktion aufgerufen. Sie sollten also sich merken, ob Sie bereits das Herunterfahren Ihres Programms initiiert haben oder nicht, damit Sie diesen Befehl nicht zweimal geben.
Die Header-Datei cstdlib bietet so vielseitige und häufig gebrauchte Funktionen, dass viele C-Programmierer sie schon nur auf Verdacht an die Spitze ihrer Programme geschrieben haben. Für einiges davon gibt es in C++ bessere Möglichkeiten, aber anderes ist auch für den C++-Programmierer noch recht praktisch.
char
-Strings in Zahlen
Wie Sie gesehen haben, kommen Sie um die Verwendung von char
-Strings
nie ganz herum. Da ist es sinnvoll, wenigstens Möglichkeiten der Konvertierung
in andere Datentypen zu kennen. Für die Klasse string
der C++-Standardbibliothek
geht es über Konstruktoren und geeignete Operatoren; wir werden diese später
noch genauer untersuchen. Für elementare Datentypen bietet diese Header-Datei
die Deklarationen einiger Bibliotheksfunktionen an. Im Einzelnen sind das:
double atof(const char *nptr);
Im Grunde handelt es sich bei diesen Funktionen nur um Kapselungen
der allgemeineren Funktionen strtol()
und strtod()
. Diese führen
zwar noch eine zusätzliche Fehlerprüfung durch, haben aber eine so gewöhnungsbedürftige
Syntax, dass ich sie nur Profis empfehlen würde. Wenn Sie möchten, können Sie
auf den man-Seiten zu diesen Funktionen Näheres erfahren.
In vielen Programmen, beim Testen, bei Spielen oder bei Simulationen ist die
Verwendung von zufällig erzeugten Werten sinnvoll. Die C-Bibliothek bietet Ihnen
gleich zwei Funktionen dazu an: rand()
und random()
. Sie basieren
zwar auf dem gleichen Grundalgorithmus, können aber dennoch leichte Unterschiede
aufweisen; man sagt, dass bei rand()
die kleineren Bits nicht in gleichem
Maße zufällig verteilt sein können wie die größeren. Aber letztlich werden Sie
diese Unterschiede wohl nur selten merken. (Wenn Sie mehr über die Theorie der
Zufallszahlenerzeugung erfahren wollen, können Sie beispielsweise -- wie bei vielen anderen
numerischen Themen -- bei [PRESS . 1990] nachschlagen.)
Die Funktion
long int random(void);
RAND_MAX
.
Diese ist im Allgemeinen genauso groß wie INT_MAX
(siehe oben)
und liegt unter Linux auf Intel32-Prozessoren bei circa 2,1 Milliarden.
Wenn Sie etwa eine Zahl
x = a+ (int) ((float)b*random()/(RAND_MAX+1.0));
Sie wissen sicher, dass Zufallszahlenalgorithmen nur pseudo-zufällig arbeiten.
Sie sind stets abhängig von einem Initialwert. Ist dieser gleich, so werden
zur gleichen Zeit auch die gleichen Zufallszahlen erzeugt. Diesen Initialwert
können Sie mit der Funktion srandom()
setzen. Wenn Sie also ganz sicher
gehen wollen, dass bei jedem Programmlauf etwas anderes erzeugt wird, initialisieren
Sie den Zufallsgenerator am besten mit der Uhrzeit:
srandom(time(0));
time()
die Sekunden seit dem 01.01.1970 liefert, haben Sie wirklich
zufällige Werte -- vorausgesetzt, sie starten Ihr Programm nicht innerhalb
einer Sekunde mehrmals. Wenn Sie so etwas vorhaben, müssen Sie auf eine genauere
Timer-Funktion wie ftime()
oder gettimeofday()
zurückgreifen.
Als Beispiel nehmen wir uns ein Feld aus sechs Elementen und füllen es mit Zahlen zwischen 1 und 49 -- wie beim Lotto. Wir müssen nur aufpassen, dass keine Zahl doppelt erscheint. Als einfache Vorsichtsmaßnahme überprüfen wir daher die erzeugte Zahl mit den bereits vorhandenen und generieren gegebenenfalls eine neue Zahl.
UINT neue_zufallszahl(UINT* iarr, UINT idx)
{
UINT z;
// gibt es die neue Zahl schon?
bool exists;
do
{
exists = false;
// Zufallszahl zwischen 1 und 49
z = 1 + (UINT)(49.0*random()/(RAND_MAX+1.0));
// pruefe ob bereits vorhanden
for(UINT j=0; j<idx; j++)
if (z == iarr[j])
{
exists = true;
break;
}
} while (exists);
return z;
}
UINT
kommt. Sie haben
sich sicher schon gedacht, dass dies als Abkürzung für unsigned int
gemeint
ist. Solche Kurzschreibweisen definiert man in C++ mit dem Schlüsselwort typedef
.
Diese steht am sinnvollsten außerhalb aller Funktionen, eventuell in einer Header-Datei,
damit sie überall bekannt und verfügbar ist. Zum Beispiel:
typedef unsigned int UINT;
int
auf long
umschalten, ohne dass der restliche Code geändert werden müsste.
Den Rest des Programms zur Lottozahlen-Ermittlung sehen wir uns im nächsten Abschnitt an.
Ein Feld zu sortieren ist eine häufig vorkommende Aufgabe. Von den verschiedenen Verfahren, die es dafür gibt, ist der Quick-Sort-Algorithmus einer der effizientesten. Eine Implementierung dafür finden Sie sowohl in der C-Bibliothek als auch in der C++-Standardbibliothek. Erstere werden wir hier verwenden, auch wenn ich Ihnen für Ihre langfristige Programmierarbeit eher zur anderen Version rate.
Die Funktion qsort()
ist ebenfalls in cstdlib
deklariert. Um möglichst universell zu bleiben, wurden zwei Verrenkungen
gemacht: Zum einen arbeitet die Funktion auf dem Feld, das durch einen Zeiger
auf void
definiert
ist. Da sich jeder Zeiger auf einen beliebigen Datentyp darauf konvertieren
lässt, ist größtmögliche Allgemeinheit garantiert. Zum anderen wollten die Autoren
der Funktion die Anwendung nicht auf Objekte beschränken, für die der Vergleich
mittels <
standardmäßig definiert ist. Daher muss der Benutzer selbst
eine Vergleichsfunktion schreiben und als Argument übergeben. Der Prototyp ist
damit:
void qsort(void* base, size_t nmemb, size_t size,
int(*comp)(const void*, const void*))
int
zurückliefern und zwei Feldelemente in Form
von konstanten Zeigern auf void
verarbeiten können. Für Ganzzahlen können
wir diese folgendermaßen implementieren:
int vergleiche(const void* a, const void* b)
{
int a1 = *((int*)a);
int b1 = *((int*)b);
return a1-b1;
}
Anwenden wollen wir die Funktion auf unser Feld mit den Lottozahlen. Diese erzeugen
wir zwar dank obiger Funktion neue_zufallszahl()
paarweise unterschiedlich,
haben sie aber immer noch in beliebiger Reihenfolge vorliegen. Daher setzen
wir qsort()
ein, um es in die gewohnte Form zu bringen.
int main()
{
const UINT anzahl = 6;
UINT iarr[anzahl];
UINT zusatzz;
// Initialisiere Zufallszahlengenerator
srandom(time(0));
// Bestimme 6 Zufallszahlen
for(UINT i=0; i<anzahl; i++)
iarr[i] = neue_zufallszahl(iarr, i);
// und die Zusatzzahl
zusatzz = neue_zufallszahl(iarr, anzahl);
// Sortiere das Feld
qsort( iarr, anzahl, sizeof(UINT), vergleiche);
// Gib das Ergebnis aus
cout << "Die Lottozahlen: ";
for(UINT i=0; i<anzahl; i++)
cout << setw(3) << iarr[i];
cout << " (" << zusatzz << ")" << endl;
return 0;
}
Besonders bei einfachen Programmen, die keine großen Anforderungen an Robustheit
erfüllen müssen, will man nicht jeden Fehler in einer Unterfunktion bis zu main()
zurückreichen, sondern gleich das Programm beenden. Auch dafür gibt es in cstdlib
deklarierte Funktionen. Man unterscheidet dabei zwischen der normalen Programmbeendigung
und der abnormalen. Für erste (und das ist die empfohlene Methode!) verwenden
Sie die Funktion
void exit(int status);
return
aus main()
.
Für ein abnormales Ende gibt es noch
void abort(void);
exit()
ausreichen.
Bereits auf Seite haben wir gesehen, wie man auf Shell-Variablen
aus einem Programm zugreift.
Daher an dieser Stelle nur als Gedächtnisstütze: Es handelt sich dabei um die
Funktion
char *getenv(const char *name);
Diese Header-Datei enthält die Prototypen für die C-typischen Funktionen für Ein-
und Ausgabe. Dazu gehört das Lesen und Schreiben von Dateien. Da diese Funktionalität
vollständig durch die Stream-Klassen aus C++ (ab Seite )
abgedeckt wird, werden Sie kaum noch Bedarf für weitere Funktionen haben. Falls
Sie dennoch mehr wissen wollen, können Sie Details den man-
und info-Seiten zu diesen Funktionen
sowie C-Büchern wie dem Klassiker [KERNIGHAN . RITCHIE 1990] entnehmen.
char
-Strings
(cstring)
Zeichenketten können in C++ auf zweierlei Art repräsentiert werden: einmal als
Felder aus char
-Elementen oder als Objekte vom Typ string
. Ich
hatte Ihnen schon mehrfach empfohlen, wo immer es geht, string
-Objekte
zu verwenden, da dabei die Gefahr von Fehlern wesentlich geringer ist. Manchmal
ist die Verwendung von char
-Strings aber leider unumgänglich, besonders
im Zusammenhang mit Funktionen der C-Bibliothek und des Betriebssystems. In diesem
Abschnitt will ich Ihnen daher einige nützliche Funktionen zum Zugriff auf solche
Zeichenketten und zu deren Manipulation kurz vorstellen. Die meisten davon sind
recht einfach, so dass Sie bei Bedarf sicher schnell damit zurecht kommen werden.
char* strcpy(char* dest, const char* src)
Kopiert den String src
nach dest
(bis zur terminierenden 0). Dabei
muss der Sepicherplatz für dest
schon bereitgestellt sein. Die Variante
strncpy(char* dest, const char* src, size_t n)
kopiert nur die ersten
Zeichen.
char* strcat(char* dest, const char *src)
Hängt den String src
an dest
an, wobei die abschließende 0 überschrieben
und ganz am Ende wieder angefügt wird. Auch hier muss bereits ausreichend Speicherplatz
für dest
vorhanden sein. Zudem dürfen sich die beiden Strings nicht schon
vorher überlappen. Die Variante strncat()
hängt nur die ersten Zeichen
an.
int strcmp(const char* s1, const char* s2)
Vergleicht die beiden angegebenen Strings miteinander und hat als
Rückgabewert 0 bei Gleichheit
oder eine Zahl größer 0, falls s1
lexikographisch größer als s2
,
beziehungsweise eine Zahl kleiner 0, falls s1
kleiner als s2
ist.
Die Variante strncmp()
vergleicht nur die ersten Zeichen, während
strcasecmp()
den vollen Vergleich macht, jedoch Groß- und Kleinschreibung
ignoriert.
char* strchr(const char* s, int c)
Sucht das Zeichen c
im String s
und gibt entweder einen Zeiger
darauf (bei erfolgreicher Suche) oder 0 zurück.
char *strstr(const char* hayst, const char* ndle)
Dient zur Teilstringsuche und findet das erste Vorkommen der Nadel ndle
im Heuhaufen hayst
. Die Funktion liefert einen Zeiger auf den
Beginn des Teilstrings zurück; wird dieser aber nicht gefunden, ist der Rückgabewert
0.
size_t strlen(const char* s)
Bestimmt die Länge des Strings, also die Anzahl der Zeichen ab Anfang bis zur abschließenden 0, wobei diese aber nicht mitzählt.
Auf Beispiele will ich an dieser Stelle verzichten - auch weil Sie diese Funktionen
eher als Ausnahme denn als Regel verwenden sollten. Das Fehlerrisiko ist beim
Umgang mit char
-Strings einfach zu hoch. C++ bietet da mit der string
-Klasse
eine wesentlich bessere Alternative. Und wenn Sie diese Klasse für zu aufwändig
halten und zu viel Overhead darin sehen, sollten Sie wenigstens eine eigene
Klasse erstellen, um die Speicherverwaltung für diese Zeichenketten zu kapseln
und damit halbwegs sicher zu machen.
Ich empfehle Ihnen, sich folgende Aspekte aus diesem Abschnitt einzuprägen:
errno
auf einen Fehlercode. Mit den Funktionen perror()
und strerror()
lässt sich dieser in Klartext umwandeln.
signal()
können Sie in Ihren Programmen auf Signale reagieren und mit
kill()
selbst welche versenden.
rand()
und random()
erzeugen eine Zufallszahl im
Bereich von 0 bis RAND_MAX
. Wollen Sie einen anderen Bereich, müssen
Sie das Ergebnis entsprechend skalieren.
qsort()
sortiert ein Feld mittels einer gegebenen Vergleichsfunktion
mit Hilfe des Quick-Sort-Algorithmus.
char
-Zeichenketten bereitstellt, sollten Sie nach Möglichkeit die
C++-Klasse string
oder etwas Gleichwertiges verwenden, um das Risiko
von Speicherfehlern zu reduzieren.
preisfaktor
multipliziert. Sie repräsentieren jede Produktkategorie
durch die Klasse
class AquariumKlasse
{
public:
float laenge;
float breite;
float hoehe;
AquariumKlasse();
float Volumen();
// ...
};
qsort()
sortieren lassen.
Bisher haben wir uns vorwiegend damit beschäftigt, wie die Programmiersprache C++ aufgebaut ist und nach welchen Regeln Programme mit ihr erstellt werden müssen. Sie haben dabei sicher auch schon gemerkt, dass die Möglichkeiten, C++ zu benutzen, äußerst vielfältig sind. Die Freiheiten, die die Sprache dem Programmierer lässt, sind aber bei der Programmentwicklung im Team eher eine Gefahr. Wenn jeder nach seinem Geschmack arbeitet, kann bald keiner mehr den Code eines anderen verstehen. Aus diesem Grund ist es gerade bei C++ besonders wichtig, dass einheitliche Richtlinien formuliert werden, nach denen alle Programmierer arbeiten. Aber auch für einen Einzelkämpfer können solche Konventionen hilfreich sein, um den eigenen Code nach einiger Zeit noch zu durchschauen. Wie solche Richtlinien zur Namensgebung etc. genau aussehen, ist weniger wichtig, als dass sich alle Beteiligten daran halten. In Firmen und im Internet kursieren eine Menge von Dokumenten mit Codierungskonventionen. Entscheidend ist vor allem, dass für ein Projekt ein solches Dokument überhaupt angelegt und berücksichtigt wird.
Sie sollten sich stets vor Augen halten, dass Software Engineering die einzige Ingenieursdisziplin ist, die nicht auf Naturgesetze Rücksicht nehmen muss. Ein Softwaresystem zu entwerfen ist folglich wie ein eigenes Universum zu schaffen. Das macht die Faszination, aber auch die Versuchung beim Programmieren aus.
Regeln für die Benennung von Klassen, Variablen und so weiter sind zwar für die Lesbarkeit und damit für die Wartbarkeit von großer Bedeutung, für den Compiler, also für die Programmqualität, aber kaum. Ich habe Ihnen bereits an einigen Stellen Empfehlungen gegeben, welche Sprachelemente Sie wie in Ihren Programmen einsetzen sollten. Solche Tipps sind meist Konzentrate der Erfahrungen, die viele Entwickler bei ihrer Arbeit mit der Sprache gemacht haben. Scott Meyers hat in seinen Büchern [MEYERS 1996] und [MEYERS 1997] fast 100 dieser Ratschläge zusammengefasst und ausführlich erläutert. Wenn Sie etwas sicherer im Umgang mit C++ geworden sind, kann ich Ihnen diese Lektüre nur empfehlen. Gute Hinweise finden Sie auch in [BOEHM 2000], [BUSCHMANN . 1998], [ECKEL 1995], [GAMMA . 1996] und [LACOS 1996].
Ich kann natürlich in diesem Rahmen nicht so detailliert auf alle Problembereiche eingehen. Vielmehr will ich Ihnen an dieser Stelle einige Tipps und Hinweise in zusammengefasster Form vorstellen, um Sie noch ein wenig für die dahinter stehende Problematik zu sensibilisieren.
Sicher sind die Regelungen für die Benennung von Bezeichnern diejenigen, die am willkürlichsten getroffen werden können und die sich daher zwischen den einzelnen Projekten am stärksten unterscheiden. Die folgenden sind daher lediglich eine Möglichkeit von vielen, orientieren sich aber an mehreren verbreiteten Standards.
typedef
), Klassen, Strukturen
und Aufzählungstypen sollten mit einem Großbuchstaben beginnen, zum Beispiel:
Raumfahrzeug
oder Queue
. Falls Namen aus mehr als einem Wort bestehen,
sollten die Wörter ohne Zwischenraum aneinander geschrieben werden, wobei aber
jedes Wort mit einem Großbuchstaben beginnt. Zahlen werden dabei als einzelne
Wörter angesehen. Von Abkürzungen sollte ebenso nur jeweils der erste Buchstabe
groß sein. Beispiele: ListEntry
oder Station2Steuerung
.
readSettings()
oder statusBarSize
. Methoden, die
den Wert eines Attributs zurückgeben, sollten mit der Silbe get
beginnen
und nach Möglichkeit als const
deklariert werden, etwa
int getStatus() const;
set
beginnen und den gesetzten Wert, bool
oder void
zurückgeben.
void setStatus(int newStatus);
public
deklariert werden,
sondern nur über Zugriffsmethoden von außen erreichbar sein.
_
verwendet werden, beispielsweise
last_status
oder level_2_zaehler
.
SESSION_UPDATE
oder FILE_HEADER_SIZE
. Generell sollten literale
Konstanten, insbesondere numerische Werte, im Code vermieden werden. Konstanten
sollten nur mit Hilfe von const
oder enum
definiert werden und
nicht mit der Präprozessor-Anweisung #define
.
Auch bei der Organisation Ihres Projekts können Sie einiges falsch machen. Wenn Sie sich an nachfolgende Tipps halten, dürften Sie jedoch in den meisten Fällen keine Probleme haben.
SearchAlgorithm
enthalten.
#include <cstdio>
anstatt
#include <stdio.h>
und #include <iostream>
anstelle von #include <iostream.h>
.
#ifndef SEARCH_ALGORITHM_H
#define SEARCH_ALGORITHM_H
// Definitionen
// ...
#endif
A
auf eine andere (etwa B
) nur dadurch
Bezug nimmt, dass sie sie als Zeiger (B*
) oder Referenz (B&
)
verwendet, so sollten Sie nicht die Deklaration von B
über #include "'B.h"'
einbinden, sondern nur eine Vorwärtsdeklaration
class B;
-Wall
. Versuchen Sie, Ihren Code
so zu schreiben, dass er auch dann ohne Warnungen übersetzt wird.
#define
,
#ifdef
und so weiter). Programmieren Sie Ihre Anweisungen lieber direkt
in C++.
Wenn Sie sich von Anfang an einen übersichtlichen und klar strukturierten Stil angewöhnen, wird Ihr Code später leichter von Ihnen und anderen verstanden und kann damit auch leichter gewartet werden. Natürlich sind einige der folgenden Ratschläge die reine Lehre, die in der Praxis nicht immer so uneingeschränkt berücksichtigt werden kann; bei einem Großteil Ihrer Programme sollte es jedoch gelingen, sie zu beherzigen.
//
und vermeiden Sie Kommentarblöcke aus /*
und */
.
for
-Anweisung stehen. Deklarieren
Sie Ihre Variablen mit dem kleinstmöglichen Gültigkeitsbereich, das heißt nur
innerhalb des Blocks, in dem sie verwendet werden. Deklarieren Sie immer nur
eine Variable in einer Zeile.
*
beziehungsweise
&
direkt neben den Typ, ohne ein Leerzeichen dazwischen.
if
, else
, while
,
for
und do
immer einen vollständigen Block, also mit den
geschweiften
Klammern. So kennzeichnen Sie eindeutig, welche Anweisungen zu diesem Block
gehören und welche nicht.
switch
-Anweisung sollte stets einen default
-Teil enthalten,
der unerwartete Fälle abfangen kann. Jeder case
-Teil sollte mit einer
break
-Anweisung enden; soll die Abarbeitung absichtlich mit dem nächsten
case
-Block fortfahren, so ist dies deutlich durch entsprechende Kommentare
zu kennzeichnen.
C++-Programme leiden zuweilen darunter, dass die Freiheit, die die Sprache bietet, auch viele Risiken mit sich bringt. Indem Sie ein paar elementare Regeln beachten, können Sie aber bereits viel dazu beitragen, Ihre Programme sicherer zu machen.
a = b = 0
). Verwenden Sie den Zuweisungsoperator
nicht an Stellen, wo er mit dem Vergleichsoperator verwechselt werden könnte
(vermeiden Sie also beispielsweise Code wie if (a=getValue()) { ...}
).
Vermeiden Sie ebenso eingebettete Zuweisungen, also Anweisungen wie return (a=getValue());
.
new
und delete
(vermeiden Sie also die C-Gegenstücke malloc()
und
free()
). Beim Freigeben von Feldern sollten Sie ausschließlich den Operator
delete[]
einsetzen.
char
-Felder und verwenden
Sie stattdessen die Klasse string
. Müssen Sie Text aus numerischen Variablen
oder Ähnlichem aufbauen, verwenden Sie die Klasse strstream
(beziehungsweise
stringstream
, sobald der GCC sie unterstützt).
==
oder !=
zu
vergleichen. Definieren Sie lieber eine Konstante (etwa EPS
für das gerne
verwendete Symbol f1
und
f2
mittels if (fabs(f1-f2) <= EPS*(fabs(f1)+fabs(f2))
.
signed
und unsigned
)
in einem Ausdruck zu mischen.
Die Empfehlungen der letzten Abschnitte waren ja eher allgemeiner Natur und könnten so fast auch für C-Programme gelten. Im Folgenden will ich Ihnen nun noch ein paar C++-spezifische Ratschläge geben.
public
, protected
und private
sollten in
dieser Reihenfolge in Ihren Klassendeklarationen auftauchen. In jedem dieser
Abschnitte sollten Sie für die Deklarationen folgende Reihenfolge einhalten:
Typen (etwa klassenspezifische Aufzählungstypen), Attribute, Konstruktoren (beginnend
mit dem Standardkonstruktor), Destruktor, überladene Operatoren, Methoden.
const
deklariert sein. Methoden einer Klasse sollten ebenso
als const
deklariert sein, wo immer dies möglich ist.
friend
-Deklarationen außer Kraft gesetzt werden.
public
von ihrer Basisklasse abgeleitet
werden. Vermeiden Sie Mehrfachvererbung, insbesondere von nicht-abstrakten Klassen.
Versuchen Sie zudem, die Hierarchie Ihrer Klassen flach zu halten und suchen
Sie zunächst Alternativen, bevor Sie weitere Ableitungen bilden.
inline
lediglich eine Empfehlung für den Compiler
ist. Verwenden Sie dieses Schlüsselwort daher äußerst sparsam und für virtuelle
Methoden überhaupt nicht.
virtual
wieder dazu.
In diesem Abschnitt möchte ich auf eine Zusammenfassung verzichten, denn eigentlich ist bereits der gesamte Abschnitt eine Zusammenfassung, nämlich wichtiger Konventionen, Ratschläge und Empfehlungen. Sie haben sicher erkannt, dass das Erlernen des Programmierens mit C++ nicht nur eine Frage der Syntax ist, sondern auch das Design und der geschickte Umgang mit den Sprachelementen entscheidet. Auf Ihrem Linux-Rechner können Sie viele Anwendungen im Source-Code installieren, von denen eine Reihe in C++ geschrieben ist. Nicht alle diese Programme halten sich an alle Tipps aus diesem Abschnitt; Sie werden jedoch überraschend viele davon berücksichtigt finden und überdies am Durchgehen dieses Codes einiges lernen. Begeben Sie sich doch mal auf die Suche!
(C) T. Wieland, 2001. Alle Rechte vorbehalten.