Gratulation! Drei Wochen intensiver Einarbeitung in C++ liegen nun fast hinter Ihnen. In dieser Zeit haben Sie sich ein solides Fundament für die Programmierung in C++ geschaffen. Doch gerade im Bereich der modernen Programmierung muß man sich ständig auf dem laufenden halten. Dieses Kapitel bringt zunächst Ergänzungen zu bisher ausgeklammerten Themen und zeigt dann die Richtung für Ihre weiteren Studien auf.
Beim größten Teil Ihrer Quellcodedateien handelt es sich um C++-Anweisungen. Der Compiler interpretiert die Quellcodedateien und überführt sie in ein ausführbares Programm. Vorher läuft allerdings noch der Präprozessor, und dies ermöglicht bedingte Kompilierung. Heute lernen Sie,
Bei jeder Ausführung des Compilers startet als erstes der Präprozessor. Dieser sucht
nach Anweisungen, die mit einem Nummernzeichen (#
) beginnen. Diese ändern den
Quelltext, so daß im Endeffekt eine neue Quellcodedatei entsteht: eine temporäre Datei,
die man normalerweise nicht zu Gesicht bekommt, die man aber vom Compiler
speichern lassen kann, um sie bei Bedarf zu untersuchen.
Der Compiler liest und kompiliert nicht die originale Quellcodedatei, sondern die Ausgabe
des Präprozessors. Sie kennen dies bereits von der Direktive #include
. Diese Anweisung
teilt dem Präprozessor mit, die Datei mit dem auf die #include
-Direktive folgenden
Namen zu suchen und sie an Stelle der Direktive in die Zwischendatei zu
schreiben. Das verhält sich genauso, als hätten Sie selbst die gesamte Datei direkt in
Ihre Quellcodedatei kopiert. Zu dem Zeitpunkt, zu dem der Compiler den Quellcode zu
sehen bekommt, befindet sich die eingebundene Datei bereits an der richtigen Stelle.
Bei nahezu jedem Compiler kann man über einen Schalter veranlassen, daß der Compiler die Zwischendatei speichert. Diesen Schalter setzt man entweder in der integrierten Entwicklungsumgebung oder in der Befehlszeile. Eine Auflistung und Erläuterung der Schalter finden Sie im Handbuch zu Ihrem Compiler. Dort erfahren Sie auch, welcher Schalter für das Speichern der Zwischendatei in Frage kommt, falls Sie diese Datei untersuchen möchten.
Der Befehl #define
definiert eine String-Ersetzung. Schreibt man zum Beispiel
#define BIG 512
hat man den Präprozessor angewiesen, alle Vorkommen des Strings BIG
gegen den
String 512
auszutauschen. Es handelt sich hier aber nicht um einen String im Sinne
von C++. Die Zeichenfolge 512
wird im Quellcode an allen Stellen, wo das Token BIG
erscheint, buchstabengetreu ersetzt. Ein Token ist eine Zeichenfolge, die sich überall
dort einsetzen läßt, wo man einen String, eine Konstante oder eine andere Buchstabengruppe
verwenden könnte. Schreibt man demzufolge
#define BIG 512
int meinArray[BIG];
steht in der vom Präprozessor produzierten Zwischendatei:
int meinArray[512];
Die #define
-Anweisung ist also verschwunden. Die Anweisungen an den Präprozessor
werden alle aus der Zwischendatei entfernt und erscheinen überhaupt nicht im endgültigen
Quellcode.
Mit #define
lassen sich unter anderem Konstanten ersetzen. Allerdings ist das fast nie
zu empfehlen, da #define
lediglich eine String-Ersetzung vornimmt und keine Typenprüfung
durchführt. Die Verwendung des Schlüsselwortes const
bringt erhebliche Vorteile
gegenüber #define
.
Man kann mit #define
auch einfach eine bestimmte Zeichenfolge definieren. Zum Beispiel
kann man schreiben:
#define BIG
Später prüft man auf die Definition von BIG
und leitet entsprechende Aktivitäten ein.
Die Präprozessor-Anweisungen #ifdef
und #ifndef
testen, ob ein String definiert
bzw. nicht definiert wurde. Bei beiden Befehlen muß vor dem Blockende (das heißt,
vor der nächsten schließenden geschweiften Klammer) ein abschließender #endif
-Befehl
erscheinen.
Der Befehl #ifdef
liefert true
, wenn die getestete Zeichenfolge bereits definiert ist.
Man kann also schreiben:
#ifdef DEBUG
cout << "DEBUG definiert";
#endif
Trifft der Präprozessor auf #ifdef
, durchsucht er eine von ihm angelegte Tabelle, ob
die Zeichenfolge DEBUG
definiert ist. Sollte das der Fall sein, liefert #ifdef
das Ergebnis
true
, und alles bis zum nächsten #else
oder #endif
wird in die Zwischendatei für die
Kompilierung geschrieben. Ergibt die Auswertung von #ifdef
den Wert false
,
schreibt der Präprozessor keine der Anweisungen zwischen #ifdef DEBUG
und #endif
in die Zwischendatei, praktisch so, als hätten diese Anweisungen niemals in der Quelldatei
gestanden.
Das logische Gegenstück zu #ifdef
ist #ifndef
. Diese Anweisung liefert true
, wenn
die Zeichenfolge bis zu diesem Punkt noch nicht in der Datei definiert wurde.
Wie Sie vielleicht erraten, läßt sich der Befehl #else
zwischen #ifdef
bzw. #ifndef
und dem schließenden #endif
einfügen. Listing 21.1 zeigt Einsatzbeispiele für diese
Anweisungen.
Listing 21.1: Einsatz von #define
1: #define DemoVersion
2: #define NT_VERSION 5
3: #include <iostream.h>
4:
5:
6: int main()
7: {
8:
9: cout << "Auf Definitionen von DemoVersion, NT_VERSION";
10: cout << " und WINDOWS_VERSION pruefen...\n";
11: #ifdef DemoVersion
12: cout << "DemoVersion definiert.\n";
13: #else
14: cout << "DemoVersion nicht definiert.\n";
15: #endif
16:
17: #ifndef NT_VERSION
18: cout << "NT_VERSION nicht definiert!\n";
19: #else
20: cout << "NT_VERSION definiert als: " << NT_VERSION << endl;
21: #endif
22:
23: #ifdef WINDOWS_VERSION
24: cout << "WINDOWS_VERSION definiert!\n";
25: #else
26: cout << "WINDOWS_VERSION wurde nicht definiert.\n";
27: #endif
28:
29: cout << "Fertig.\n";
30: return 0;
31: }
Auf Definitionen von DemoVersion, NT_VERSION und WINDOWS_VERSION pruefen...
DemoVersion definiert.
NT_VERSION definiert als: 5
WINDOWS_VERSION wurde nicht definiert.
Fertig.
Die Zeilen 1 und 2 definieren DemoVersion
und NT_VERSION
, wobei NT_VERSION
mit dem
String 5
definiert ist. Zeile 11 testet die Definition von DemoVersion
. Da DemoVersion
-
wenn auch ohne Wert - definiert ist, liefert der Test das Ergebnis true
, und Zeile 12
gibt den String aus.
Der Test in Zeile 17 prüft, ob NT_VERSION
nicht definiert ist. Wie wir eben festgestellt
haben, ist NT_VERSION
aber definiert. Damit liefert der Test das Ergebnis false
, und die
Programmausführung springt zu Zeile 20. Hier findet die Ersetzung des Wortes
NT_VERSION
durch den String 5
statt. Dem Compiler stellt sich diese Zeile dann wie
folgt dar:
cout << "NT_VERSION definiert als: " << 5 << endl;
Das erste Vorkommen von NT_VERSION
in dieser Anweisung wird nicht ersetzt, da der
String in Anführungszeichen steht. Die Substitution erfolgt nur für das zweite
NT_VERSION
. Damit findet der Compiler an dieser Stelle eine 5
vor, genauso, als hätte
man diese Zahl direkt hier eingetippt.
Schließlich testet das Programm in Zeile 23 auf WINDOWS_VERSION
. Da WINDOWS_VERSION
nicht definiert ist, liefert der Test das Ergebnis false
, und Zeile 26 gibt eine entsprechende
Meldung aus.
Projekte bestehen gewöhnlich aus einer größeren Anzahl von Dateien. Dabei kann
jede Klasse eine eigene Header-Datei (zum Beispiel .HPP
) mit der Klassendeklaration
und eine Implementierungsdatei (zum Beispiel .CPP
) mit dem Quellcode der Klassenmethoden
erhalten.
Die main()
-Funktion steht in einer separaten .CPP
-Datei. Alle .CPP
-Dateien werden zu
.OBJ
-Dateien kompiliert und mit dem Linker zu einem einzigen Programm gebunden.
Da Ihre Programme auf Methoden aus vielen Klassen zurückgreifen, sind in jede Datei mehrere Header-Dateien aufzunehmen. Außerdem müssen sich Header-Dateien oftmals untereinander einbinden. Beispielsweise muß die Header-Datei für die Deklaration einer abgeleiteten Klasse die Header-Datei für ihre Basisklasse einschließen.
Nehmen wir an, daß die Klasse Animal
in der Datei ANIMAL.HPP
deklariert ist. Die von
Animal
abgeleitete Klasse Dog
muß die Datei ANIMAL.HPP
in DOG.HPP
einbinden, da sich
Dog
sonst nicht von Animal
ableiten läßt. Die Header-Datei für die Klasse Cat
schließt
ANIMAL.CPP
aus demselben Grund ein.
Wenn man eine Methode definiert, die sowohl Cat
als auch Dog
verwendet, läuft man
Gefahr, ANIMAL.HPP
zweimal einzubinden. Das Ganze führt zu einem Compiler-Fehler,
da es nicht zulässig ist, eine Klasse (Animal
) zweimal zu deklarieren, selbst wenn die
Deklarationen identisch sind. Dieses Problem läßt sich mit entsprechenden Schutzvorkehrungen
lösen. Am Beginn der Header-Datei ANIMAL.HPP
schreiben Sie folgende
Zeilen:
#ifndef ANIMAL_HPP
#define ANIMAL_HPP
... // Hier steht der gesamte Inhalt der Datei
#endif
Diese Zeilen sagen aus: »Wenn der Begriff ANIMAL_HPP
noch nicht definiert ist, dann
definiere ihn jetzt.« Zwischen der #define
-Anweisung und dem schließenden #endif
steht der gesamte Inhalt der Datei.
Wenn das Programm erstmalig diese Datei einbindet und der Compiler die erste Zeile
auswertet, liefert der Test true
. Das heißt: ANIMAL_HPP
ist noch nicht definiert. Demzufolge
holt der Compiler jetzt die Definition von ANIMAL_HPP
nach und bindet dann die
gesamte Datei ein.
Wenn Ihr Programm die Datei ANIMAL.HPP
ein zweites Mal einbindet, liefert die Auswertung
der ersten Zeile das Ergebnis false
, da ANIMAL_HPP
nun definiert ist. Damit
springt das Programm zur nächsten #else
-Klausel (hier gibt es keine) oder zur nächsten
#endif
-Anweisung (am Ende der Datei) und übergeht damit den gesamten Inhalt
der Datei. Die Klasse wird damit nicht zweimal deklariert.
Der tatsächliche Name des definierten Symbols (ANIMAL_HPP
) spielt keine Rolle. Es ist
üblich, den Dateinamen zu verwenden, ihn durchweg in Großbuchstaben zu schreiben
und dabei den Punkt zwischen Dateiname und Erweiterung durch einen Unterstrich zu
ersetzen. Allerdings ist das nur eine Konvention.
Es schadet nichts, Schutzmaßnahmen gegen Mehrfachdeklarationen vorzusehen. Oftmals spart man sich dadurch eine stundenlange Fehlersuche.
Eine Makrofunktion ist ein Symbol, das mit Hilfe von #define
erzeugt wird und in der
Art einer Funktion ein Argument übernimmt. Der Präprozessor substituiert den Ersetzungsstring
durch das jeweils übergebene Argument. Beispielsweise kann man das
Makro VERDOPPELN
wie folgt definieren:
#define VERDOPPELN(x) ( (x) * 2 )
VERDOPPELN(4)
Der gesamte String VERDOPPELN
(4)
wird entfernt und durch den Wert 8
ersetzt. Trifft
der Präprozessor auf die 4
, setzt er dafür ( (4) * 2 )
ein, was dann zu 4 * 2
oder 8
ausgewertet wird.
Ein Makro kann über mehrere Parameter verfügen, wobei man jeden Parameter wiederholt
im Ersetzungstext verwenden kann. Zwei häufig anzutreffende Makros sind
MAX
und MIN
:
#define MAX(x,y) ( (x) > (y) ? (x) : (y) )
#define MIN(x,y) ( (x) < (y) ? (x) : (y) )
Beachten Sie, daß in einer Makro-Definition die öffnende Klammer für die Parameterliste unmittelbar auf den Makronamen folgen muß - ohne Leerzeichen dazwischen. Der Präprozessor ist nicht so nachsichtig mit Whitespace-Zeichen wie der Compiler.
#define MAX (x,y) ( (x) > (y) ? (x) : (y) )
und anschließend versucht, MAX
wie folgt zu verwenden
int x = 5, y = 7, z;
z = MAX(x,y);
int x = 5, y = 7, z;
z = (x,y) ( (x) > (y) ? (x) : (y) )(x,y)
Es findet eine einfache Textersetzung statt und nicht der Aufruf des Makros. Das Token
MAX
würde durch (x,y) ( (x) > (y) ? (x) : (y) )
substituiert, woran sich das
nach MAX
angegebene (x,y)
anschließt.
Entfernt man das Leerzeichen zwischen MAX
und (x,y)
, erhält man dagegen den folgenden
Zwischencode:
int x = 5, y = 7, z;
z =7;
Vielleicht fragen Sie sich, warum in den bisher präsentierten Makros so viele Klammern
vorkommen. Der Präprozessor ist nicht darauf angewiesen, daß Klammern um
die Argumente in der Ersetzungszeichenfolge stehen. Allerdings helfen Ihnen die
Klammern, unerwünschte Nebeneffekte zu vermeiden, wenn Sie komplizierte Werte
an ein Makro übergeben. Wenn Sie zum Beispiel MAX
als
#define MAX(x,y) x > y ? x : y
definieren und die Werte 5
und 7
übergeben, funktioniert das Makro wie erwartet. Die
Übergabe komplizierterer Ausdrücke führt aber zu unerwarteten Ergebnissen wie es
Listing 21.2 demonstriert.
Listing 21.2: Klammern in Makros
1: // Listing 21.2 Makro-Erweiterung
2: #include <iostream.h>
3:
4: #define CUBE(a) ( (a) * (a) * (a) )
5: #define THREE(a) a * a * a
6:
7: int main()
8: {
9: long x = 5;
10: long y = CUBE(x);
11: long z = THREE(x);
12:
13: cout << "y: " << y << endl;
14: cout << "z: " << z << endl;
15:
16: long a = 5, b = 7;
17: y = CUBE(a+b);
18: z = THREE(a+b);
19:
20: cout << "y: " << y << endl;
21: cout << "z: " << z << endl;
22: return 0;
23: }
y: 125
z: 125
y: 1728
z: 82
Zeile 4 definiert das Makro CUBE
. Bei jedem Aufruf dieses Makros wird das Argument x
zwischen die Klammern eingefügt. Zeile 5 definiert das Makro THREE
ohne Klammern.
Bei der ersten Verwendung dieser Makros übergibt man den Wert 5
als Parameter,
und beide Makros arbeiten wie erwartet. CUBE(5)
ergibt die Makro-Erweiterung ( (5)
* (5) * (5) )
und liefert damit den Wert 125
. Die Erweiterung von THREE(5)
führt zu
5 * 5 * 5
und wird ebenfalls zu 125
ausgewertet.
Bei der zweiten Verwendung in den Zeilen 16 bis 18 lautet der Parameter 5 + 7
. In
diesem Fall liefert CUBE(5+7)
die Erweiterung
( (5+7) * (5+7) * (5+7) )
( (12) * (12) * (12) )
entspricht und das Ergebnis 1728 liefert. Dagegen wird THREE(5+7)
zu
5 + 7 * 5 + 7 * 5 + 7
erweitert. Da die Multiplikation einen höheren Vorrang als die Addition hat, ergibt sich
5 + (7 * 5) + (7 * 5) + 7
5 + (35) + (35) + 7
was schließlich das Ergebnis 82
liefert.
In C++ sind Makros mit einigen Problemen verbunden. Erstens werden große Makros
unübersichtlich und sind schwer zu handhaben, da man das gesamte Makro auf einer
Zeile definieren muß. Man kann zwar das Makro mit dem Backslash-Zeichen (\
) auf einer
neuen Zeile fortsetzen, das grundsätzliche Problem bleibt aber bestehen.
Zweitens werden Makros bei jedem Aufruf inline erweitert. Wenn man das Makro ein Dutzend Mal verwendet, erscheint die Substitution zwölfmal im Programm und nicht nur einmal wie bei einem Funktionsaufruf. Auf der anderen Seite ist ein Makro gewöhnlich schneller als eine Funktion, da es den Overhead des Funktionsaufrufs vermeidet.
Die Inline-Erweiterung führt zu einem dritten Problem: Das Makro erscheint nicht im Zwischencode, den der Compiler verarbeitet. Damit ist das Makro für die meisten Debugger nicht verfügbar, und die Fehlersuche in Makros wird zur kniffligen Angelegenheit.
Das letzte Problem ist allerdings das größte: Makros sind nicht typensicher. Es ist zwar bequem, absolut jedes Argument in einem Makro verwenden zu können, doch unterläuft dies vollständig die strenge Typisierung von C++. Für C++-Programmierer kommen Makros deshalb nicht in Frage. Wie Tag 19 gezeigt hat, läßt sich dieses Problem mit Templates beseitigen.
Oftmals ist es möglich, anstelle eines Makros eine Inline-Funktion zu deklarieren. Listing
21.3 verwendet zum Beispiel die Funktion Cube()
, die das gleiche macht, wie
das Makro CUBE
in Listing 21.2 - jetzt allerdings auf eine typensichere Art und Weise.
Listing 21.3: Inline-Funktion statt Makro
1: #include <iostream.h>
2:
3: inline unsigned long Square(unsigned long a) { return a * a; }
4: inline unsigned long Cube(unsigned long a)
5: { return a * a * a; }
6: int main()
7: {
8: unsigned long x=1 ;
9: for (;;)
10: {
11: cout << "Bitte eine Zahl eingeben (0 = Beenden): ";
12: cin >> x;
13: if (x == 0)
14: break;
15: cout << "Ihre Eingabe: " << x;
16: cout << ". Quadrat(" << x << "): ";
17: cout << Square(x);
18: cout<< ". Kubik(" << x << "): ";
19: cout << Cube(x) << "." << endl;
20: }
21: return 0;
22: }
Bitte eine Zahl eingeben (0 = Beenden): 1
Ihre Eingabe: 1. Quadrat(1): 1. Kubik(1): 1.
Bitte eine Zahl eingeben (0 = Beenden): 2
Ihre Eingabe: 2. Quadrat(2): 4. Kubik(2): 8.
Bitte eine Zahl eingeben (0 = Beenden): 3
Ihre Eingabe: 3. Quadrat(3): 9. Kubik(3): 27.
Bitte eine Zahl eingeben (0 = Beenden): 4
Ihre Eingabe: 4. Quadrat(4): 16. Kubik(4): 64.
Bitte eine Zahl eingeben (0 = Beenden): 5
Ihre Eingabe: 5. Quadrat(5): 25. Kubik(5): 125.
Bitte eine Zahl eingeben (0 = Beenden): 6
Ihre Eingabe: 6. Quadrat(6): 36. Kubik(6): 216.
Bitte eine Zahl eingeben (0 = Beenden): 0
Die Zeilen 3 und 4 definieren zwei Inline-Funktionen Square()
und Cube()
. Aufgrund
der Inline-Deklaration wird jeder Aufruf der Funktion wie ein Makro erweitert und der
Overhead eines Funktionsaufrufs entfällt.
Die Inline-Erweiterung bedeutet, daß jeder Funktionsaufruf in den Code durch den Inhalt der Funktion ersetzt wird (wie es beispielsweise in Zeile 17 geschieht). Da kein eigentlicher Aufruf einer Funktion stattfindet, entfällt der Overhead, um die Übergabeparameter und die Adresse für den Rücksprung aus der Funktion auf dem Stack abzulegen.
Zeile 17 ruft die Funktion Square()
auf, Zeile 19 die Funktion Cube()
. Durch die Inline-Deklaration
der Funktionen sehen die Zeilen 16 bis 19 letztendlich genauso aus,
als hätte man sie folgendermaßen formuliert:
16: cout << ". Quadrat(" << x << "): "
<< x * x << ". Kubik (" << x << "): "
<< x * x * x << "." << endl;
Der Präprozessor stellt zwei spezielle Operatoren für die Manipulation von Strings in Makros bereit. Der Operator zur Zeichenkettenbildung (#) wandelt das übergebene Argument in eine Zeichenfolge um. Der Verkettungsoperator verbindet zwei Strings zu einem.
Der Operator zur Zeichenkettenbildung (#
, stringizing operator) schließt alle Zeichen,
die bis zum nächsten Whitespace auf den Operator folgen, in Anführungszeichen ein.
Schreibt man also:
#define WRITESTRING(x) cout << #x
WRITESTRING(Das ist ein String);
auf, wandelt das der Präprozessor wie folgt um:
cout << "Das ist ein String";
Der String »Das ist ein String
« ist nun in Anführungszeichen eingeschlossen, wie es
für cout
erforderlich ist.
Mit dem Verkettungsoperator lassen sich mehrere Terme zu einem neuen Wort verbinden. Das neue Wort ist eigentlich ein Token, das man als Klassenname, Variablenname, Index in ein Array oder überall, wo eine Buchstabenfolge stehen darf, verwenden kann.
Nehmen wir an, Sie hätten fünf Funktionen fEinsAusgabe()
, fZweiAusgabe()
, fDreiAusgabe(),
fVierAusgabe()
und fFuenfAusgabe()
benannt sind. Mit der Deklaration
#define fAUSGABE(x) f ## x ## Ausgabe
lassen sich dann mit fAUSGABE(Zwei)
die Zeichenfolge fZweiAusgabe
und mit fAUSGABE(Drei)
die Zeichenfolge fDreiAusgabe
generieren.
Am Ende von Woche 2 haben Sie die Klasse PartsList
entwickelt. Diese Liste konnte
allerdings nur Objekte vom Typ List
behandeln. Nehmen wir an, daß diese Liste ausgezeichnet
funktioniert. Allerdings wollen wir auch Listen mit Tieren, Autos, Computern
usw. aufbauen.
Man könnte nun AnimalList
, CarList
, ComputerList
usw. erzeugen, indem man die
entsprechenden Codeabschnitte an Ort und Stelle per Ausschneiden und Einfügen
mehrfach erzeugt. Das kann allerdings zum Alptraum ausarten, da jede Änderung an
einer Liste in alle anderen zu übernehmen ist.
Als Alternative bieten sich hier Makros und der Verkettungsoperator an. Beispielsweise könnte man folgendes definieren:
#define Listof(Type) class Type##List \
{ \
public: \
Type##List(){} \
private: \
int itsLength; \
};
Das ist zwar ein sehr vereinfachtes Beispiel, zeigt aber das Konzept, das wir in alle erforderlichen
Methoden und Daten übernehmen. Um eine AnimalList
zu erzeugen,
schreibt man:
Listof(Animal)
Das Ganze wird in die Deklaration der Klasse AnimalList
umgewandelt. Bei diesem
Verfahren gibt es einige Probleme, auf die Tag 19 bei der Behandlung von Templates
im Detail eingegangen ist.
Viele Compiler definieren eine Reihe nützlicher Makros. Dazu gehören __DATE__
,
__TIME__
, __LINE__
und __FILE__
. Vor und nach diesen Namen stehen jeweils zwei
Unterstriche, um Konflikte mit anderen Namen, die Sie in Ihrem Programm vergeben,
nach Möglichkeit zu vermeiden.
Trifft der Compiler auf eines dieser Makros, nimmt er die entsprechende Ersetzung
vor. Für __DATE__
wird das aktuelle Datum eingefügt, für __TIME__
die aktuelle Uhrzeit.
Die Makros __LINE__
und __FILE__
ersetzt der Compiler durch die Zeilennummern
des Quellcodes bzw. den Dateinamen. Diese Substitution wird ausgeführt, wenn die
Quelle vorkompiliert wird und nicht, wenn das Programm läuft. Wenn man im Programm
die Ausgabe des Datums mit __DATE__
realisiert, erhält man nicht das aktuelle
Datum bei Programmstart, sondern das Datum, zu dem das Programm kompiliert
wurde. Bei der Fehlersuche stellen diese Makros eine wertvolle Hilfe dar.
Das Makro assert
liefert true
zurück, wenn der Parameter zu true
ausgewertet wird.
Ergibt die Auswertung des Parameters den Wert false
, brechen einige Compiler das
Programm ab, andere lösen eine Ausnahme aus (siehe dazu Tag 20).
Ein leistungsfähiges Charakteristikum des Makros assert
ist es, daß der Präprozessor
überhaupt keinen Code dafür produziert, wenn DEBUG
nicht definiert ist. Während der
Entwicklungsphase ist das Makro eine große Hilfe, und wenn man das fertige Produkt
vertreibt, gibt es weder eine Leistungseinbuße noch eine Vergrößerung der ausführbaren
Programmdatei.
Statt sich auf das vom Compiler bereitgestellte assert
zu stützen, kann man auch ein
eigenes assert
-Makro schreiben. Listing 21.4 zeigt dazu ein Beispiel.
Listing 21.4: Ein einfaches assert-Makro
1: // Listing 21.4 ASSERTS
2: #define DEBUG
3: #include <iostream.h>
4:
5: #ifndef DEBUG
6: #define ASSERT(x)
7: #else
8: #define ASSERT(x) \
9: if (! (x)) \
10: { \
11: cout << "FEHLER!! Annahme " << #x << " nicht zutreffend\n"; \
12: cout << " in Zeile " << __LINE__ << "\n"; \
13: cout << " in Datei " << __FILE__ << "\n"; \
14: }
15: #endif
16:
17:
18: int main()
19: {
20: int x = 5;
21: cout << "Erste Annahme: \n";
22: ASSERT(x==5);
23: cout << "\nZweite Annahme: \n";
24: ASSERT(x != 5);
25: cout << "\nFertig.\n";
26: return 0;
27: }
Erste Annahme:
Zweite Annahme:
FEHLER!! Annahme x != 5 nicht zutreffend
in Zeile 24
in Datei test2104.cpp
Zeile 2 definiert den Begriff DEBUG
. Normalerweise erledigt man das von der Befehlszeile
(oder der IDE) zur Kompilierzeit, so daß man die Fehlersuche bei Bedarf ein- und
ausschalten kann. Die Zeilen 8 bis 15 definieren das Makro assert
. In der Regel
schreibt man das Ganze in eine Header-Datei, und diesen Header (ASSERT.HPP
)
schließt man in alle Implementierungsdateien ein.
In Zeile 5 findet der Test des Begriffs DEBUG
statt. Ist dieser Begriff nicht angegeben,
stellt Zeile 6 eine Definition für assert
bereit, die überhaupt keinen Code erzeugt. Ist
DEBUG
definiert, kommt die in den Zeilen 8 bis 14 definierte Funktionalität zum Tragen.
Das Makro assert
selbst besteht aus einer einzigen langen Anweisung, die über sieben
Quellcodezeilen verteilt ist. Zeile 9 testet den als Parameter übergebenen Wert. Liefert
dieser Test das Ergebnis false
, geben die Anweisungen in den Zeilen 11 bis 13 eine
Fehlermeldung aus. Wenn der übergebene Wert das Ergebnis true
liefert, finden keine
Aktionen statt.
Wenn man ein Programm schreibt, ist man oft hundertprozentig sicher, daß bestimmte
Bedingungen wahr sind: Eine Funktion liefert einen bestimmten Wert, ein Zeiger ist
gültig und so weiter. Es liegt in der Natur der Fehler, daß diese Annahmen unter bestimmten
Bedingungen nicht zutreffen. Beispielsweise stürzt ein Programm ab, obwohl
man absolut sicher war, daß ein Zeiger gültig ist. Bei der Suche nach Fehlern dieser
Art kann Sie assert
unterstützen. Dazu müssen Sie es sich aber zur Gewohnheit
machen, die assert
-Anweisungen großzügig im Code vorzusehen. Bei jeder Zuweisung
oder der Übergabe eines Zeigers als Parameter oder Rückgabewert einer Funktion
sollten Sie mit assert
prüfen, ob dieser Zeiger gültig ist. Ist ein Codeabschnitt von
einem bestimmten Wert einer Variablen abhängig, verifizieren Sie mit assert
, daß die
Annahmen auch zutreffen.
Der häufige Einsatz von assert
-Anweisungen zieht keine Leistungseinbußen nach
sich. Man läßt diese Anweisungen nach abgeschlossener Fehlersuche durch eine #undefine
-Anweisung aus dem Code entfernen. Darüber hinaus stellen diese Anweisungen
eine gute interne Dokumentation dar, die den Leser darauf hinweisen, was der
Programmierer an einem bestimmten Punkt im Programmablauf als wahr angenommen
hat.
Gestern haben Sie gesehen, wie man Fehlerbedingungen mit Exceptions abfängt. Beachten
Sie bitte, daß assert
nicht dafür vorgesehen ist, Laufzeitfehler durch ungültige
Daten, Speichermangel, Verletzungen beim Dateizugriff oder ähnliche Bedingungen
zu behandeln. Das Makro assert
dient ausschließlich dazu, Programmierfehler aufzuspüren.
Wenn sich also ein assert
-Makro »bemerkbar macht«, wissen Sie, daß der
Code einen Fehler enthält.
Im fertigen Code, den Sie an Ihre Kunden ausliefern, sind natürlich keine assert
-Makros
enthalten. Sie können sich nicht mehr darauf verlassen, daß assert
ein Problem
zur Laufzeit aufdeckt, weil es keine assert
-Makros mehr gibt.
Ein häufiger Fehler ist es, mit assert
den Rückgabewert einer Speicherzuweisung zu
testen:
Animal *pCat = new Cat;
Assert(pCat); // Falsche Nutzung von assert
pCat->EineFunktion();
Es handelt sich hier um einen klassischen Programmierfehler. Wenn der Entwickler
das Programm ausführt, ist immer genügend Speicher vorhanden, und assert
wird
niemals aktiv. Immerhin hat der Programmierer seinem Computer ausreichend RAM
spendiert, um zum Beispiel höhere Geschwindigkeiten beim Kompilieren oder beim
Debuggen zu erreichen. Beim »armen« Kunden, der vielleicht nicht über die üppige
Speicherausstattung verfügt, scheitert der Aufruf von new
und gibt NULL
zurück. Das
assert
-Makro ist im fertigen Programm nicht mehr präsent und kann mithin auch
nicht anzeigen, daß der Zeiger auf NULL
verweist. Sobald das Programm die Anweisung
pCat->EineFunktion();
erreicht, stürzt es ab.
Der Rückgabewert NULL
bei einer Speicherzuweisung ist kein Programmierfehler, sondern
eine Ausnahmebedingung. Das Programm muß in der Lage sein, sich aus diesem
Zustand zu befreien, und sei es durch Auslösen einer Exception. Noch einmal zur Erinnerung:
Die gesamte assert
-Anweisung ist verschwunden, wenn DEBUG
nicht mehr definiert
ist. Exceptions wurden am Tag 20 ausführlich behandelt.
Es ist nicht ungewöhnlich, daß sich ein Fehler erst zeigt, nachdem man die assert
-Anweisungen
aus dem Code entfernt hat. Fast immer hängt das mit unbeabsichtigten
Nebeneffekten zusammen, die in assert
-Anweisungen und anderen, ausschließlich für
die Fehlersuche vorgesehenen Codeabschnitten, auftreten. Zum Beispiel erzeugt man
mit der Anweisung
ASSERT (x = 5)
einen besonders üblen Fehler, da man eigentlich die Bedingung x == 5
testen wollte.
Nehmen wir an, daß Sie unmittelbar vor dieser assert
-Anweisung eine Funktion aufgerufen
haben, die x
gleich 0
gesetzt hat. Von der obigen assert
-Anweisung nehmen
Sie an, daß sie einen Test auf x
gleich 5
vornimmt. Statt dessen setzen Sie x
gleich 5
.
Der Test liefert true
, da x = 5
nicht nur x
auf 5
setzt, sondern auch den Wert 5
zurückgibt.
Und da 5
ungleich Null ist, ergibt die Auswertung das Ergebnis true
.
Unmittelbar nach der assert
-Anweisung ist dann x
wirklich gleich 5
(Sie haben es ja so
gewollt!). Ihr Programm läuft wunderbar. Daher bereiten Sie es für den Vertrieb vor
und schalten die Unterstützung für die Fehlersuche aus. Nun verschwindet die assert
-
Anweisung, und x
wird nicht mehr auf 5
gesetzt. Da x
aber unmittelbar vor dieser assert
-Anweisung den Wert 0
erhalten hat, bleibt dieser Wert auch 0
, und das Programm
stürzt an dieser Stelle ab (oder macht anderen Unsinn).
In Ihrer Verzweiflung schalten Sie die assert
-Anweisungen wieder ein, und siehe da:
Der Fehler hat sich verkrümelt. Mit der Zeit können derartige Dinge ganz schön auf
die Nerven gehen. Achten Sie also in Ihrem Code zur Fehlersuche genau auf mögliche
Nebeneffekte. Tritt ein Fehler nur dann auf, wenn diese Unterstützung abgeschaltet
ist, sehen Sie sich den betreffenden Code an und halten Ausschau nach gemeinen Nebeneffekten.
In vielen Klassen gibt es Bedingungen, die auch nach Abschluß einer Elementfunktion
immer true
sein sollten. Diese Klasseninvarianten sind die unerläßlichen Bedingungen
Ihrer Klasse. Zum Beispiel könnte die Forderung bestehen, daß ein CIRCLE
-Objekt immer
einen Radius größer als 0
hat oder daß das Alter eines ANIMAL
-Objekts im Wertebereich
zwischen 0
und 100
liegt.
In diesem Zusammenhang erweist sich eine Invariants()
-Methode als hilfreich, die
nur dann true
zurückgibt, wenn alle diese Bedingungen noch wahr sind. Am Anfang
und Ende jeder Klassenmethode baut man dann eine Assert(Invariants())
-Anweisung
ein. Lediglich vor Aufruf des Konstruktors und nach Abschluß des Destruktors
kann man von der Invariants
-Methode kein true
-Ergebnis erwarten. Listing 21.5 demonstriert
die Verwendung der Invariants()
-Methode in einer trivialen Klasse.
Listing 21.5: Der Einsatz von Invarianten
1: #define DEBUG
2: #define SHOW_INVARIANTS
3: #include <iostream.h>
4: #include <string.h>
5:
6: #ifndef DEBUG
7: #define ASSERT(x)
8: #else
9: #define ASSERT(x) \
10: if (! (x)) \
11: { \
12: cout << "FEHLER!! Annahme " << #x << " nicht zutreffend\n"; \
13: cout << " in Zeile " << __LINE__ << "\n"; \
14: cout << " in Datei " << __FILE__ << "\n"; \
15: }
16: #endif
17:
18:
19: const int FALSE = 0;
20: const int TRUE = 1;
21: typedef int bool;
22:
23:
24: class String
25: {
26: public:
27: // Konstruktoren
28: String();
29: String(const char *const);
30: String(const String &);
31: ~String();
32:
33: char & operator[](int offset);
34: char operator[](int offset) const;
35:
36: String & operator= (const String &);
37: int GetLen()const { return itsLen; }
38: const char * GetString() const { return itsString; }
39: bool Invariants() const;
40:
41: private:
42: String (int); // Privater Konstruktor
43: char * itsString;
44: // unsigned short itsLen;
45: int itsLen;
46: };
47:
48: // Standardkonstruktor erzeugt String von 0 Byte Laenge
49: String::String()
50: {
51: itsString = new char[1];
52: itsString[0] = '\0';
53: itsLen=0;
54: ASSERT(Invariants());
55: }
56:
57: // Privater (Hilfs-) Konstruktor, der nur von Methoden
58: // der Klasse fuer das Erzeugen von Null-Strings der
59: // erforderlichen Groeße verwendet wird.
60: String::String(int len)
61: {
62: itsString = new char[len+1];
63: for (int i = 0; i<=len; i++)
64: itsString[i] = '\0';
65: itsLen=len;
66: ASSERT(Invariants());
67: }
68:
69: // Konvertiert ein Zeichen-Array in einen String
70: String::String(const char * const cString)
71: {
72: itsLen = strlen(cString);
73: itsString = new char[itsLen+1];
74: for (int i = 0; i<itsLen; i++)
75: itsString[i] = cString[i];
76: itsString[itsLen]='\0';
77: ASSERT(Invariants());
78: }
79:
80: // Kopierkonstruktor
81: String::String (const String & rhs)
82: {
83: itsLen=rhs.GetLen();
84: itsString = new char[itsLen+1];
85: for (int i = 0; i<itsLen;i++)
86: itsString[i] = rhs[i];
87: itsString[itsLen] = '\0';
88: ASSERT(Invariants());
89: }
90:
91: // Destruktor, gibt reservierten Speicher frei
92: String::~String ()
93: {
94: ASSERT(Invariants());
95: delete [] itsString;
96: itsLen = 0;
97: }
98:
99: // Zuweisungsoperator, gibt vorhandenen Speicher frei,
100: // kopiert dann String und Groeße
101: String& String::operator=(const String & rhs)
102: {
103: ASSERT(Invariants());
104: if (this == &rhs)
105: return *this;
106: delete [] itsString;
107: itsLen=rhs.GetLen();
108: itsString = new char[itsLen+1];
109: for (int i = 0; i<itsLen;i++)
110: itsString[i] = rhs[i];
111: itsString[itsLen] = '\0';
112: ASSERT(Invariants());
113: return *this;
114: }
115:
116: // Nicht konstanter Offset-Operator
117: char & String::operator[](int offset)
118: {
119: ASSERT(Invariants());
120: if (offset > itsLen)
121: {
122: ASSERT(Invariants());
123: return itsString[itsLen-1];
124: }
125: else
126: {
127: ASSERT(Invariants());
128: return itsString[offset];
129: }
130: }
131: // Konstanter Offset-Operator
132: char String::operator[](int offset) const
133: {
134: ASSERT(Invariants());
135: char retVal;
136: if (offset > itsLen)
137: retVal = itsString[itsLen-1];
138: else
139: retVal = itsString[offset];
140: ASSERT(Invariants());
141: return retVal;
142: }
143: bool String::Invariants() const
144: {
145: #ifdef SHOW_INVARIANTS
146: cout << " String OK ";
147: #endif
148: return ( (itsLen && itsString) ||
149: (!itsLen && !itsString) );
150: }
151: class Animal
152: {
153: public:
154: Animal():itsAge(1),itsName("John Q. Animal")
155: {ASSERT(Invariants());}
156: Animal(int, const String&);
157: ~Animal(){}
158: int GetAge() { ASSERT(Invariants()); return itsAge;}
159: void SetAge(int Age)
160: {
161: ASSERT(Invariants());
162: itsAge = Age;
163: ASSERT(Invariants());
164: }
165: String& GetName()
166: {
167: ASSERT(Invariants());
168: return itsName;
169: }
170: void SetName(const String& name)
171: {
172: ASSERT(Invariants());
173: itsName = name;
174: ASSERT(Invariants());
175: }
176: bool Invariants();
177: private:
178: int itsAge;
179: String itsName;
180: };
181:
182: Animal::Animal(int age, const String& name):
183: itsAge(age),
184: itsName(name)
185: {
186: ASSERT(Invariants());
187: }
188:
189: bool Animal::Invariants()
190: {
191: #ifdef SHOW_INVARIANTS
192: cout << " Animal OK ";
193: #endif
194: return (itsAge > 0 && itsName.GetLen());
195: }
196:
197: int main()
198: {
199: Animal sparky(5,"Sparky");
200: cout << "\n" << sparky.GetName().GetString() << " ist ";
201: cout << sparky.GetAge() << " Jahre alt.";
202: sparky.SetAge(8);
203: cout << "\n" << sparky.GetName().GetString() << " ist ";
204: cout << sparky.GetAge() << " Jahre alt.";
205: return 0;
206: }
String OK String OK String OK String OK
String OK String OK String OK String OK
String OK Animal OK String OK Animal OK
Sparky ist Animal OK 5 Jahre alt. Animal OK Animal OK Animal OK
Sparky ist Animal OK 8 Jahre alt. String OK
Die Zeilen 9 bis 15 definieren das Makro assert
. Ist DEBUG
definiert, gibt dieses Makro
eine Fehlermeldung aus, wenn es das Ergebnis false
liefert.
Zeile 39 deklariert die Elementfunktion Invariants()
der Klasse String
. Die Definition
steht in den Zeilen 143 bis 150. Der Konstruktor wird in den Zeilen 49 bis 55 deklariert.
Nachdem das Objekt vollständig konstruiert ist, ruft Zeile 54 die Methode Invariants()
auf, um die korrekte Konstruktion zu bestätigen.
Dieses Schema wiederholt sich bei den anderen Konstruktoren, und der Destruktor
ruft Invariants()
auf, bevor er sich an den Abbau des Objekts heranmacht. Die restlichen
Klassenfunktionen rufen Invariants()
einmal vor Ausführung einer Aktion und
erneut vor der Rückkehr auf. Damit bestätigt und validiert man ein grundlegendes
Prinzip von C++: Elementfunktionen (außer Konstruktoren und Destruktoren) sollten
auf gültigen Objekten arbeiten und sie in einem gültigen Zustand belassen.
In Zeile 176 deklariert die Klasse Animal
ihre eigene Invariants()
-Methode. Die Implementierung
steht in den Zeilen 189 bis 195. Beachten Sie in den Zeilen 155, 158,
161 und 163, daß auch Inline-Funktionen die Invariants()
-Methode aufrufen können.
Neben der Prüfung von Bedingungen mit dem Makro assert
möchte man bei Bedarf
auch die aktuellen Werte von Zeigern, Variablen und Strings ausgeben. Das kann sehr
hilfreich sein, wenn man Annahmen zum Verlauf des Programms überprüft oder in
Schleifen nach Fehlern sucht, die mit Abweichungen von plus/minus 1 bei Zählerwerten
zusammenhängen. Listing 21.6 verdeutlicht dieses Konzept.
Listing 21.6: Ausgabe von Werten im DEBUG-Modus
1: // Listing 21.6 - Werte im DEBUG-Modus ausgeben
2: #include <iostream.h>
3: #define DEBUG
4:
5: #ifndef DEBUG
6: #define PRINT(x)
7: #else
8: #define PRINT(x) \
9: cout << #x << ":\t" << x << endl;
10: #endif
11:
12: enum BOOL { FALSE, TRUE } ;
13:
14: int main()
15: {
16: int x = 5;
17: long y = 73898l;
18: PRINT(x);
19: for (int i = 0; i < x; i++)
20: {
21: PRINT(i);
22: }
23:
24: PRINT (y);
25: PRINT("Hi.");
26: int *px = &x;
27: PRINT(px);
28: PRINT (*px);
29: return 0;
30: }
x: 5
i: 0
i: 1
i: 2
i: 3
i: 4
y: 73898
"Hi.": Hi.
px: 0x2100
*px: 5
Für
px:
kann bei Ihnen ein anderer Wert angezeigt werden (abhängig vom konkreten Computer).
Das Makro in den Zeilen 5 bis 10 schreibt den aktuellen Wert des übergebenen Parameters
auf den Bildschirm. Als erstes wird die mit Anführungszeichen versehene Version
des Parameters in cout
geschrieben. Wenn man also x
übergibt, empfängt cout
die Zeichenfolge "x"
.
Als nächstes erhält cout
den mit Anführungszeichen versehenen String ":\t"
, der die
Ausgabe eines Doppelpunktes mit einem anschließenden Tabulator bewirkt. Als drittes
wird an cout
der Wert des Parameters (x
) und schließlich das Zeichen endl
, das
eine neue Zeile ausgibt und den Puffer leert, übergeben.
In großen, komplexen Projekten möchte man den Debug-Modus differenzierter steuern,
als lediglich DEBUG
ein- und auszuschalten. Zu diesem Zweck definiert man verschiedene
Ebenen der Fehlersuche. Im Programm schaltet man dann - je nachdem,
welche Makros zu verwenden sind und welche unbeachtet bleiben sollen - die verschiedenen
Ebenen ein oder aus.
Um eine Ebene zu definieren, schreiben Sie einfach nach der Anweisung #define DEBUG
die entsprechende Zahl. Obwohl man eine beliebige Anzahl von Ebenen einführen
kann, haben sich im allgemeinen vier Ebenen durchgesetzt: HIGH
, MEDIUM
, LOW
und
NONE
(hoch, mittel, niedrig und keine). Listing 21.7 zeigt ein Beispiel, das die Klassen
String
und Animal
aus Listing 21.5 verwendet.
Listing 21.7: Ebenen für die Fehlersuche
1: enum LEVEL { NONE, LOW, MEDIUM, HIGH };
2: const int FALSE = 0;
3: const int TRUE = 1;
4: typedef int bool;
5:
6: #define DEBUGLEVEL HIGH
7:
8: #include <iostream.h>
9: #include <string.h>
10:
11: #if DEBUGLEVEL < LOW // Muß MEDIUM oder HIGH sein
12: #define ASSERT(x)
13: #else
14: #define ASSERT(x) \
15: if (! (x)) \
16: { \
17: cout << "FEHLER!! Annahme " << #x << " nicht zutreffend\n"; \
18: cout << " in Zeile " << __LINE__ << "\n"; \
19: cout << " in Datei " << __FILE__ << "\n"; \
20: }
21: #endif
22:
23: #if DEBUGLEVEL < MEDIUM
24: #define EVAL(x)
25: #else
26: #define EVAL(x) \
27: cout << #x << ":\t" << x << endl;
28: #endif
29:
30: #if DEBUGLEVEL < HIGH
31: #define PRINT(x)
32: #else
33: #define PRINT(x) \
34: cout << x << endl;
35: #endif
36:
37:
38: class String
39: {
40: public:
41: // Konstruktoren
42: String();
43: String(const char *const);
44: String(const String &);
45: ~String();
46:
47: char & operator[](int offset);
48: char operator[](int offset) const;
49:
50: String & operator= (const String &);
51: int GetLen()const { return itsLen; }
52: const char * GetString() const
53: { return itsString; }
54: bool Invariants() const;
55:
56: private:
57: String (int); // Privater Konstruktor
58: char * itsString;
59: unsigned short itsLen;
60: };
61:
62: // Standardkonstruktor erzeugt String von 0 Byte Laenge
63: String::String()
64: {
65: itsString = new char[1];
66: itsString[0] = '\0';
67: itsLen=0;
68: ASSERT(Invariants());
69: }
70:
71: // Privater (Hilfs-) Konstruktor, wird nur von Methoden
72: // der Klasse fuer das Erzeugen von Null-Strings der
73: // erforderlichen Groeße verwendet.
74: String::String(int len)
75: {
76: itsString = new char[len+1];
77: for (int i = 0; i<=len; i++)
78: itsString[i] = '\0';
79: itsLen=len;
80: ASSERT(Invariants());
81: }
82:
83: // Konvertiert ein Zeichen-Array in einen String
84: String::String(const char * const cString)
85: {
86: itsLen = strlen(cString);
87: itsString = new char[itsLen+1];
88: for (int i = 0; i<itsLen; i++)
89: itsString[i] = cString[i];
90: itsString[itsLen]='\0';
91: ASSERT(Invariants());
92: }
93:
94: // Kopierkonstruktor
95: String::String (const String & rhs)
96: {
97: itsLen=rhs.GetLen();
98: itsString = new char[itsLen+1];
99: for (int i = 0; i<itsLen;i++)
100: itsString[i] = rhs[i];
101: itsString[itsLen] = '\0';
102: ASSERT(Invariants());
103: }
104:
105: // Destruktor, gibt reservierten Speicher frei
106: String::~String ()
107: {
108: ASSERT(Invariants());
109: delete [] itsString;
110: itsLen = 0;
111: }
112:
113: // Zuweisungsoperator, gibt vorhandenen Speicher frei,
114: // kopiert dann String und Groeße
115: String& String::operator=(const String & rhs)
116: {
117: ASSERT(Invariants());
118: if (this == &rhs)
119: return *this;
120: delete [] itsString;
121: itsLen=rhs.GetLen();
122: itsString = new char[itsLen+1];
123: for (int i = 0; i<itsLen;i++)
124: itsString[i] = rhs[i];
125: itsString[itsLen] = '\0';
126: ASSERT(Invariants());
127: return *this;
128: }
129:
130: // Nicht konstanter Offset-Operator
131: char & String::operator[](int offset)
132: {
133: ASSERT(Invariants());
134: if (offset > itsLen)
135: {
136: ASSERT(Invariants());
137: return itsString[itsLen-1];
138: }
139: else
140: {
141: ASSERT(Invariants());
142: return itsString[offset];
143: }
144: }
145: // Konstanter Offset-Operator
146: char String::operator[](int offset) const
147: {
148: ASSERT(Invariants());
149: char retVal;
150: if (offset > itsLen)
151: retVal = itsString[itsLen-1];
152: else
153: retVal = itsString[offset];
154: ASSERT(Invariants());
155: return retVal;
156: }
157:
158: bool String::Invariants() const
159: {
160: PRINT("(String-Invariants ueberprueft)");
161: return ( (bool) (itsLen && itsString) ||
162: (!itsLen && !itsString) );
163: }
164:
165: class Animal
166: {
167: public:
168: Animal():itsAge(1),itsName("John Q. Animal")
169: {ASSERT(Invariants());}
170:
171: Animal(int, const String&);
172: ~Animal(){}
173:
174: int GetAge()
175: {
176: ASSERT(Invariants());
177: return itsAge;
178: }
179:
180: void SetAge(int Age)
181: {
182: ASSERT(Invariants());
183: itsAge = Age;
184: ASSERT(Invariants());
185: }
186: String& GetName()
187: {
188: ASSERT(Invariants());
189: return itsName;
190: }
191:
192: void SetName(const String& name)
193: {
194: ASSERT(Invariants());
195: itsName = name;
196: ASSERT(Invariants());
197: }
198:
199: bool Invariants();
200: private:
201: int itsAge;
202: String itsName;
203: };
204:
205: Animal::Animal(int age, const String& name):
206: itsAge(age),
207: itsName(name)
208: {
209: ASSERT(Invariants());
210: }
211:
212: bool Animal::Invariants()
213: {
214: PRINT("(Animal-Invariants ueberprueft)");
215: return (itsAge > 0 && itsName.GetLen());
216: }
217:
218: int main()
219: {
220: const int AGE = 5;
221: EVAL(AGE);
222: Animal sparky(AGE,"Sparky");
223: cout << "\n" << sparky.GetName().GetString();
224: cout << " ist ";
225: cout << sparky.GetAge() << " Jahre alt.";
226: sparky.SetAge(8);
227: cout << "\n" << sparky.GetName().GetString();
228: cout << " ist ";
229: cout << sparky.GetAge() << " Jahre alt.";
230: return 0;
231: }
AGE: 5
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)
(String-Invariants ueberprueft)Sparky ist (Animal-Invariants ueberprueft)
5 Jahre alt. (Animal-Invariants ueberprueft)
(Animal-Invariants ueberprueft)
(Animal-Invariants ueberprueft)
Sparky ist (Animal-Invariants ueberprueft)
8 Jahre alt. (String-Invariants ueberprueft)
(String-Invariants-ueberprueft)
// Erneuter Durchlauf mit DEBUG = MEDIUM
AGE: 5
Sparky ist 5 Jahre alt.
Sparky ist 8 Jahre alt.
Die Definition des Makros assert
in den Zeilen 11 bis 21 ist so ausgelegt, daß das Makro
bei DEBUGLEVEL
kleiner als LOW
(das heißt, DEBUGLEVEL
hat den Wert NONE
) praktisch
gelöscht wird. Ist irgendeine Ebene für die Fehlersuche aktiviert, nimmt das Makro seine
Arbeit auf. Die Definition von EVAL
in Zeile 24 ist so konzipiert, daß dieses Makro
bei DEBUG
kleiner als MEDIUM
übergangen wird - also wenn DEBUGLEVEL
die Werte NONE
oder LOW
aufweist.
Schließlich deklarieren die Zeilen 30 bis 35 das Makro PRINT
. Es wird übergangen,
wenn DEBUGLEVEL
kleiner als HIGH
ist. Das Makro PRINT
kommt nur zum Einsatz, wenn
DEBUGLEVEL
gleich HIGH
ist, und man kann dieses Makro ausblenden, indem man DEBUGLEVEL
auf MEDIUM
setzt. In diesem Fall bleiben die Makros EVAL
und assert
aktiv.
Das Makro PRINT
kommt innerhalb der Invariants()
-Methoden zum Einsatz, um informative
Meldungen auszugeben. In Zeile 221 wertet das Programm mit dem Makro
EVAL
den aktuellen Wert der Integer-Konstanten AGE
aus.
Häufig möchte man in einem Objekt Flags setzen, um den Zustand des Objekts zu verfolgen. (Befindet es sich im Alarmzustand? Wurde dieser Wert bereits initialisiert? Handelt es sich um den Eintritt in das oder den Austritt aus dem Objekt?)
Man kann das zwar mit benutzerdefinierten Booleschen Werten erreichen, doch wenn man viele Flags hat und der Speicherplatz kritisch ist, empfiehlt es sich, die Flags durch einzelne Bits zu implementieren.
Ein Byte besteht aus 8 Bit, so daß sich in einer 4-Byte-Zahl vom Typ long
genau 32
individuelle Flags speichern lassen. Ein Bit gilt als gesetzt, wenn sein Wert gleich 1 ist,
und als gelöscht oder zurückgesetzt, wenn es den Wert 0 aufweist. Beim Setzen eines
Bits weist man ihm den Wert 1 zu, wenn man das Bit löscht, erhält es den Wert 0. Die
Bits könnte man zwar setzen und löschen, indem man den Wert der long
-Zahl als
Ganzes verändert, das wäre aber umständlich und kaum zu überblicken.
Im Anhang C finden Sie weiterführende Informationen über die Manipulation im binären und hexadezimalen Zahlensystem.
In C++ lassen sich einzelne Bits mit den bitweisen Operatoren manipulieren. Diese Operatoren (siehe Tabelle 21.1) ähneln den logischen Operatoren und werden von Einsteigern in die C++-Programmierung oft mit diesen verwechselt.
Der Operator für bitweises AND
wird durch ein kaufmännisches Und-Zeichen (&
) dargestellt,
während der logische AND
-Operator aus zwei kaufmännischen Und-Zeichen besteht.
Bei einer AND
-Verknüpfung von zwei Bits ist das Ergebnis nur dann 1, wenn beide
Bits gleich 1 sind. Ist mindestens ein Bit gleich 0, ist auch das Ergebnis 0.
Den bitweisen OR
-Operator stellt man mit einem vertikalen Strich (|
) dar, während es
beim logischen OR
-Operator zwei vertikale Striche sind. Bei einer OR
-Verknüpfung von
zwei Bits ist das Ergebnis 1, wenn mindestens ein Bit gesetzt ist. Nur wenn beide Bits
gleich 0 sind, ist auch das Ergebnis 0.
Bei einer bitweisen XOR
-Verknüpfung ist das Ergebnis 1, wenn beide Bits unterschiedliche
Werte aufweisen. Der Exklusiv-OR
-Operator wird durch den Zirkumflex (^
) dargestellt.
Den Komplementoperator schreibt man als Tilde (~
). Der Operator löscht jedes gesetzte
Bit und setzt jedes gelöschte Bit, schaltet also alle Bitwerte einer Zahl in den
entgegengesetzten Zustand um. Wenn der aktuelle Wert der Zahl gleich 1010 0011
lautet, liefert das Komplement dieser Zahl den Wert 0101 1100.
Mit sogenannten Maskierungsoperationen lassen sich einzelne Bits setzen oder löschen.
Wenn Sie Flags in einer Zahl mit 4 Byte speichern und das Bit 8 auf true
setzen
wollen, verknüpfen Sie die Zahl durch eine bitweise OR
-Operation mit dem Wert
128. Warum? Die binäre Darstellung der Zahl 128 lautet 1000 0000. Das achte Bit
hat demnach den Wert 128. Bei einer OR
-Verknüpfung mit dem Wert 128 spielt es
keine Rolle, welche Werte die anderen Bits der Zahl haben, da diese Operation nur
genau das eine Bit setzt und die Werte der übrigen Bits nicht verändert. Nehmen wir
an, daß der aktuelle Wert einer 2-Byte-Zahl gleich 1010 0110 0010 0110 lautet.
Eine bitweise OR
-Verknüpfung mit dem Wert 128 sieht dann folgendermaßen aus:
Bit: 9 8765 4321
1010 0110 0010 0110 // Bit 8 ist geloescht
| 0000 0000 1000 0000 // OR mit 128
===================
1010 0110 1010 0110 // Bit 8 ist gesetzt
Binäre Zahlen stellt man normalerweise so dar, daß das niederwertigste Bit rechts
steht und das höherwertigste links. Die Zählung der Bitpositionen erfolgt also von
rechts nach links. In der Zahl 128 sind alle Bits bis auf das achte - das gesetzt werden
soll - gelöscht. Die OR
-Operation verändert demnach den Ausgangswert 1010 0110
0010 0110 nur an der achten Position, falls das Bit momentan gelöscht ist. Wenn das
achte Bit bereits gesetzt ist, bleibt es gesetzt. Genau das haben wir mit der OR
-Operation
bezweckt.
Um das achte Bit zu löschen, bilden Sie eine bitweise AND
-Verknüpfung der Zahl mit
dem Komplement von 128. Das Komplement von 128 erhalten Sie, indem Sie im
Bitmuster der Zahl (1000 0000) jedes gelöschte Bit setzen und jedes gesetzte Bit löschen.
Das Ergebnis lautet 0111 1111. (In einer Zahl mit mehreren Bytes sind die höherwertigen
Bitpositionen mit 1 aufzufüllen.) Bei einer bitweisen AND
-Verknüpfung mit
diesem Wert bleiben alle Bits in der ursprünglichen Zahl unverändert, nur das achte
Bit wird auf 0 gesetzt:
1010 0110 1010 0110 // Bit 8 ist gesetzt
& 1111 1111 0111 1111 // ~128 (Komplement zu 128)
===================
1010 0110 0010 0110 // Bit 8 ist geloescht
Vollziehen Sie die Rechnung selbst nach. Wenn beide Bits an derselben Position gleich 1 sind, schreiben Sie eine 1 in das Ergebnis. Ist mindestens ein Bit gleich 0, kommt eine 0 an die entsprechende Bitposition im Ergebnis. Vergleichen Sie das Ergebnis mit dem Ausgangswert. Er hat sich nicht verändert, außer daß das achte Bit jetzt gelöscht ist.
Mit der bitweisen XOR
-Verknüpfung können Sie schließlich den Zustand eines Bit umschalten,
unabhängig davon, welchen Wert das Bit momentan hat. Um das achte Bit
umzuschalten, bilden Sie eine XOR
-Verknüpfung mit dem Wert 128:
1010 0110 1010 0110 // Ausgangswert
^ 0000 0000 1000 0000 // Exklusiv-OR mit 128
===================
1010 0110 0010 0110 // Achtes Bit umgeschaltet
^ 0000 0000 1000 0000 // Erneute XOR-Verknuepfung mit 128
===================
1010 0110 1010 0110 // Bit wieder zurueckgeschaltet
Unter bestimmten Umständen zählt jedes Byte, und wenn sich in einer Klasse 6 oder 8 Byte einsparen lassen, kann dadurch erst die Realisierung eines umfangreichen Programms möglich werden. Wenn Ihre Klasse oder Struktur eine Reihe von Boole'schen Variablen enthält oder die Variablen nur einen sehr begrenzten Bereich möglicher Werte annehmen können, läßt sich mit Bitfeldern etwas Platz einsparen.
Mit den Standarddatentypen von C++ kann man als kleinsten Typ in einer Klasse den
Typ char
verwenden, der genau 1 Byte beansprucht. Gewöhnlich setzt man einfach
den Typ int
ein, der aus 2 oder auch 4 Byte besteht. Wenn man mit Bitfeldern arbeitet,
kann man 8 Binärwerte in einem char
und 32 derartige Werte in einer Zahl vom
Typ long
speichern.
Bitfelder sind benannte Elemente, auf die man in der gleichen Weise zugreift wie auf
jedes andere Element der Klasse. Der Typ von Bitfeldern ist immer als unsigned int
deklariert. Nach dem Namen des Bitfeldes schreiben Sie einen Doppelpunkt und eine
Zahl. Diese Zahl gibt dem Compiler an, wie viele Bits der Variablen zuzuweisen sind.
Mit einem Bit (Zahl gleich 1) lassen sich zwei Werte darstellen: 0 und 1. Wenn Sie
eine 2 angeben, können Sie insgesamt vier Werte - 0, 1, 2 und 3 - kodieren. Bei einem
Feld aus 3 Bit sind es acht Werte und so weiter. Anhang C geht näher auf Binärzahlen
ein. Listing 21.8 demonstriert den Einsatz von Bitfeldern.
Listing 21.8: Einsatz von Bitfeldern
1: #include <iostream.h>
2: #include <string.h>
3:
4: enum STATUS { FullTime, PartTime } ;
5: enum GRADLEVEL { UnderGrad, Grad } ;
6: enum HOUSING { Dorm, OffCampus };
7: enum FOODPLAN { OneMeal, AllMeals, WeekEnds, NoMeals };
8:
9: class student
10: {
11: public:
12: student():
13: myStatus(FullTime),
14: myGradLevel(UnderGrad),
15: myHousing(Dorm),
16: myFoodPlan(NoMeals)
17: {}
18: ~student(){}
19: STATUS GetStatus();
20: void SetStatus(STATUS);
21: unsigned GetPlan() { return myFoodPlan; }
22:
23: private:
24: unsigned myStatus : 1;
25: unsigned myGradLevel: 1;
26: unsigned myHousing : 1;
27: unsigned myFoodPlan : 2;
28: };
29:
30: STATUS student::GetStatus()
31: {
32: if (myStatus)
33: return FullTime;
34: else
35: return PartTime;
36: }
37: void student::SetStatus(STATUS theStatus)
38: {
39: myStatus = theStatus;
40: }
41:
42:
43: int main()
44: {
45: student Jim;
46:
47: if (Jim.GetStatus()== PartTime)
48: cout << "Jim studiert nebenbei" << endl;
49: else
50: cout << "Jim studiert ganztags" << endl;
51:
52: Jim.SetStatus(PartTime);
53:
54: if (Jim.GetStatus())
55: cout << "Jim studiert nebenbei" << endl;
56: else
57: cout << "Jim studiert ganztags" << endl;
58:
59: cout << "Jim steht auf dem " ;
60:
61: char Plan[80];
62: switch (Jim.GetPlan())
63: {
64: case OneMeal: strcpy(Plan,"Eine Mahlzeit"); break;
65: case AllMeals: strcpy(Plan,"Alle Mahlzeiten"); break;
66: case WeekEnds: strcpy(Plan,"Wochenende"); break;
67: case NoMeals: strcpy(Plan,"Keine Mahlzeiten");break;
68: default : cout << "Etwas ist schiefgegangen!\n"; break;
69: }
70: cout << Plan << " Speiseplan." << endl;
71: return 0;
72: }
Jim studiert nebenbei
Jim studiert ganztags
Jim steht auf dem Keine Mahlzeiten Speiseplan.
Die Zeilen 4 bis 7 definieren mehrere Aufzählungstypen. Damit werden die möglichen Werte für die Bitfelder in der Studiengruppe definiert.
In den Zeilen 9 bis 28 steht die Deklaration der Klasse student
. Diese eigentlich triviale
Klasse ist vor allem deshalb interessant, weil sie alle Daten in fünf Bits verpackt. Das
erste Bit repräsentiert die Anwesenheit eines Studenten - ganztags oder nur zeitweise.
Im zweiten Bit ist die akademische Laufbahn kodiert. Das dritte Bit gibt darüber Auskunft,
ob der Student einen Wohnheimplatz beansprucht. In den beiden letzten Bits
sind die vier möglichen Schemata für einen Speiseplan untergebracht.
Die Klassenmethoden sind genauso geschrieben wie bei jeder anderen Klasse. Es spielt also keine Rolle, daß die jeweiligen Werte Bitfelder und keine Integer-Zahlen oder Aufzählungstypen sind.
Die Elementfunktion GetStatus()
liest den Boole'schen Bitwert und gibt einen Aufzählungstyp
zurück, obwohl das eigentlich nicht notwendig ist. Man könnte auch den
Wert des Bitfeldes direkt zurückgeben. Die Interpretation der Werte ist Sache des
Compilers.
Wenn Sie das selbst ausprobieren wollen, ersetzen Sie die Implementierung von GetStatus()
durch den folgenden Code:
STATUS student::GetStatus()
{
return myStatus;
}
Das sollte keinen Einfluß auf die Funktionsweise des Programms haben. Die Darstellungsform ist lediglich für die Verständlichkeit des Codes maßgebend. Dem Compiler ist das völlig egal.
Der Code in Zeile 47 soll den Status der Anwesenheit prüfen und dann die jeweilige Meldung ausgeben. Man könnte versucht sein, folgendes zu schreiben:
cout << "Jim ist " << Jim.GetStatus() << endl;
Damit erhält man aber nur folgendes Ergebnis:
Jim ist 0
Der Compiler hat keine Möglichkeit, die Aufzählungskonstante PartTime
in einen verständlichen
Text umzuwandeln.
In Zeile 62 wertet das Programm mit einer switch
-Anweisung die möglichen Speisepläne
aus und schreibt für jeden Wert eine verständliche Meldung in den Ausgabepuffer,
dessen Inhalt Zeile 70 auf den Bildschirm ausgibt. Auch hier hätte man die switch
-
Anweisung in der folgenden Form schreiben können:
case 0: strcpy(Plan,"Eine Mahlzeit"); break;
case 1: strcpy(Plan,"Alle Mahlzeiten"); break;
case 2: strcpy(Plan,"Wochenende"); break;
case 3: strcpy(Plan,"Keine Mahlzeiten");break;
Der wichtigste Punkt beim Einsatz von Bitfeldern ist, daß sich der Benutzer der Klasse überhaupt keine Gedanken um die Implementierung der Datenspeicherung machen muß. Da die Bitfelder als privat deklariert sind, kann man sie bei Bedarf ändern, ohne daß das einen Einfluß auf die Schnittstelle der Klasse zum übrigen Programm hat.
An verschiedenen Stellen des Buchs wurde bereits darauf hingewiesen, daß der Aneignung eines einheitlichen Stils eine besondere Bedeutung zukommt, wenn es auch in vielerlei Hinsicht keine Rolle spielt, welchen Stil Sie wählen. Ein einheitlicher Stil läßt die Bedeutung bestimmter Codeabschnitte klar hervortreten. Beim Schreiben des Programms braucht man nicht ständig nachzusehen, ob man eine Funktion beim letzten Mal mit einem großen Anfangsbuchstaben geschrieben hat oder nicht.
Die folgenden Richtlinien sind willkürlich zusammengestellt. Sie basieren auf den Konzepten, die der Autor in eigenen Projekten realisiert hat und die sich als geeignet erwiesen haben. An diesen Richtlinien können Sie sich bei der Entwicklung eines eigenen Stils orientieren. Nachdem Sie sich auf einen Stil festgelegt haben, sollten Sie ihn strikt einhalten und so behandeln, als wäre er von den Programmiergöttern vorgegeben worden.
Tabulatorsprünge sollten über eine Distanz von vier Zeichen erfolgen. Richten Sie Ihren Editor so ein, daß er jedes Tabulatorzeichen in vier Leerzeichen umwandelt.
Die Ausrichtung der geschweiften Klammern ist eines der kontroversesten Themen zwischen C- und C++-Programmierern. Hierzu einige Vorschläge:
if (bedingung==true)
{
j = k;
EineFunktion();
}
m++;
Schreiben Sie nur so viel auf eine Zeile, wie sich auf einem normalen Bildschirm anzeigen läßt. Ein Code, der über den rechten Bildschirmrand hinausreicht, wird oft übersehen, und der horizontale Bildlauf ist lästig. Teilt man eine Zeile, sollten die folgenden Zeilen mit einem Einzug beginnen. Nehmen Sie Zeilentrennungen an sinnvollen Stellen vor, und belassen Sie einen dazwischenliegenden Operator am Ende der vorhergehenden Zeile (und setzen Sie ihn nicht an den Beginn der folgenden Zeile). Damit ist klar, daß die Zeile nicht allein steht und noch etwas folgt.
In C++ sind Funktionen oftmals zwar kürzer als in C. Trotzdem gilt nach wie vor der gute alte Rat: Halten Sie Funktionen so kurz wie möglich, damit die gesamte Funktion auf eine Seite paßt.
Damit switch
-Anweisungen die Breite einer Seite nicht überschreiten, können Sie sie
wie folgt einrücken:
switch(variable)
{
case WertEins:
AktionA();
break;
case WertZwei:
AktionB();
break;
default:
assert("Unzulaessige Aktion");
break;
}
Ein leicht zu erfassender Code läßt sich auch leichter warten. Beherzigen Sie folgende Tips:
.
, ->
, []
).
!
, ~
, ++
,
-, *
(für Zeiger), &
(Typumwandlungen) und sizeof
.
+
, =
, *
, /
, %
,
>>
, <<
, <
, >
, ==
, !=
, &
, |
, &&
, ||
, ?:
, =
, +=
usw.
4+
3*2
).
if
sollte man durch ein Leerzeichen absetzen: if (a == b)
.
//
durch ein Leerzeichen getrennt
sein.
char* str;
int& einInt;
char *str;
int &einInt;
Für Bezeichner sollte man folgende Regeln einhalten:
p
), Referenzen
(r
) oder Elementvariablen einer Klasse (m_
) setzen.
i
, p
, x
usw.) sollte man nur verwenden, wenn sich durch die Kürze
der Code besser lesen läßt und wo der Gebrauch so offensichtlich ist, daß man auf
einen aussagekräftigen Namen verzichten kann.
Suchen()
, Zuruecksetzen()
, FindeAbsatz()
,
CursorZeigen()
. Namen von Variablen sind gewöhnlich abstrakte Substantive,
eventuell mit einem ergänzenden Substantiv: zaehler
, status
, windGeschwindigkeit
, fensterHoehe
. Boole'sche Variablen sollten einen passenden Namen erhalten:
fensterMinimiert
, dateiGeoeffnet
.
Die Groß-/Kleinschreibung sollte man nicht übersehen, wenn man einen eigenen Stil entwickelt. Dazu folgende Hinweise:
QUELL_DATEI_TEMPLATE
. In C++ kommen
diese allerdings selten vor. Derartige Bezeichner sollte man vor allem für Konstanten
und Templates verwenden.
typedef
und struct
sollten
mit einem Großbuchstaben beginnen. Datenelemente oder lokale Variablen
beginnt man mit einem Kleinbuchstaben.
enum
) beginnen. Zum Beispiel:
enum TextStil
{
tsNormal,
tsFett,
tsKursiv,
tsUnterstrichen,
};
Mit Kommentaren läßt sich ein Programm wesentlich verständlicher gestalten. Wenn man an einem Programm mehrere Tage oder sogar Monate arbeitet, kann man leicht vergessen, was bestimmte Codeabschnitte bewirken oder warum man sie eingebunden hat. Probleme mit dem Verständnis des Codes können auch auftreten, wenn irgend jemand anderes Ihren Code liest. Einheitlich angewandte Kommentare in einem gut durchdachten Stil sind immer die Mühe wert. Hier einige Tips zur Verwendung von Kommentaren:
//
und nicht die Form
/* */
verwenden.
n++; // n wird um eins inkrementiert
Die Zugriffssteuerung sollte ebenfalls einheitlich sein. Einige Tips für den Zugriff:
public:
, private:
, und protected:
. Verlassen
Sie sich nicht auf die Standardeinstellungen.
include
-Anweisungen sollten Sie ebenfalls nach dem
Alphabet ordnen.
virtual
beim Überschreiben optional ist, verwenden
Sie es trotzdem. Es erinnert Sie daran, daß das Element virtuell ist und führt
zu einer einheitlichen Deklaration.
Geben Sie die Definitionen von Methoden möglichst in derselben Reihenfolge wie die Deklarationen an. Man findet sie dann leichter.
Wenn Sie eine Funktion definieren, stellen Sie den Rückgabetyp und alle anderen Modifizierer in die vorangehende Zeile, damit der Klassenname und der Funktionsname am linken Rand beginnen. Damit lassen sich Funktionen leichter auffinden.
Binden Sie nach Möglichkeit keine unnötigen Dateien in Header-Dateien ein. Im Idealfall
benötigen Sie lediglich die Header-Datei der Basisklasse für die in der Datei abgeleitete
Klasse. Zwingend erforderlich sind auch include
-Anweisungen für Objekte,
die Elemente der deklarierten Klasse sind. Für Klassen, auf die nur mit Zeigern oder
Referenzen verwiesen wird, brauchen Sie nur Vorwärtsreferenzen.
Lassen Sie in der Header-Datei keine include
-Datei weg, nur weil Sie annehmen, daß
irgendeine .CPP
-Datei die benötigte include
-Datei einbindet.
Alle Header-Dateien sollten mit Schutzmechanismen gegen Mehrfachdeklaration ausgestattet sein.
Verwenden Sie assert
-Anweisungen, sooft Sie wollen. Diese Anweisungen helfen Ihnen
bei der Fehlersuche, zeigen dem Leser aber auch deutlich, von welchen Annahmen
auszugehen ist. Außerdem denkt man beim Schreiben des Codes genauer darüber
nach, was gültig ist und was nicht.
Verwenden Sie const
, wo immer es angebracht ist: für Parameter, Variablen und Methoden.
Häufig ist sowohl eine konstante als auch eine nicht konstante Version einer
Methode erforderlich. Nehmen Sie das nicht als Entschuldigung, eine auszulassen.
Achten Sie genau auf Typenumwandlungen von const
in nicht-const
und umgekehrt.
Manchmal ist es die einzige Lösung. Prüfen Sie aber, ob es sinnvoll ist, und geben Sie
einen Kommentar an.
Nach drei Wochen harter Arbeit mit C++ sind Sie nun ein kompetenter C++-Programmierer. Der Lernprozeß hört aber nicht einfach an dieser Stelle auf. Auf Ihrem Weg vom Einsteiger in C++ zu einem Experten sollten Sie weitere Wissensquellen ausschöpfen.
Die folgenden Abschnitte bringen einige Quellenhinweise. Diese Empfehlungen spiegeln die persönliche Erfahrung und Meinung des Autors wider. Zu allen Themen gibt es Dutzende Werke. Informieren Sie sich eingehend, bevor Sie sich zum Kauf entschließen.
Als allererstes sollten Sie als C++-Programmierer in die eine oder andere C++-Diskussionsrunde in einem Online-Dienst einsteigen. Diese Gruppen gestatten direkten Kontakt mit Hunderten oder Tausenden von C++-Programmierern, die Ihre Fragen beantworten, Rat bieten und ein Sprachrohr für Ihre Ideen darstellen.
Ich nehme selbst an C++-Internet-Newsgroups teil (comp.lang.c++
und
comp.lang.c++.moderated
) und empfehle sie als ausgezeichnete Quelle für Informationen
und Unterstützung.
Vielleicht sollten Sie auch nach lokalen Benutzergruppen Ausschau halten. Vor allem in größeren Städten finden Sie C++-Interessentengruppen, wo Sie Ideen mit anderen Programmierern austauschen können.
Ihre Fertigkeiten können Sie auch vervollkommnen, indem Sie eine gute Fachzeitschrift zur C++-Programmierung abonnieren. Das absolut beste Magazin dieser Art ist nach Meinung des Autors C++ Report von SIGS Publications. Jede Ausgabe ist vollgepackt mit nützlichen Artikeln. Archivieren Sie diese. Was Sie heute noch nicht betrifft, kann morgen von entscheidender Bedeutung sein.
Für Kommentare, Vorschläge oder Ideen zu diesem Buch oder anderen hat der Autor
immer ein offenes Ohr. Schreiben Sie bitte an jliberty@libertyassociates.com
oder
besuchen Sie seine Webseite: www.libertyassociates.com
. Er freut sich, von Ihnen zu
hören.
Heute haben Sie weitere Einzelheiten zur Arbeit mit dem Präprozessor kennengelernt.
Bei jedem Start des Compilers startet zuerst der Präprozessor und übersetzt die Präprozessor-Direktiven
wie #define
und #ifdef
.
Der Präprozessor führt Textersetzungen aus, obwohl das bei Verwendung von Makros
etwas kompliziert sein kann. Mit Hilfe von #ifdef
, #else
und #ifndef
lassen sich bestimmte
Abschnitte des Codes in Abhängigkeit von festgelegten Bedingungen kompilieren
oder von der Kompilierung ausblenden. Dieses Vorgehen ist hilfreich, wenn
man Programme für mehrere Plattformen schreibt. Die bedingte Kompilierung
kommt häufig auch beim Einbinden von Informationen zur Fehlersuche zum Einsatz.
Makros erlauben komplexe Textersetzungen auf der Basis von Argumenten, die man an das Makro zur Kompilierzeit übergibt. Man sollte auf jeden Fall Klammern um die Argumente von Makros setzen, damit die korrekte Substitution gesichert ist.
Im allgemeinen haben Makros und der Präprozessor in C++ gegenüber C an Bedeutung verloren. C++ bietet eine Reihe von Sprachkonstrukten wie zum Beispiel konstante Variablen und Templates, die leistungsfähige Alternativen zum Präprozessor darstellen.
Weiterhin haben Sie erfahren, wie sich einzelne Bits setzen, löschen und testen lassen und wie man einem Klassenelement eine festgelegte Anzahl von Bits zuweist.
Gegen Ende dieses Tages ging es auch um stilistische Fragen, die das Erstellen der Quelltexte betreffen. Zu guter Letzt wurde noch auf Quellen für Ihre weiteren Studien hingewiesen.
Frage:
Wenn C++ bessere Alternativen als den Präprozessor bietet, warum
gibt es dann diese Option noch?
Antwort:
Erstens ist C++ abwärtskompatibel zu C, und alle wesentlichen Elemente von
C muß C++ unterstützen. Zweitens gibt es einige Einsatzfälle des Präprozessors,
auf die man in C++ weiterhin häufig zurückgreift. Dazu gehören zum
Beispiel die Schutzmaßnahmen gegen Mehrfachdeklarationen.
Frage:
Warum setzt man Makros ein, wenn man eine normale Funktion verwenden
kann?
Antwort:
Makros sind erweiterte Inline-Funktionen und ersparen das wiederholte Eintippen
gleichen Befehle mit kleineren Variationen. Dennoch bieten Templates
eine bessere Alternative.
Frage:
Wie kann ich feststellen, ob ein Makro einer Inline-Funktion vorzuziehen
ist?
Antwort:
Oftmals spielt es keine Rolle, welche Version Sie wählen - nehmen Sie die
einfachere. Makros bieten allerdings die Möglichkeit, Zeichen zu ersetzen sowie
Strings zu manipulieren und zu verketten. Funktion erlauben das nicht.
Frage:
Welche Alternativen zum Präprozessor gibt es, um Zwischenwerte während
der Fehlersuche auszugeben?
watch
-Anweisungen (Überwachte Ausdrücke) innerhalb eines Debuggers. Informationen zu watch
-Anweisungen finden Sie in der Dokumentation zum Compiler bzw. dem Debugger.
Frage:
Wie entscheidet man, ob eine assert
-Anweisung zu verwenden oder
eine Exception auszulösen ist?
Antwort:
Wenn eine zu testende Bedingung true
sein kann, ohne daß ein Programmierfehler
vorliegt, verwenden Sie eine Exception. Kann die Testbedingung ausschließlich
bei einem Fehler im Programm das Ergebnis true
liefern, nehmen
Sie eine assert
-Anweisung.
Frage:
Wann verwendet man Bitstrukturen statt einfacher Integer-Zahlen?
Antwort:
Wenn die Größe des Objekts eine entscheidende Rolle spielt. Bei äußerst begrenztem
Hauptspeicher oder bei Kommunikationsprogrammen gewährleistet
unter Umständen erst die mit Bitfeldern erreichbare Einsparung den Erfolg
eines Produkts.
Frage:
Warum werden Stilfragen so kontrovers diskutiert?
Antwort:
Programmierer sind Gefangene ihrer Gewohnheiten. Wenn Sie sich zum Beispiel
an Einzüge der Art
if (EineBedingung){
// Anweisungen
} // schließende Klammer
gewöhnt haben, fällt es schwer, sich einem alternativen Stil zuzuwenden. Neue Stile haben etwas Mystisches an sich und sind nicht gleich zu überblicken. Wenn Sie Langeweile haben, holen Sie bei einer Newsgroup Rat zu Stilen, geeigneten Editoren für C++ oder der besten Textverarbeitung. Nachdem Sie die entsprechenden Fragen in einer Newsgroup gestellt haben, brauchen Sie nur noch die Flut von sich widersprechenden Antworten abzuwarten.
Antwort:
Ja! Sie beherrschen jetzt C++ - und doch wieder nicht. Vor zehn Jahren
konnte eine einzige Person noch nahezu alles lernen, was über den Mikroprozessor
bekannt war. Heutzutage wäre das ein aussichtsloses Unterfangen.
Man kann nicht wirklich Schritt halten. Und selbst, wenn man es versucht -
die Softwareindustrie ist immer etwas schneller. Hier hilft nur ständige Weiterbildung.
Schöpfen Sie die entsprechenden Quellen - Fachzeitschriften und
Online-Dienste - aus, um über den neuesten Stand informiert zu sein.
Der Workshop enthält Quizfragen, die Ihnen helfen sollen, Ihr Wissen zu festigen, und Übungen, die Sie anregen sollen, das eben Gelernte umzusetzen und eigene Erfahrungen zu sammeln. Versuchen Sie, das Quiz und die Übungen zu beantworten und zu verstehen, bevor Sie die Lösungen in Anhang D lesen und zur Lektion des nächsten Tages übergehen.
#define debug 0
und #undef debug
?
OR
und XOR
?
&
und &&
?
|
und ||
?
STRING.H
zu realisieren.
assert
-Makro, das eine Fehlermeldung zusammen mit dem Dateinamen und der Zeilennummer ausgibt, wenn für die Fehlersuche die Ebene 2 definiert ist, und das ausschließlich eine Fehlermeldung (ohne Dateinamen und Zeilennummer) ausgibt, wenn für die Fehlersuche die Ebene 1 festgelegt ist, und das bei Ebene 0 überhaupt nichts macht.
DPrint
, das auf die Definition von DEBUG
testet. Wenn DEBUG
definiert ist, soll das Makro den als Parameter übergebenen Wert anzeigen.
+
) zu verwenden. Hinweis: Arbeiten Sie mit Bitoperatoren.
© Markt&Technik Verlag, ein Imprint der Pearson Education Deutschland GmbH