vorheriges KapitelInhaltsverzeichnisStichwortverzeichnisFeedbacknächstes Kapitel


Woche 3

Tag 21



So geht's weiter

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,

Präprozessor und Compiler

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.

Das Zwischenformat ansehen

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.

Die Anweisung #define

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.

Konstanten mit #define erzeugen

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.

Testen mittels #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.

Der Präprozessor-Befehl #else

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.

Schutz vor Mehrfachdeklarationen

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.

Makrofunktionen

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 )

Im Code schreibt man dann

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.

Wenn man etwa schreibt

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

erhält man den Zwischencode

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;

Warum so viele Klammern?

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

die

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

gleich

5 + (35) + (35) + 7

was schließlich das Ergebnis 82 liefert.

Makros, Funktionen und Templates

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.

Inline-Funktionen

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;

String-Manipulation

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.

Zeichenkettenbildung

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

und ruft dann

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.

Verkettung

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.

Vordefinierte Makros

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.

assert

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.

Fehlersuche mit assert

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.

assert und Exceptions

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.

Nebeneffekte

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.

Klasseninvarianten

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.

Zwischenwerte ausgeben

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.

Ebenen der Fehlersuche

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.

Was Sie tun sollten

... und was nicht

Schreiben Sie Makronamen durchgängig in Großbuchstaben. Da sich diese Konvention durchgesetzt hat, würde es andere Programmierer nur verwirren, wenn Sie andere Schreibweisen wählen.

Setzen Sie die Argumente von Makros stets in Klammern.

Lassen Sie in Ihren Makros keine Nebeneffekte zu. Verzichten Sie darauf, aus einem Makro heraus Variablen zu inkrementieren oder ihnen Werte zuzuweisen.

Bitmanipulation

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.

Symbol

Operator

&

AND

|

OR

^

XOR (Exklusiv-OR)

~

Komplement

Tabelle 21.1: Bitweise Operatoren

AND

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.

OR

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.

Exklusiv-OR

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.

Komplement

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.

Bits setzen

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.

Bits löschen

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.

Bits umschalten

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

Was Sie tun sollten

Setzen Sie Bits mit Hilfe von Masken und dem bitweisen OR-Operator.

Löschen Sie Bits mit Hilfe von Masken und dem bitweisen AND-Operator.

Schalten Sie Bits mit Hilfe von Masken und dem XOR-Operator (Exklusiv-OR) um.

Bitfelder

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.

Stil

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.

Einzüge

Tabulatorsprünge sollten über eine Distanz von vier Zeichen erfolgen. Richten Sie Ihren Editor so ein, daß er jedes Tabulatorzeichen in vier Leerzeichen umwandelt.

Geschweifte Klammern

Die Ausrichtung der geschweiften Klammern ist eines der kontroversesten Themen zwischen C- und C++-Programmierern. Hierzu einige Vorschläge:

Lange Zeilen

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.

switch-Anweisungen

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

Leerzeichen im Programmtext

Ein leicht zu erfassender Code läßt sich auch leichter warten. Beherzigen Sie folgende Tips:

Statt so:

char *str;
int &einInt;

Namen von Bezeichnern

Für Bezeichner sollte man folgende Regeln einhalten:

Schreibweise von Namen

Die Groß-/Kleinschreibung sollte man nicht übersehen, wenn man einen eigenen Stil entwickelt. Dazu folgende Hinweise:

Kommentare

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:

Zugriff

Die Zugriffssteuerung sollte ebenfalls einheitlich sein. Einige Tips für den Zugriff:

Klassendefinitionen

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.

include-Dateien

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.

assert()

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.

const

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.

Wie geht es weiter?

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.

Hilfe und Rat

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.

Zeitschriften

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.

In Kontakt bleiben

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.

Was Sie tun sollten

... und was nicht

Sehen Sie sich andere Bücher an. Es gibt noch viel zu lernen, und ein Buch allein kann Ihnen nicht alles beibringen.

Abonnieren Sie eine gute C++-Zeitschrift, und werden Sie Mitglied in einer C++-Benutzergruppe.

Lesen Sie nicht einfach den Code. C++ erlernen Sie am besten, wenn Sie eigene Programme schreiben.

Zusammenfassung

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.

Fragen und Antworten

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?

  1. A: Am besten eignen sich 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

Frage:
War das alles?

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.

Workshop

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.

Quiz

  1. Was versteht man unter dem Schutz vor Mehrfachdeklarationen?
  2. Wie weisen Sie den Compiler an, den Inhalt der Zwischendatei auszugeben, um die Arbeit des Präprozessors zu kontrollieren?
  3. Worin liegt der Unterschied zwischen #define debug 0 und #undef debug?
  4. Was bewirkt der Komplementoperator?
  5. Wie unterscheiden sich die Verknüpfungen OR und XOR?
  6. Worin unterscheiden sich die Operatoren & und &&?
  7. Worin unterscheiden sich die Operatoren| und ||?

Übungen

  1. Schreiben Sie Anweisungen, um einen Schutz vor Mehrfachdeklarationen für die Header-Datei STRING.H zu realisieren.
  2. Schreiben Sie ein 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.
  3. Schreiben Sie ein Makro DPrint, das auf die Definition von DEBUG testet. Wenn DEBUG definiert ist, soll das Makro den als Parameter übergebenen Wert anzeigen.
  4. Schreiben Sie ein Programm, das zwei Zahlen addiert, ohne den Additionsoperator (+) zu verwenden. Hinweis: Arbeiten Sie mit Bitoperatoren.



vorheriges KapitelInhaltsverzeichnisStichwortverzeichnisFeedbacknächstes Kapitel


© Markt&Technik Verlag, ein Imprint der Pearson Education Deutschland GmbH