C++ ist keine Sprache für Programmieranfänger. Höchstwahrscheinlich besitzen Sie schon
Grundkenntnisse in einer höheren Programmiersprache. Dann möchten Sie nicht mit ausführlichen Erklärungen der elementaren Konzepte höherer Programmiersprachen gelangweilt werden, sondern erfahren, worin sich C++ von der Ihnen bekannten Sprache unterscheidet.
Dieses Buch wendet sich vor allem an Leser, die bereits in Pascal programmiert haben. Die
Sprachkonstruktionen, die sich in C++ kaum von Pascal unterscheiden, werden nur ganz knapp gegenübergestellt. So bleibt genügend Raum, die wirklich entscheidenden
Neuerungen in C++ darzustellen und vor besonderen Fallstricken für Pascal-Programmierer zu warnen. Natürlich genügt es nicht, Pascal-Formulierungen in C++ umsetzen zu können.
Sie werden vor allem auch in die Denkweise der objektorientierten Programmierung eingeführt.
Das Buch besteht aus drei großen Teilbereichen: Die Kapitel 1 und 2 dienen dem Umstieg von der Pascal-Programmierung zur konventionellen Programmierung in C++. Hier genügen knappe Programmbeispiele, die die vorgestellten Sprachmittel auf möglichst engem Raum klären.
Das dritte Kapitel führt Sie in die objektorientierte Programmierung in C++ ein. Danach sollten Sie diese neue Denkweise und die wichtigsten Sprachmittel von C++ im Prinzip beherrschen. Einige weniger wichtige Konzepte wurden bewußt vernachlässigt. Hier wurde einer soliden Beherrschung der wichtigen Konzepte der Vorzug vor Vollständigkeit gegeben. Zweifelsfälle werden vor allem dann behandelt, wenn sie zu schwer auffindbaren
Programmfehlern führen können, nicht aber, wenn sie eindeutig durch den Compiler erkannt und erklärt werden.
Nun verhält es sich aber mit Programmiersprachen - und ganz besonders mit so komplexen
Sprachen wie C++ - ähnlich wie mit dem Schachspiel: Die Kenntnis der Regeln macht noch keinen Schachspieler. Entscheidend ist die Fähigkeit, die erlernten Regeln in praktisches
Handeln umzusetzen. Diese Fähigkeit kann man sich nicht allein anlesen. Sie muß durch eigenes Tun erworben werden. Der Weg läßt sich aber deutlich verkürzen, wenn man an den
Erfahrungen anderer teilhaben kann.
Das vierte Kapitel bietet Ihnen deshalb die Möglichkeit, Ihre neu erworbenen Kenntnisse im praktischen Einsatz zu vertiefen. Der Nutzen objektorientierter Methoden erschließt sich vor allem bei größeren Programmierprojekten. Deshalb stelle ich Ihnen in diesem letzten Kapitel des Buchs Projekte vor, in denen sinnvoll einsetzbare Programme entwickelt werden. Es ist eine Erfahrungsregel, daß erst ein Bruchteil der Arbeit getan ist, wenn ein
Programm "im Prinzip fertig" ist. Den Hauptteil der Arbeit machen dann solche
"Kleinigkeiten" aus, wie eine komfortable Benutzeroberfläche, Robustheit gegen
Fehlbedienung oder kleine zusätzliche Verbesserungen der Funktion (der Appetit kommt mit dem Essen). Sie finden zu jedem Projekt eine leicht verständliche Prototyp-Version mit ausführlichen Erklärungen. Umfangreichere komfortable Versionen mit weiterer
Dokumentation finden Sie auf der dem Buch beiliegenden Diskette. Unter diesen
Programmen befinden sich interessante Spiel- und Simulationsprogramme, aber auch praktisch einsetzbare Programme, wie ein komfortables Zeichenprogramm für
Graphenstrukturen. Dazu erhalten Sie ein komplettes Entwicklungspaket für Programme unter einer komfortablen grafischen Benutzeroberfläche für alle gängigen Grafikkarten.
Was Sie in der Hand halten, müßte man daher eigentlich als Diskette mit beiliegendem
Buch bezeichnen.
Alle Programmbeispiele in diesem Buch und auf der beiliegenden Diskette sind unter
Borland C++ lauffähig. Solange keine Programme für Microsoft Windows entwickelt werden, gibt es keine wesentlichen Unterschiede zwischen Borland C++ und Turbo C++.
Alles, was in diesem Buch über Borland C++ gesagt wird, gilt auch für Turbo C++. Für
Anwender von Zortech C++ enthält die Diskette Hinweise zur Portierung. Viele Programme, so auch die grafische Benutzeroberfläche, verwenden keine Borland-spezifischen
Funktionen und sind unverändert auch unter Zortech C++ einsetzbar.
Man hört oft die Meinung, C sei eine schwierige Programmiersprache. Da C++ eine
Erweiterung von C ist (jedes C-Programm ist auch ein C++-Programm!), gilt dies umso mehr auch für C++. Wenn Sie von Pascal nach C++ umsteigen wollen, haben Sie vor allem wohl folgende Hürden zu überwinden:
•
•
•
Zunächst gibt es einige sehr harmlose Unterschiede, nämlich Sprachmittel, die in
C++ im Prinzip wie in Pascal, aber mit anderen Symbolen ausgedrückt werden. Zum
Beispiel wird der logische Operator or
aus Pascal in C++ durch
||
dargestellt. Hier könnte man also bei der gewohnten Schreibweise bleiben und vor dem Übersetzen eines Programmes mit einem Texteditor überall or
durch
||
ersetzen. Diese
Umsetzung kann der Compiler sogar selbst mit Hilfe des sogenannten Präprozessors
übernehmen. Wenn Ihnen das hilft, können Sie für eine Übergangszeit bei einigen besonders ungewöhnlichen Operatoren die vertraute Pascal-Schreibweise beibehalten.
Die Art der Bezeichnung von Zeigertypen, insbesondere in höheren Stufen, ist nicht ganz einfach zu durchschauen. Hier lassen sich Anfangshürden vermeiden, indem jeder verwendete Zeigertyp zunächst in einer Typdeklaration mit einem Namen versehen wird.
C++ läßt Ihnen als Programmierer viel mehr Freiheiten als etwa Pascal oder gar
Modula-2. Insbesondere erlaubt C++ (verlangt es aber nicht!) einen extrem kompakten Programmierstil, der für Ungeübte nur sehr schwer zu durchschauen ist.
Die verschiedenen Möglichkeiten zur Darstellung der gleichen Situation führen dazu, daß jeder Programmierer seinen individuellen Stil entwickelt, der oft von anderen schwer nachvollzogen werden kann. In dieser Einführung verzichte ich bewußt auf einige Möglichkeiten der Sprache und empfehle Ihnen einen einfachen
Programmierstil, der es Ihnen ermöglicht, wenigstens Ihre eigenen Programme später wieder zu durchschauen.
Was die Schwierigkeit einer Programmiersprache betrifft, so sollten Sie unterscheiden zwischen der Schwierigkeit, die Sprache zu erlernen, und der Schwierigkeit, Probleme mit der Sprache zu lösen. Ein Bagger ist zwar schwieriger zu bedienen als ein Suppenlöffel.
Beim Ausheben einer Baugrube werden Sie sich aber mit dem Bagger leichter tun. In diesem Sinne ist C++ ein leistungsfähiger Bagger, über dessen Design und Ergonomie man sicher geteilter Meinung sein kann.
Dieses einführende Kapitel soll Ihnen den Umstieg von Pascal nach C++ möglichst leicht machen. Sie werden deshalb hauptsächlich die Sprachmittel kennenlernen, die es in gleicher oder ähnlicher Form auch in Pascal (oder im erweiterten Sprachvorrat von Turbo
Pascal) gibt. Wenn Sie sich am Ende dieses Kapitels sich noch nicht für C++ erwärmen können, liegt das einfach daran, daß Sie die Sprachmittel, die den eigentlichen Wert von
C++ ausmachen, erst später kennenlernen werden. Sie werden in diesem Kapitel vergeblich nach umfangreicheren, realistischen Programmbeispielen suchen. Dafür gibt es zwei
Gründe. Zum einen sind Sie ja bereits mit Pascal (oder vielleicht Modula-2) vertraut, so daß knappe Gegenüberstellungen ausreichen dürften, um Ihnen die Unterschiede in der Syntax zu verdeutlichen. Zum anderen möchte ich die wirklich interessanten Beispielprogramme solange zurückstellen, bis wir sie mit objektorientierten Methoden angehen können.
Nach diesen Vorbemerkungen wollen wir an einem ersten Beispiel einige wichtige
Unterschiede zwischen Pascal und C++ betrachten. Das folgende Programm liest einen
String ein, wandelt alle Kleinbuchstaben in Großbuchstaben um und prüft, ob die vorkommenden runden Klammern paarweise passend angeordnet sind. So erzeugt etwa die
Eingabe
"a*(b+c)"
die Ausgabe
"richtig: A*(B+C)"
. Das ganze soll kein sinnvolles
Programm sein, sondern nur auf engem Raum möglichst viele typische elementare
Sprachelemente vorführen.
Bevor Sie beim Vergleich der beiden Programmversionen C++ in Grund und Boden verdammen, bedenken Sie bitte, daß es sich hier um einen ungleichen Wettbewerb handelt:
Sie sehen auf der rechten Seite gewissermaßen ein C++ mit angezogener Handbremse. Hier kommt keine der Stärken von C++ zum Tragen. So ist es kein Wunder, wenn Pascal hier wegen seiner lesbareren Syntax noch das Rennen macht.
1 program Klammern; /* Klammern */
#include <string.h>
#include <ctype.h>
#include <iostream.h>
5
const n = 80; const int n = 80;
type Zeile = string[n];
10 var s: Zeile; typedef char Zeile[n+1];
Zeile s;
function korrekt (s: Zeile):
boolean;
(* prüft Zeichenkette auf
15 korrekte Klammerstruktur *)
var i, offen: integer;
begin
i := 1;
offen := 0;
20 while (i <= Length (s))
and (offen >= 0) do begin
if s[i] = '(' then
offen := offen + 1
else if s[i] = ')' then
25 offen := offen -1;
i := i + 1;
end;
korrekt := offen = 0;
end;
30
procedure bearbeite (var s:
Zeile);
(* verwandelt alle Buchstaben
in Großbuchstaben *)
35 var i: integer;
begin
for i := 1 to Length (s) do
case s[i] of
'ä': s[i] := 'Ä';
40 'ö': s[i] := 'Ö';
'ü': s[i] := 'Ü';
else s[i] := UpCase(s[i])
end;
end;
45 int korrekt (Zeile s)
/* prüft eine Zeichenkette
auf korrekte Klammerstruktur */
{
int i, offen;
i = 0;
offen = 0;
while (i < strlen (s)
&& offen >= 0) {
if (s[i] == '(')
offen = offen + 1;
else if (s[i] == ')')
offen = offen - 1;
i = i + 1;
}
return offen == 0;
} void bearbeite (Zeile s)
/* verwandelt alle Buchstaben
in Großbuchstaben */
{
int i;
for (i=0; i<strlen (s); i=i+1)
switch (s[i]) {
case 'ä': s[i] = 'Ä'; break;
case 'ö': s[i] = 'Ö'; break;
case 'ü': s[i] = 'Ü'; break;
default: s[i]=toupper(s[i]);
}
}
begin
repeat
writeln;
write('Ausdruck: ');
50 readln (s);
bearbeite (s);
if korrekt (s) then
write ('richtig: ')
else write ('falsch : ');
55 writeln (s);
until s = 'ENDE'
end.
main () {
do {
cout << "\nAusdruck: ";
cin >> s;
bearbeite (s);
if (korrekt (s))
cout << "richtig: ";
else cout << "falsch : ";
cout << s << '\n';
}
while (strcmp (s, "ENDE") != 0);
}
Sie können und sollen hier noch nicht alle Details der rechten Spalte völlig verstehen. Es geht einfach darum, daß Sie eine gewisse Vorstellung von der syntaktischen Form eines
C++-Programms bekommen. Auf einige Besonderheiten möchte ich hier schon eingehen. Sie werden sie alle später noch einmal systematisch kennenlernen.
•
•
Zeile 1: Das Schlüsselwort program
entfällt in C++ ersatzlos. Stattdessen sehen Sie rechts einen Kommentar.
Zeilen 2-4: Diese Zeilen entsprechen den uses
-Anweisungen in Turbo Pascal (Die
Pascal-Version benötigt keine speziellen Units).
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
Zeile 6: In C++ gibt es nur typisierte Konstanten.
Zeile 8: In C++ gibt es keinen speziellen String-Typ. Die Deklaration entspricht array[0..n] of char
.
Zeile 10: Variablen werden in C++ ohne ein spezielles Schlüsselwort deklariert. Erst kommt der Typname und dann der (oder die) Bezeichner.
Zeile 12: Auch Funktionsdefinitionen kommen in C++ ohne ein eigenes Schlüsselwort aus. Der Typ des Funktionswerts steht am Anfang. Es gibt keinen eigenen Typ boolean
.
Zeilen 16-17: Die Schlüsselwörter begin
und end
werden in C++ durch geschweifte
Klammern ersetzt. Lokale Deklarationen stehen in C++ innerhalb der Blockklammern.
Zeilen 18-19: Für die Zuweisung verwendet C++ das einfache Gleichheitszeichen. In
Pascal werden Strings ab eins indiziert, in C++ dagegen ab Null.
Zeile 20-21: Bei der while
-Anweisung entfällt das Schlüsselwort do
. Dafür muß die
Bedingung in runde Klammern eingeschlossen werden. Die logische Und-Verknüpfung wird in C++ durch
&&
ausgedrückt. Die Klammerung der beiden Teilaussagen kann in
C++ entfallen, weil es dort differenziertere Vorrangregeln gibt.
Zeile 22: Die if
-Anweisung verhält sich wie die while
-Anweisung. Hier entfällt das
Schlüsselwort then
. Die Bedingung muß ebenfalls in runde Klammern eingeschlossen werden. Für die Gleichheitsrelation wird in C++ ein doppeltes Gleichheitszeichen verwendet.
Zeilen 23-24: In Pascal darf vor else
kein Semikolon stehen, in C++ muß es stehen.
Das liegt daran, daß in C++ jede Anweisung mit einem Semikolon endet, in Pascal dagegen das Semikolon nur zwischen zwei Anweisungen steht (Vor dem else
endet zwar eine Anweisung; mit dem else
beginnt aber keine neue Anweisung, sondern nur der else
-Zweig der umfassenden Anweisung!).
Zeile 28: Der Funktionswert wird in C++ mit der return
-Anweisung (wie in Modula-
2) zurückgegeben.
Zeile 31: C++ macht keinen Unterschied zwischen Funktionen und Prozeduren. Eine
Prozedur ist formal eine Funktion mit leerem Wertebereich ( void
).
Zeile 37: Die for
-Anweisung hat in C++ eine wesentlich flexiblere, aber auch schwerer lesbare Struktur der Form: for (
Anfangsaktion Fortsetzungsbedingung
;
Inkrementierungsaktion
)
Anweisung
;
Zeilen 38-43: Auch die case
-Anweisung sieht in C++ etwas anders aus. Statt case steht in C++ switch
, das Wort case
wird in C++ den einzelnen Selektoren vorangestellt.
Zeile 46: In C++ ist das Hauptprogramm formal eine Funktion mit dem speziellen
Namen main
.
Zeile 47: Aus der repeat
-Schleife wird in C++ eine do
-Schleife mit zwei wichtigen
Unterschieden: Erstens darf nach dem do
nur eine Anweisung stehen. Zweitens prüft C++ nicht wie Pascal eine Abbruchbedingung, sondern das logische Gegenteil, nämlich eine Fortsetzungsbedingung.
Zeile 48-49: cout
steht für die Standardausgabe. Was rechts vom Operator
<< steht, wird dorthin ausgegeben. Strings werden in C++ in echte Anführungszeichen eingeschlossen. Ein Zeilenwechsel kann innerhalb eines Strings durch
"\n" dargestellt werden. Es können beliebig viele Objekte mit einer Anweisung
ausgegeben werden. Zum Beispiel kann die Anweisung write ('Pi = ', 3.12459); durch cout << "Pi = " << 3.14159;
•
• ersetzt werden.
Zeile 50: cin
steht für die Standardeingabe. Die Eingabe wird der Variablen rechts vom Operator
>>
übergeben. Die Wirkung ist nicht exakt die gleiche, wie in der
Pascal-Version.
Zeile 57: Im Umgang mit Strings unterscheiden sich Pascal und C++ wesentlich. Zur
Prüfung auf Gleichheit benötigen Sie die Bibliotheksfunktion strcmp
. Das Ergebnis ist Null genau dann, wenn beide Strings gleich sind. Die elegantere Pascal-
Schreibweise werden wir später mit Hilfe des Klassenkonzepts nachbilden.
Namen (
Bezeichner) für benutzerdefinierte Objekte (Variablen, Konstanten, Typen,
Funktionen), werden fast wie in Turbo Pascal gebildet: Dort muß ein Name mit einem
Buchstaben (keine Umlaute etc.) beginnen; danach dürfen Buchstaben, Ziffern und
Unterstriche (
_
) beliebig folgen.
In C++ darf ein Name auch mit einem Unterstrich beginnen. Allerdings sind solche Namen für die Sprachimplementierung reserviert und sollen nicht vom Programmierer definiert werden. Die Anzahl der signifikanten Zeichen ist compilerabhängig. In C++ wird, im
Gegensatz zu Pascal, zwischen Groß- und Kleinbuchstaben unterschieden. Die
Schlüsselwörter von C++ sind reserviert und dürfen nicht als Namen definiert werden:
Schlüsselwörter von C++
asm auto
bool break case class const_cast catch continue char default dynamic_cast else false if namespace float inline new delete enum for int operator const do explicit friend long private double extern goto mutable protected
public signed switch register sizeof template reinterpret_cast static this return static_cast throw short struct true try unsigned typedef using typeid virtual typename void union volatile wchar_t while
Die beiden kursiv gekennzeichneten Schlüsselwörter werden in diesem Buch nicht behandelt. auto
ist ein Relikt aus früheren Versionen, asm
weist darauf hin, daß die nächste Anweisung oder der nächste Block in Assembler formuliert ist und ist daher maschinenabhängig.
Sie sollten Ihren Objekten möglichst prägnante Namen geben. Es ist manchmal nicht ganz einfach, einen treffenden und dennoch nicht allzu langen Namen zu finden. Manchmal erfordert es ein paar Minuten Nachdenken, einen möglichst kurzen, aussagekräftigen
Namen zu finden. Solche Sorgfalt zahlt sich beim späteren Bearbeiten des Programms mit
Sicherheit aus. In manchen Fällen ist es übrigens durchaus angebracht, einzelne
Buchstaben als Variablennamen zu verwenden, so etwa für lokale Laufvariablen oder für die Argumente abstrakter mathematischer Funktionen.
Es kann leicht zu Mißverständnissen führen, wenn Abkürzungen zusammengesetzter
Wörter verschmolzen werden. Da die Syntax von C++, wie fast aller Programmiersprachen, keine Leerzeichen in einem Namen erlaubt, muß man sich hier anders behelfen. Bei Pascal- und Modula-2-Programmierern ist es üblich, die einzelnen Wörter lückenlos hintereinanderzuschreiben, aber die neuen Wörter mit einem Großbuchstaben zu beginnen
(Beispiel aus Turbo Pascal:
MemAvail ist wesentlich besser lesbar als memavail). Diese
Konvention empfiehlt sich auch in C++. Allerdings müssen Sie wegen der Unterscheidung von Groß- und Kleinbuchstaben auf stets gleiche Schreibweise achten. Alternativ dazu können Teilwörter auch durch einen Unterstrich getrennt werden.
Für nicht englischsprachige Programmierer erhebt sich auch die Frage, ob man der
Einheitlichkeit wegen selbst englische Namen oder im Interesse der Lesbarkeit muttersprachliche Namen verwenden sollte. In diesem Buch verwende ich mit Rücksicht auf viele Leser weitgehend deutsche Namen, mache aber Ausnahmen in solchen Fällen, wo bereits englische Namen weitverbreitet sind.
Kommentare können zum Verständnis eines Programms beitragen, wenn sie sinnvoll verwendet werden. Überflüssige Kommentare können aber sogar den Überblick über ein
Programm erschweren. Im Zweifelsfall sollten Sie zunächst versuchen, den Programmcode so klar zu gestalten, daß Kommentare überflüssig werden. Insbesondere ist es nicht der
Sinn eines Kommentars, die Bedeutung eines Namens zu erklären. Das sollte der Name selbst tun. Kommentare sollten nicht wiederholen, was aus dem Programmcode unmittelbar hervorgeht. Kommentare sind insbesondere dann sinnvoll, wenn die Implementierung eines
Algorithmus inhaltlich nicht unmittelbar verständlich ist.
In C++ gibt es zwei verschiedene Möglichkeiten für Kommentare. Mit
/*
wird der Beginn, mit
*/
das Ende eines Kommentars angezeigt. Hiermit können größere Kommentarblöcke eingeschlossen werden. Oft stehen kurze Kommentare am Ende einer Zeile. Hierfür gibt es in C++ eine bequeme Alternative: Durch
//
wird der Rest der Zeile zum Kommentar erklärt.
Kommentare dürfen nicht ineinander verschachtelt werden. Es ist aber erlaubt, zwischen
/*
und
*/
Zeilenendkommentare einzuschließen.
Die Speicherdarstellung der Standard-Datentypen ist compilerabhängig. In der folgenden
Übersicht ist jeweils die Größe in Bits für Windows 3.1 / Windows 95 aufgeführt:
Standard-Datentypen Turbo Pascal C++ Bits
Gleitkommatypen real
-
48 long double
80
Ganzzahltypen
Extended
Double
Single
-
LongInt
Word integer
Word integer
Byte
ShortInt double float unsigned long long unsigned int unsigned short short unsigned char char
64
32
32
32
16/32
16/32
16
16
8
8
Den Datentyp boolean
gibt es in C++ nicht. Stattdessen gilt die Konvention, daß die Zahl
Null für den Wahrheitswert false
steht, alle übrigen Zahlen für den Wahrheitswert true
.
In den MS-DOS-Implementierungen stimmt der Datentyp short
mit int
überein und ist daher überflüssig.
Die folgende Gegenüberstellung gibt Ihnen einen Überblick über die Darstellung von
Konstanten in C++:
Turbo Pascal C++
Dezimal
Oktal
Sedezimal (Hexadezimal
Gleitkommazahl
)
122
$7A
---
3.14
122
0x7A
0172
3.14
1.4E-10
1.4E-10
Zeichen
'z'
'z'
Strings (Zeichenketten)
'Beispiel'
"Beispiel"
Wie Sie sehen, unterscheidet sich die Darstellung nur bei Sedezimalzahlen und bei
Strings.
Sedezimale Konstanten werden mit den 16 Ziffern
0123456789ABCDEF
gebildet und mit
0x
eingeleitet. Dabei können die Buchstaben
A
..
F
und x
(und ebenso das
E
für den
Dezimal-Exponenten in Gleitkommakonstanten) wahlweise groß oder klein geschrieben werden.
Oktale Konstanten beginnen mit einer Null und dürfen nur die Oktalziffern
0
bis
7 enthalten. Auf Byte-orientierten Rechnern, wie dem IBM-PC, sind Sedezimal-Konstanten meist sinnvoller. Sie sollten daran denken, daß Sie niemals Dezimalzahlen mit führenden
Nullen auffüllen, weil sie sonst als Oktalzahlen interpretiert werden!
Zahlkonstanten gehören dem kleinstmöglichen Datentyp an. So ist etwa
17
vom Typ int
, aber
100000
vom Typ long
. Durch den nachgestellten Buchstaben l
oder
L
( l
kann leicht mit
1
verwechselt werden!) können auch kleine Zahlen als long
definiert werden. Ebenso kann durch den nachgestellten Buchstaben u
oder
U
der Typ unsigned
erzwungen werden.
Auch die Kombination
UL
ist möglich:
17 int
17L long
40000 long (zu groß für int)
40000U unsigned int
100000U unsigned long (zu groß für unsigned int)
Nicht druckbare Zeichen-Konstanten können auch mit Hilfe ihres ASCII-Codes dargestellt werden, und zwar wahlweise durch die Zeichenfolge
"\x"
, gefolgt von bis zu drei
Sedezimalziffern oder durch das Zeichen
'\'
, gefolgt von bis zu drei Oktalziffern. So können Sie zum Beispiel das Escape-Zeichen (ASCII 27) wahlweise durch
'\x1B'
,
'\x01B'
,
'\33'
oder
'\033'
darstellen.
Für einige oft benötigte
Steuerzeichen gibt es spezielle Standarddarstellungen:
ASCII Kurzzeichen Bezeichnung Darstellung
7
BEL
'\a'
8
BS
Alarm (Warnton)
Rückschritt (Backspace))
'\b'
9
HT
'\t'
10
11
12
LF
VT
FF
Horizontal-Tabulator
Zeilenvorschub (Line Feed)
Vertikal-Tabulator
Seitenvorschub (Form Feed)
'\n'
'\v'
'\f'
13
34
92
CR
"
\
Wagenrücklauf (Carriage Return)
Anführungszeichen
Backslash
'\r'
'\"'
'\\'
96
'
Apostroph ("Hochkomma")
'\''
In String-Konstanten können nicht druckbare Zeichen ebenso wie in Zeichenkonstanten verwendet werden. Der String
"Erste Zeile\nZweite Zeile" enthält ein Zeilenvorschub-Zeichen.
Wenn Sie innerhalb einer Stringkonstante nichtdruckbare Zeichen in der allgemeinen Form verwenden, ist Vorsicht angebracht. Wollen Sie etwa auf einem Standard-Nadeldrucker
die Kursivschrift einschalten, so müssen Sie die Sequenz
[Esc]-'4'
senden. Sie können also erst das Zeichen
'\33'
und dann das Zeichen
'4'
senden. Wenn Sie beide Zeichen in einem String zusammenfassen, ergibt sich
"\334"
. Dies wird aber als String mit nur einem
Zeichen (
'\334'
) mit dem Oktalcode
0334
oder dem Dezimalcode
220
aufgefaßt, denn der Oktalcode darf bis zu drei Stellen besitzen! Sie können dieses Problem vermeiden, indem Sie den Oktal- oder Sedezimalcode mit führenden Nullen auf drei Stellen auffüllen:
"\0334"
.
Wie in Pascal werden
Ausdrücke auch in C++ aus elementaren Einheiten durch beliebige
Anwendung von Operatoren gebildet. Die Unterschiede liegen in der Abgrenzung dieser
Operatoren. In Pascal kann mit dem Zuweisungsoperator kein Ausdruck gebildet werden, sondern die Zuweisung ist eine spezielle Anweisung. In C++ dagegen nimmt der
Zuweisungsoperator nicht diese Sonderstellung ein. Das bedeutet: Auch die Zuweisung ist in C++ ein Ausdruck und hat demnach einen Wert. Der Wert einer Zuweisung ist der Wert des zugewiesenen Ausdrucks.
Zunächst gibt es alle von Pascal her bekannten Operatoren auch in C++, allerdings teilweise in recht exzentrischer Schreibweise. Wenn Ihnen die Symbole für einige Operatoren in
C++ überhaupt nicht gefallen, können Sie für eine Eingewöhnungsphase bei den Pascal-
Bezeichnungen bleiben, wenn Sie die folgenden Zeilen (mit Präprozessor-Definitionen) am
Anfang Ihrer Programme einfügen:
#define MOD %
#define DIV /
#define NOT !
#define AND &&
#define OR ||
#define XOR ^^
Arithmetische Operatoren
Unär binär unäres Plus unäres Minus
Addition
Subtraktion
Multiplikation
Ganzzahl-Division
-
* div
Pascal
+
-
+
-
*
/
C++
+
-
+
Gleitkomma-Division
Modulo-Operator
/ mod
/
%
Logische Operatoren
unär Negation
Pascal
not
C++
!
binär Und-Verknüpfung
Oder-Verknüpfung and or
&&
|| exklusives Oder xor
^^
Wie schon erwähnt, gibt es in C++ keinen speziellen Typ ( boolean
) für Wahrheitswerte.
Die logischen Operatoren erwarten Argumente vom Typ int
. Hat ein Argument den Wert
Null, wird es als falsch interpretiert sonst als wahr. Die logischen Operatoren geben für den Wahrheitswert true
immer die Zahl
1
zurück.
In Turbo Pascal kann der Compiler so eingestellt werden, daß er logische Ausdrücke nur soweit wie nötig auswertet. Hat der Ausdruck links von einem Und-Operator den Wert false
, so steht bereits fest, daß der Operator den Wert false
zurückliefert. Deshalb braucht die rechte Seite nicht mehr untersucht zu werden. Das entsprechende gilt, wenn auf der linken Seite eines Oder-Operators ein wahrer Ausdruck steht. In C++ ist dieses optimierte Auswerten von logischen Ausdrücken in der Sprachdefinition vorgeschrieben.
Sie können sich also darauf verlassen, daß etwa die Anweisung if (q > 0 && p/q == 2) r++; keine Division durch Null ausführt.
Die merkwürdigen Doppelsymbole für die logischen Operatoren gehören mit Sicherheit nicht zu den ästhetischen Glanzlichtern von C++. Wie Sie sogleich sehen werden, gibt es auch die entsprechenden einfachen Symbole, allerdings in anderer Bedeutung.
Diese Operatoren sollten nur in begründeten Fällen verwendet werden. Sie erlauben den direkten Zugriff auf einzelne Bits. Die typischen Anwendungsfälle lassen sich mit Hilfe der Bitfelder meist wesentlich klarer und problemnäher beschreiben.
Operatoren zur Bitmanipulation
unär bitweise Negation binär bitweises Und bitweises Oder
Pascal
not and or
&
|
C++
~ bitweises exkl. Oder xor
^
<<
Linksverschiebung shl
Rechtsverschiebung shr
>>
Die bitweisen Verschiebungen werden oft zur schnelleren Multiplikation oder Division mit
Zweierpotenzen verwendet. Dies ist bei modernen optimierenden Compilern meist
überflüssig und sollte daher möglichst vermieden werden. Das Rechtsschieben von vorzeichenbehafteten Zahlen ist übrigens nicht portabel. Manche Compiler schieben Nullen nach, andere schieben das Vorzeichenbit nach (arithmetische Verschiebung), damit auch negative Zahlen durch Zweierpotenzen dividiert werden können.
In Pascal werden für die logischen Operatoren und für die Bitoperatoren die gleichen
Operanden verwendet. Dieses sogenannte
Überladen von Operatoren ist in Pascal möglich, da sich dort Zahlen von Wahrheitswerten im Typ unterscheiden.
Sie können Ihr Verständnis der Operatoren an den folgenden Beispielen überprüfen:
Pascal C
Ausdruck
(3>2) and (3<4)
Wert true
Ausdruck
3>2 && 3<4
3>2 & 3<4
Wert
1
1
5 and 6
4
5 & 6 4
5 && 6
3>2 && 4
1
(3>2) and 4
Syntaxfehler
1
3>2 & 4
0
In Pascal werden einige der Operatoren auch noch für Mengenoperationen und String-
Verkettung benutzt. Das Überladen von Operatoren steht in C++ generell dem
Programmierer als Sprachmittel zur Verfügung. Wir werden später sehen, daß sich so das
Mengenkonzept von Pascal identisch in C++ realisieren läßt, obwohl es nicht zur Sprache gehört.
Hier genügt eine tabellarische Gegenüberstellung
Vergleichsoperatoren Pascal
Gleichheit
Ungleichheit kleiner
=
<>
<
C++
==
!=
< kleiner oder gleich
<=
<=
> größer
> größer oder gleich
>=
>=
Nur die Gleichheits- und die Ungleichheitsrelation werden anders als in Pascal geschrieben.
Schreiben Sie in C++ versehentlich
<>
anstelle von
!=
, weist Sie der Compiler darauf hin.
Wenn Sie dagegen die Gleichheitsrelation (
==
) in der von Pascal gewohnten Weise (
=
) schreiben, kann das unangenehme Folgen haben, wie Sie sogleich sehen werden.
Die Zuweisung wird in C++ mit dem einfachen Gleichheitszeichen (ohne vorgestellten
Doppelpunkt!) dargestellt. Wie Sie bereits zu Beginn dieses Abschnitts erfahren haben, sind in C++ auch Zuweisungen Ausdrücke mit einem Wert, nämlich dem des zugewiesenen
Ausdrucks. Damit sind auch Zuweisungsketten möglich: a = b = 1;
ist gleichbedeutend mit a = (b = 1);
. Der Wert von
(b = 1)
ist
1
und wird a
zugewiesen.
Beim Umstieg von Pascal nach C++ tut sich hier eine der berüchtigtsten Fallgruben auf: a := 0; if a = 1 then write ('ja') else write ('nein') a = 0; if (a = 1) printf ("ja"); else printf ("nein");
Das Pascal-Programm sagt "nein", C++ entscheidet sich für "ja". Was ist der Grund? Der
Ausdruck nach dem if
ist im Pascal-Programm eine Gleichheitsrelation mit dem Ergebnis false
, in C++ eine Zuweisung mit dem Ergebnis
1
, was von Null verschieden und damit wahr ist! Würden Sie in Pascal den gleichen Fehler machen, nämlich versehentlich a := 1 anstelle a = 1
schreiben, würde der Compiler das nicht durchgehen lassen, weil nach dem if
nur ein Ausdruck, aber keine Anweisung stehen darf.
Auf der linken Seite einer Zuweisung muß, ebenso wie in Pascal, ein Objekt stehen, dem eine bestimmte Speicheradresse zugeordnet ist. In der C-Terminologie spricht man von einem
L-Wert (L-value, L für linke Seite). Auf der rechten Seite darf ein beliebiger
Ausdruck stehen. Dieser Ausdruck muß vom gleichen Typ wie der L-Wert sein oder in den
L-Wert umgewandelt werden können. Die genauen Regeln für solche Typkonvertierungen erfahren Sie in einem späteren Abschnitt.
Neben der gewöhnlichen Zuweisung gibt es noch eine ganze Reihe von Kombinationen von
Zuweisungen mit arithmetischen Operationen. Anstelle von a = a + b; können Sie auch die Kurzschreibweise a += b; verwenden. Dies ist besonders bei komplizierten Objekten von Vorteil: langeVariable[2*i+j] = langeVariable[2*i+j] + 2; ist mühsamer zu schreiben und nicht so leicht lesbar wie langeVariable[2*i+j] += 2;
Zudem macht diese Schreibweise dem Compiler klar, daß er die Adresse der Variablen nicht zweimal berechnen muß.
In entsprechender Bedeutung gibt es die Operatoren
+= -= *= /= %= >>= <<= &= ^= |=
Besonders oft kommt eine Erhöhung oder Verminderung einer Variablen um eins vor. Dafür hat Turbo Pascal von Modula-2 die Prozeduren
INC
und
DEC
übernommen. C++ bietet hierfür die unären Operatoren
++
und
--
an. Statt a += 1
können Sie auch a++
oder
++a schreiben, a -= 1
können Sie durch a--
oder
--a
ersetzen. Zwischen der Präfix- und der
Postfix-Schreibweise gibt es einen feinen Unterschied, der nur dann von Bedeutung ist, wenn der Wert eines solchen Ausdrucks ausgewertet werden soll: Der Wert eines
Präfixausdrucks ist der neue Wert der Variablen, der des Postfix-Ausdrucks der alte
Wert. Jetzt können Sie sich auch die Bedeutung des Namens C++ erklären!
Das Programmstück a = 2; while (a--) printf ("A"); a = 2; while (--a) printf ("B"); schreibt 2 mal "
A
", aber nur einmal "
B
".
Mit diesem Operator, den es ähnlich auch in Turbo Pascal gibt, kann die Größe eines
Datentyps oder eines Ausdrucks in Bytes ermittelt werden. Er wird formal wie eine
Funktion verwendet. Beispiele: int i, j; i = sizeof (int); j = sizeof (i);
Der Bedingungsoperator hat eine etwas ungewöhnliche Syntax. Er ist der einzige dreistellige Operator in C++. Er erlaubt es, den Wert eines Ausdrucks von einer Bedingung abhängig zu machen, ohne eine if
-Anweisung zu verwenden. Die Syntax lautet:
logischerAusdruck
?
Ausdruck1
:
Ausdruck2
Der Wert eines solchen bedingten Ausdrucks ist der Wert von
Ausdruck1
bzw.
Ausdruck2
, je nachdem, ob der logische Ausdruck wahr oder falsch ist. Es folgen einige Beispiele: ohne Bedingungsoperator mit Bedingungsoperator if (a < b) A[i][j] = x; else A[i][j] = y;
A[i][j] = (a < b) ? x : y; if (x < y) return x; else return y; return (x < y) ? x : y;
Dieser Operator wird hier nur der Vollständigkeit halber erwähnt. Er ist ein hervorragendes Mittel, Programme unlesbar zu machen. Sie sollten ihn nur in Ihren passiven Sprachschatz aufnehmen.
Zwei Ausdrücke können durch ein Komma dazwischen zu einem Ausdruck verschmolzen werden:
Ausdruck1
,
Ausdruck2
Bei der Auswertung werden der Reihe nach beide Ausdrücke ausgewertet. Der Wert des
Gesamtausdrucks ist der Wert des zweiten Ausdrucks. Einige abschreckende Beispiele zeigen die Möglichkeiten, mit dem Komma-Operator Verwirrung zu schaffen: if (x > 0) i++, j++; k++ // k wird immer erhöht, i und j, falls x > 0 x = sin (3,14159) // Fehler: sin erwartet nur ein Argument x = sin ((3,14159)); // sin (14159) wird berechnet x = sin ((Pi = 3.14159, Pi/2)); // sin (Pi/2) wird berechnet
In Pascal müssen Sie ziemlich viele Klammern setzen, weil es nur vier Vorrangstufen gibt:
Am stärksten bindet der not
-Operator, danach kommen die multiplikativen Operatoren (
*
,
/ div
, mod
, and
), dann die additiven Operatoren (
+
,
-
, or
) und schließlich, mit schwächster
Bindung, die Vergleichsoperatoren (
=
,
<
,
>
,
<>
,
>=
,
<=
, in
).
C++ bietet eine 16-stufige Operatorenhierarchie. Deshalb ist die einfachste Faustregel:
Wenn Sie die Vorrangregeln nicht ganz sicher beherrschen, sollten Sie lieber einige
Klammern zuviel als zuwenig verwenden.
Eine vollständige Tabelle aller Operatoren, auch der Ihnen bisher unbekannten, finden Sie im Anhang. Hier sollen einige Hinweise genügen:
Sehr oft kommen logische Verknüpfungen von Vergleichsrelationen vor. In Pascal müssen die Vergleiche geklammert werden, in C++ ist das überflüssig: if (a > 0) and (b > 0) then ...
if (a > 0 && b > 0) ...
Einige Operatoren verhalten sich anders als Sie es vielleicht erwarten. So binden die
Schiebe-Operatoren schwächer als die Addition und Subtraktion. Die Ausdrücke a * 16 + 1
und a << 4 + 1
sind nicht äquivalent! Sie werden nämlich so interpretiert:
(a * 16) + 1
und a << (4 + 1)
.
Unäre (einstellige) Operatoren und Zuweisungsoperatoren sind rechtsassoziativ. Das heißt:
Wenn rechts und links von einem Ausdruck ein solcher Operator steht, wird zunächst der rechte Operator angewendet. So steht etwa
*p++
für
*(p++)
oder
*a[i]
für
*(a[i]) oder x = y = z
für x = (y = z)
.
Alle übrigen binären Operatoren sind linksassoziativ. x y z
bedeutet also
(x y) z
.
Im letzten Abschnitt haben Sie bereits erfahren, daß Zuweisungen in C++ keine
Anweisungen, sondern Ausdrücke sind. Jeder Ausdruck wird aber zu einer Anweisung, wenn man ein Semikolon anhängt. Es folgen ein paar Beispiele:
Ausdrücke
a = b + 1 a++ a + 3
Anweisungen
a = b + 1; a++; a + 3;
Nicht jede Anweisung ist wirklich sinnvoll. So berechnet die dritte Beipiel-Anweisung die
Summe a + 3
, ohne daß dies irgendwelche Konsequenzen hätte. Der Wert von a
bleibt unverändert.
Beachten Sie auch, daß in C++ das abschließende Semikolon Bestandteil der Anweisung ist, während in Pascal das Semikolon nicht zu einer Anweisung gehört, sondern als Trenner in
Verbundanweisungen dient. Dieser feine Unterschied ist wichtig!
Wir gehen nun im einzelnen auf die verschiedenen Anweisungsformen ein. Dabei läßt es sich nicht ganz vermeiden, daß wir in einigen Beispielen schon auf später erklärte Sprachmittel zurückgreifen.
Die leere Anweisung besteht lediglich aus einem Semikolon. Sie wird zum Beispiel benötigt, wenn mit einer while
-Anweisung untätig auf das Eintreffen eines Ereignisses, wie etwa eines Tastendrucks gewartet wird: while (keineTasteGedrueckt);
Diese Anweisung entlockt dem Zortech-Compiler eine Warnung. Sie können diese Warnung vermeiden, indem Sie das Schlüsselwort void
für die leere Anweisung einsetzen: while (keineTasteGedrueckt) void; // In Borland C++ verboten!
Wie in Pascal verlangt die Syntax an manchen Stellen genau eine Anweisung. Deshalb muß es möglich sein, aus mehreren Anweisungen formal eine Anweisung zu machen. Hierzu werden in C++ anstelle der Pascal-Schlüsselwörter begin
und end
die geschweiften
Klammern verwendet. In C++ wird durch eine Verbundanweisung ein lokaler Block eingeführt. Variablen, die hier deklariert werden, sind außerhalb der Verbundanweisung nicht sichtbar, und ihr Speicherplatz wird nach Beendigung der Verbundanweisung wieder freigegeben.
if
Ausdruck
then
Anweisung
[
else
Anweisung ]
if (
Ausdruck
)
Anweisung
[
else
Anweisung ]
Die if
-Anweisung ist in C++ strukturgleich mit Pascal: Sie könnten die gewohnte Pascal-
Schreibweise beibehalten und die Übersetzung dem Präprozessor überlassen. Dazu müßten
Sie folgende Definitionen in Ihr Programm einfügen:
#define IF if (
#define THEN )
#define ELSE else
Genaueres über den Präprozessor erfahren Sie im Abschnitt 2.1. Ich empfehle Ihnen nicht, diese Präprozessor-Definitionen wirklich zu verwenden. Sie sollen Ihnen lediglich die
Umsetzung von Pascal nach C++ klarmachen. Wie Sie sehen, wird in C++ die Bedingung in runde Klammern gesetzt, dafür entfällt das Schlüsselwort then
.
Die folgenden beiden Zeilen
IF a < 0 THEN b := 0;
IF a < 0 THEN b := 0; ELSE b := 1; würden expandiert zu den korrekten C++-Anweisungen
if (a < 0) b = 0; if (a < 0) b = 0; else b := 1;
Beachten Sie das Semikolon vor dem else
! Da in C++ jede Anweisung mit einem Semikolon endet, muß auch hier eines stehen. In Pascal wäre dies ein Fehler. Gemeinsam ist beiden
Sprachen, daß nach dem then
und dem else
jeweils nur eine Anweisung stehen darf.
Mehrere Anweisungen müssen zu einer Verbundanweisung gemacht werden. if x < y then begin
z := x;
x := y;
y := z; end if (x < y) {
z = x;
x = y;
y = z;
}
In der Sprachdefinition von C++ wird (wie in Pascal) festgelegt, daß in syntaktisch mehrdeutigen Anweisungen wie der folgenden if (
Ausdruck
) if (
Ausdruck
)
Anweisung
else
Anweisung
jedes else
dem letztmöglichen if
zugeordnet wird.
Da es in C++ keinen Datentyp boolean
gibt, darf für den Bedingungsausdruck ein beliebiger arithmetischer Ausdruck oder ein Ausdruck eines Zeigertyps stehen. Die
Bedingung ist erfüllt, wenn der Ausdruck von Null verschieden ist.
while
Ausdruck
do
Anweisung
while (
Ausdruck
)
Anweisung
Auch die while
-Anweisung ist strukturgleich mit Pascal. Sie könnte mit folgenden
Präprozessor-Anweisungen übersetzt werden:
#define WHILE while (
#define DO )
Wie bei der if
-Anweisung wird in C++ auch hier die Bedingung in runde Klammern gesetzt.
Dafür entfällt das Schlüsselwort do
.
Die repeat
-Anweisung aus Pascal wird in C++ durch die do
-Anweisung ersetzt. repeat
Anweisungsfolge
until
Ausdruck
do
Anweisung
while (
Ausdruck
)
Beachten Sie bitte folgende Unterschiede:
•
• repeat
wird durch do
ersetzt, until
durch while
.
Statt einer Abbruchbedingung in Pascal wird in C++ eine Wiederholungsbedingung angegeben, also das logische Gegenteil. Die Bedingung wird, wie in der if
- und
•
• while
-Anweisung auch, in runde Klammern gesetzt.
Die Syntax der repeat
-Anweisung fällt in Pascal etwas aus dem Rahmen. Sie ist die einzige strukturierte Anweisung, in die nicht nur eine, sondern beliebig viele
Unteranweisungen eingesetzt werden dürfen, ohne sie zu einer Verbundanweisung zu klammern. Die Klammerung übernimmt hier das abschließende Schlüsselwort until
.
Obwohl die entsprechende Anweisung in C++ formal gleichartig aufgebaut ist, darf trotzdem nur genau eine Anweisung eingesetzt werden. repeat
i := i * i;
j := j + i until i > n do {
i = i * i;
j = j + i;} while (i <= n);
Das Schlüsselwort while
wird in C++ also für zwei verschiedene Zwecke eingesetzt: Für die Anfangsabfrage der while
-Anweisung und für die Endabfrage der do
-Anweisung.
Das Schlüsselwort do
steht in C++ einzig für die do
-Schleife. In Pascal wird es für ganz andere Zwecke verwendet (nach while
, for
und with
).
Die for
-Anweisung in Pascal ist nicht gerade üppig ausgestattet: for
Variable
:=
Ausdruck
to
Ausdruck
do
Anweisung
oder for
Variable
:=
Ausdruck
downto
Ausdruck
do
Anweisung
Zumindest eine von ±1 abweichende Schrittweite wäre wünschenswert. Was hier allerdings die entsprechende Konstruktion in C++ bietet, ist so mächtig, daß wir uns zunächst mit der direkten Umsetzung einer for
-Schleife aus Pascal begnügen wollen: for i := 1 to 10 do
s := s + i; for i := 10 downto 1 do
s := s + i; for (i = 1; i <= 10; i = i + 1)
s = s + i; for (i = 10; i >= 1; i = i - 1)
s = s + i;
Nach diesem Schema können alle for
-Anweisungen aus Pascal übertragen werden. Doch die merkwürdig anmutende Ausdrucksweise in C++ läßt erahnen, daß die Möglichkeiten damit bei weitem nicht ausgeschöpft sind. Die for
-Anweisung hat in C++ die allgemeine Form for (
Anweisung [ Ausdruck ]
;
[ Ausdruck ]
)
Anweisung
Da in C++ jede Anweisung mit einem Semikolon endet, stehen zwischen den runden
Klammern also zwei Semikola!
Eine Anweisung der Form for (
Anfangsanweisung Fortsetzungsbedingung
;
Inkrementierung
)
Schleifenanweisung
hat die gleiche Bedeutung wie das Programmstück
Anfangsanweisung
while (
Fortsetzungsbedingung
) {
Schleifenanweisung
Inkrementierung
}
Damit läßt sich unter anderem jede while
-Schleife darstellen, indem man die
Anfangsanweisung und die Inkrementierung wegläßt: while (
Bedingung
)
Anweisung
könnte (aber sollte keinesfalls) ersetzt werden durch for ( ;
Bedingung
; )
Anweisung
Eine fehlende Fortsetzungsbedingung wird als wahr interpretiert. Durch eine for
-
Anweisung der Form for ( ; ; )
Anweisung
läßt sich also eine Endlosschleife darstellen.
Mit der case
-Anweisung in Pascal kann die folgende Anweisung vom Wert einer ordinalen
Selektor-Variablen abhängig gemacht werden. Der gleiche Mechanismus steht auch in C++ zur Verfügung. case Monat of
1: Tage := 31;
2: Tage := 28;
3: Tage := 31;
4: Tage := 30; switch (Monat) {
case 1: Tage = 31; break;
case 2: Tage = 28; break;
case 3: Tage = 31; break;
case 4: Tage = 30; break;
5: Tage := 31;
6: Tage := 30;
7: Tage := 31;
8: Tage := 31;
9: Tage := 30;
10: Tage := 31;
11: Tage := 30;
12: Tage := 31; end;
case 5: Tage = 31; break;
case 6: Tage = 30; break;
case 7: Tage = 31; break;
case 8: Tage = 31; break;
case 9: Tage = 30; break;
case 10: Tage = 31; break;
case 11: Tage = 30; break;
case 12: Tage = 31; break;
}
Das Pascal-Schlüsselwort case
wird in C++ durch switch
ersetzt. Der Selektorausdruck wird in C++ in runde Klammern gesetzt, dafür entfällt das Wort of
. Vor jeden einzelnen
Fall muß das Wort case
gesetzt werden. Der wichtigste Unterschied liegt in der Art der
Abarbeitung. Pascal führt nur die Anweisung nach der zutreffenden Konstanten aus, C++ dagegen alle Anweisungen ab der zutreffenden Konstante. Wird dies nicht gewünscht (das ist der Normalfall!), muß die Abarbeitung der weiteren Anweisungen durch das
Schlüsselwort break
verhindert werden. Nur durch diese merkwürdige Konvention ist es in
C++ möglich, mehreren Konstanten die gleiche Anweisung zuzuordnen: case Monat of
1,3,5,7,8,10,12: Tage := 31;
2 : Tage := 28;
4,6,9,11 : Tage := 30; else Tage := 0 end; switch (Monat) {
case 1:
case 3:
case 5:
case 7:
case 8:
case 10:
case 12: Tage = 31; break;
case 2: Tage = 28; break;
case 4:
case 6:
case 9:
case 11: Tage = 30; break;
default: Tage = 0;
}
Hier bietet Pascal die elegantere Form. In diesem Beispiel sehen Sie auch, wie Werte behandelt werden können, zu denen es keine case
-Konstante gibt. Dafür dient das
Schlüsselwort default
(Das entsprechende else
im Pascal-Beispiel ist eine spezielle
Spracherweiterung von Turbo Pascal!).
Die case
-Anweisung in Pascal und die switch
-Anweisung in C++ haben folgende
Gemeinsamkeiten: Die Reihenfolge der Konstanten ist beliebig, es darf auch Lücken zwischen den Konstanten geben. Alle angegebenen Konstanten müssen verschieden sein.
Sie sollten sich klarmachen, daß das Resultat einer case/switch
-Anweisung extrem von der Implementierung des Compilers abhängt. Während etwa if
- oder while
-Anweisungen in stets gleichbleibender Weise in Maschinensprache umgesetzt werden, hängt die optimale Umsetzung einer switch
-Anweisung vom individuellen Fall ab. Gibt es nicht zu viele Lücken zwischen den einzelnen Konstanten, bietet sich eine Sprungtabelle an, die durch den Wert der Selektor-Variablen indiziert wird. Wenige Konstanten, die aber weit auseinanderliegen, sollten in eine Abfrage der Form if
... else if
... umgesetzt werden.
Bei einer größeren Anzahl von weit gestreuten Konstanten bieten sich dagegen Hash-
Verfahren oder binäre Suche an. Mit der optimalen Umsetzung in allen Fällen dürften die meisten Compiler wohl überfordert sein. Eine Anweisung wie etwa switch (Preis) {
case 99:
Anweisung1
; break; case 199:
Anweisung2
; break; case 299:
Anweisung3
; break;
...
case 9999:
Anweisung99
; break; default :
Anweisung0
; break;
} läuft unter Borland C++ um ein vielfaches langsamer als die äquivalente Form:
if (Preis % 100 == 99)
switch (Preis / 100) {
case 0:
Anweisung1
; break; case 1:
Anweisung2
; break; case 2:
Anweisung3
; break;
...
case 99:
Anweisung99
; break; default:
Anweisung0
; break;
} else
Anweisung0
;
Übungsaufgabe 1:
Machen Sie sich die Wirkung des folgenden Programmstücks klar und ersetzen Sie es durch eine einfache Anweisung: if (a > 0 && a <= 4) {
b = 1;
switch (a) {
case 4: b++;
case 3: b++;
case 2: b++;
}
} else b = a;
Bei der while
-Anweisung wird die Fortsetzungsbedingung am Anfang abgefragt, bei der do
-Schleife am Ende. Im Prinzip würde eine der beiden Konstuktionen ausreichen. So läßt sich etwa die allgemeine while
-Anweisung while (
Bedingung
) do
Anweisung
ersetzen durch if (
Bedingung
) do
Anweisung
while (
Bedingung
)
Trotzdem empfinden Sie es sicher als angenehm, daß Sie in beiden Fällen die maßgeschneiderte Schleifensteuerung verwenden können. Nun gibt es aber auch Schleifen, bei denen die Fortsetzungsbedingung logisch weder am Anfang noch am Ende sondern irgendwo in der Mitte des Anweisungsblocks steht. Im folgenden Beispiel sollen solange ganze Zahlen eingegeben und deren Kehrwerte aufsummiert werden, bis eine Null eingegeben wird. Mit unseren bisherigen Mitteln könnten wir das etwa so formulieren:
#include <iostream.h> main () {
int n;
float s = 0.0;
int Abbruch = 0;
while (! Abbruch) {
cin >> n;
if (n == 0) Abbruch = 1;
else s += 1.0 / n;
}
cout << "Summe = " << s;
}
Hier muß eine künstlich anmutende logische Variable
Abbruch
eingeführt und der letzte
Teil des Anweisungsblocks bedingt ausgeführt werden. Das Beispiel wird durchsichtiger, wenn auch hier eine maßgeschneiderte Konstruktion verwendet wird, nämlich eine
Endlosschleife mit der Abbruchbedingung in der Mitte:
#include <iostream.h> main () {
int n;
float s = 0.0;
for (;;) { // Endlosschleife
cin >> n;
if (n == 0) break; // bedingter Schleifenabbruch
s += 1.0 / n;
}
cout << "Summe = " << s;
}
Die break
-Anweisung beendet vorzeitig eine Schleife ( while
, do
oder for
) oder, wie Sie bereits wissen, eine switch
-Anweisung. Bei mehreren geschachtelten Schleifen wird jeweils nur die aktuelle Schleife verlassen.
Eine weitere, allerdings seltener benötigte Sprunganweisung ist die continue
-Anweisung.
Sie bewirkt einen Sprung hinter die letzte Anweisung der Schleife. Das bedeutet: Die
Schleife wird mit der nächsten Bedingungsabfrage fortgesetzt, bei der for
-Schleife wird vorher noch die Inkrementierungsanweisung ausgeführt.
Wie in Pascal gibt es auch in C++ aus historischen Gründen eine allgemeine
Sprunganweisung ( goto
), deren Gebrauch allerdings nur in seltenen Fällen zu rechtfertigen ist (z.B. Sprung aus verschachtelten Schleifen).
Übungsaufgabe 2:
Was gibt folgendes Programm aus:
#include <iostream.h> main () {
int n=0;
for (int i=0; i<30; i++) {
if (i > 20) break;
n += 100;
if (i < 10) continue;
n++;
}
cout << n;
}
In Pascal werden gewöhnliche Prozeduren mit dem Schlüsselwort procedure
eingeleitet,
Funktionsprozeduren mit dem Schlüsselwort function
. Im Prinzip wäre diese
Unterscheidung nicht nötig, denn Funktionsprozeduren kann man ja schon an der Angabe des Ergebnistyps erkennen. Deshalb verzichtet Modula-2 auf das Schlüsselwort
FUNCTION und verwendet das Schlüsselwort
PROCEDURE
für beide Arten von Prozeduren. In C++ dagegen spricht man nur von Funktionen. Eine Prozedur wird also in C++ als eine Funktion mit leerem Wertebereich (Pseudo-Datentyp: void
) angesehen. Die Syntax der
Prozedurdefinitionen ist so entworfen, daß kein spezielles Schlüsselwort benötigt wird.
Das folgende Beispiel zeigt in beiden Sprachen eine Funktion, die das Maximum dreier
Zahlen ermittelt: function max3 (a,b,c: real): real;
var max: real; float max3 (float a, float b,
float c) {
float max;
begin
if a > b then max := a
else max := b;
if c > max then max := c;
max3 := max;
end;
if (a > b) max = a;
else max = b;
if (c > max) max = c;
return max;
}
Beachten Sie bitte folgende Unterschiede in C++:
•
•
•
•
•
•
Im Funktionskopf steht der Typ des Funktionswertes am Anfang, dafür entfällt das
Schlüsselwort function
.
In der
Parameterliste muß zu jedem einzelnen Parameter der Datentyp angegeben werden, und zwar vor dem Namen.
Bei einer leeren Parameterliste dürfen die runden Klammern nicht weggelassen werden. Beispiel: int ohneParameter ()
. Wenn Sie die Klammern vergessen, ist das gefährlich, weil dann statt des Funktionswerts die Adresse der Funktion gemeint ist, was nicht immer einen Syntaxfehler nach sich zieht. Mehr darüber erfahren Sie in einem eigenen Abschnitt.
Lokale Variablen können an beliebiger Stelle innerhalb des Funktionsblocks deklariert werden.
Der
Funktionswert wird mit der return
-Anweisung zurückgegeben. Damit wird gleichzeitig die Ausführung der Funktion abgebrochen.
Prozeduren werden wie Funktionen mit dem Ergebnistyp void
behandelt.
In Pascal ist eine beliebige
Verschachtelung von Prozeduren und Funktionen erlaubt: eine
Prozedur kann lokal zu einer anderen Prozedur sein. Damit ist es möglich, daß für eine
Unterprozedur die lokalen Variablen der aufrufenden Prozedur global sind. Die wenigen sinnvollen Anwendungsfälle hierfür können meist mit Hilfe des Klassenkonzepts von C++ besser dargestellt werden. C++ verzichtet auf ein hierarchisches Prozedurkonzept.. Es wird nicht einmal ein formaler Unterschied zwischen dem Hauptprogramm und den
Prozeduren gemacht: Das Hauptprogramm ist in C++ einfach eine spezielle Funktion mit
dem reservierten Namen main
.
Die
Reihenfolge der Prozeduren ist in Pascal mit Rücksicht auf Einschritt-Compiler eingeschränkt: eine aufgerufene Funktion muß im Programmtext vor der aufrufenden
Funktion erscheinen. Wenn dies nicht möglich ist (wie etwa bei zwei sich wechselseitig aufrufenden Prozeduren), muß vor dem Aufruf eine forward
-Deklaration stehen. Dies ist in C++ nicht anders: Wenn eine Funktion aufgerufen wird, muß sie in der gleichen Datei vorher deklariert sein. Wenn Sie sich von den Anordnungszwängen frei machen wollen, können Sie einfach Deklarationen für alle verwendeten Funktionen (im C-Jargon:
Prototypen) an den Anfang des Programms setzen.
In Pascal dürfen nur einfache Typen als Funktionswert zurückgegeben werden. Dies ist ein ernsthafter Verstoß gegen das
Typvollständigkeitsprinzip. So ist es mit der naheliegenden
Definition type komplex = record x, y: real end; nicht möglich, Funktionen wie etwa function komplexeSumme (z1, z2: komplex): komplex; zu schreiben. Dieses Manko umgeht man in der Regel durch eine Prozedur mit einem zusätzlichen Referenzparameter für den Funktionswert.
In C++ gibt es diese Einschränkung nicht: Jeder Typ kann als Funktionswert verwendet werden.
Betrachten wir zunächst das einfache Problem, zwei Variablen mit Hilfe einer Prozedur zu vertauschen: Ein typischer Anfängerfehler wäre es, dies in Pascal so zu formulieren: procedure tausche (a, b: integer); (* ohne Wirkung *)
var h: integer;
begin
h := a;
a := b;
b := h;
end;
Warum ist diese Prozedur wirkungslos? Pascal kennt zwei Arten der Parameterübergabe an
Prozeduren: Im Normalfall wird der Wert des aktuellen Parameters an den formalen
Parameter übergeben ( Wertparameter). Die Prozedur kann nun den formalen Parameter verändern, ohne daß sich dies auf den aktuellen Parameter auswirkt. In unserem Beispiel sollen aber die übergebenen Variablen duch die Prozedur verändert werden. In diesem Fall muß man
Referenzparameter (Variablenparameter) verwenden. Dann arbeitet die Prozedur nicht mit einer Kopie der formalen Variablen sondern mit diesen selbst. Dies wird in Pascal durch Voranstellen des Schlüsselworts var
ausgedrückt:
procedure tausche (var a, b: integer); (* korrekte Version *)
var h: integer;
begin
h := a;
a := b;
b := h;
end;
Gäbe es keine Referenzparameter in Pascal, so könnte man das Problem auf folgende
Weise angehen: type IntPtr = ^integer; procedure tausche (a, b: IntPtr);
var h: integer;
begin
h := a^;
a^ := b^;
b^ := h;
end;
Hier wurden nicht die Werte der Variablen, sondern ihre Adressen übergeben. Da es sich um Wertparameter handelt, kann die Prozedur zwar nicht diese Adressen ändern. Die
Adressen sind aber der Schlüssel zum Zugriff auf die eigentlichen Zahlenwerte! In Turbo-
Pascal könnte man diese Prozedur etwa so aufrufen: program Test1; type
IntPtr = ^integer; procedure tausche (pa, pb: IntPtr);
var h: integer;
begin
h := pa^;
pa^ := pb^;
pb^ := h;
end; var a, b: integer; begin
a := 1;
b := 2;
WriteLn (a:4, b:4);
tausche (Addr(a), Addr(b));
WriteLn (a:4, b:4); end.
Wenn Sie diese beiden Varianten vergleichen, wären Sie sicher nicht begeistert, wenn Sie von nun an auf Referenzparameter verzichten müßten. In Standard-C gibt es aber tatsächlich nur Wertparameter. Deshalb sind C-Programmierer zu einer Zeigerakrobatik verurteilt, wie wir sie gerade gesehen haben. Zum Glück wurde dieses Manko beim
Übergang von C nach C++ beseitigt. Es folgen jetzt die Versionen unseres Beispiels in C und
C++:
Veränderung von Parametern in C: Wertparameter und Adreßübergabe Veränderung von Parametern in C++: Referenzparameter
void tausche (int* pa, int* pb) { int h; void tausche (int& a, int& b) { int h;
h = *pa;
*pa = *pb;
*pb = h;
} void main () { int a, b; a = 1; b = 2; printf ("%4%4\n", a, b); tausche (&a, &b); printf ("%4%4\n", a, b);
} h = a; a = b; b = h;
} void main () { int a, b; a = 1; b = 2; printf ("%4%4\n", a, b); tausche (a, b); printf ("%4%4\n", a, b);
}
Sie sehen also, daß sich die Prozedur in C++ ganz wie in Pascal programmieren läßt. Um einen formalen Parameter als Referenzparameter zu kennzeichnen, stellt man das Zeichen
&
hinter den Typnamen. Wird das Zeichen
&
nicht in einer Parameterliste (und nicht in einer Variablendeklaration) verwendet, so wirkt es wie in C als Adreß-Operator. Den werden Sie aber in C++ nur selten benötigen.
Referenzparameter sollten Sie immer dann verwenden, wenn eine Prozedur die ihr
übergebenen Parameter verändert. Aber auch bei unveränderlichen Parametern kann eine
Referenzübergabe in manchen Fällen gerechtfertigt sein, nämlich dann, wenn eine Prozedur mit so speicheraufwendigen Parametern arbeitet, daß das Kopieren eines Parameters gegenüber der Adreßübergabe wesentlich aufwendiger wäre. Sie sollten sich aber auch
über die Nachteile solcher technischen Referenzparameter klar sein. Damit verschenken
Sie die Möglichkeit, beliebige Ausdrücke zu übergeben und automatische Typkonvertierung zu erzwingen.
C++ geht sogar noch einen Schritt weiter als Pascal und erlaubt die Übergabe von
Referenzen nicht nur in Parameterlisten. Betrachten wir folgendes Programmstück: if (a[10*i+b[k]] == 3 || a[10*i+b[k]] == 5)
a[10*i+b[k]] = -a[10*i+b[k]];
Hier wird mehrmals das gleiche Objekt angesprochen. Fälle dieser Art bedeuten erstens hohen Schreibaufwand und zweitens muß (falls der Compiler hier nicht optimiert) mehrmals die gleiche Adresse berechnet werden. Überdies kann man nicht auf den ersten Blick erkennen, daß die Ausdrücke identisch sind. Eine Hilfsvariable kann dieses Problem teilweise, aber nicht ganz mildern: int t = a[10*i+b[k]]; if (t == 3 || t == 5)
a[10*i+b[k]] = -t;
Als Ziel der Zuweisung muß weiterhin das Originalobjekt angegeben werden. C++ erlaubt auch
Referenzvariablen, die ebenso wie Referenzparameter als Synonyme für eine andere
Variable stehen: int& t = a[10*i+b[k]]; // t ist Referenz auf a[10*i+b[k]]
if (t == 3 || t == 5)
t = -t;
Bevor eine Referenzvariable als Synonym für ein anderes Objekt verwendet werden kann, muß sie mit diesem Objekt verbunden werden. Dies geschieht bei der zwingend vorgeschriebenen Initialisierung.
Betrachten Sie bitte das Programmfragment: int i = a; i = b;
Hier wird zunächst die Variable i
definiert, der Anfangswert a
zugewiesen und anschließend mit dem Wert b
überschrieben. Sie sehen, daß die Wirkung einer
Initialisierung und einer separaten Zuweisung identisch ist (Bei der Behandlung von Klassen werden Sie sehen, daß es doch einen wichtigen Unterschied zwischen Initialisierung und
Zuweisung gibt). Bei Referenzvariablen sieht es völlig anders aus. Hier wird durch die
Initialisierung vereinbart, zu welchem Objekt die Variable synonym sein soll. Danach steht die Variable dann für ihr Synonym. Es folgt das entspechende Beispiel: int& i = a; // i ist Referenz auf a i = b; // gleichbedeutend mit a = b
Besonders klar wird sich der Nutzen von Referenzen beim Überladen von Operatoren erweisen. So werden wir etwa sehen, wie Sie die von Pascal her gewohnte Art von Vektoren
(Arrays) mit beliebigem Anfangsindex in C selbst implementieren können. Dazu müssen Sie die Bedeutung des Operators
[]
für den Vektortyp neu definieren ("überladen"), damit Sie in gewohnter Weise Ausdrücke der Form
A[i]
für Ihren eigenen Datentyp verwenden können. Würde der Ausdruck
A[i]
einen Wert zurückliefern, dann wären aber
Zuweisungen wie
A[i] = b;
ebenso sinnlos wie
3 = b;
, denn einem Wert kann man natürlich nichts zuweisen. Dieses Problem läßt sich lösen, wenn
A[i]
statt des Wertes des i-ten Elements von
A
die entsprechende Referenz zurückliefert.
Zusammenfassung
•
•
Der Operator
&
dient zur Deklaration von Referenzparametern und
Referenzvariablen. Außerhalb von Variablen-Definitionen dient er als Adreßoperator.
Bei der Definition einer Referenzvariablen wird durch die Initialisierung die
Referenz hergestellt.
Nicht selten benötigt man verschiedene Varianten einer Funktion, die sich nur durch die
übergebenen Typen unterscheiden. So könnte man etwa folgende Funktionen zur
Berechnung des Absolutbetrags einer Zahl erklären: int abs (int n) {
if (n < 0) return -n;
else return n;
} double fabs (double x) {
if (x < 0) return -x;
else return x;
}
In C++ dürfen Sie beiden Funktionen den gleichen Namen geben (den Funktionsnamen
überladen), um damit anzudeuten, daß es sich beide Male um den gleichen Vorgang handelt.
Der Compiler sucht dann jeweils an Hand der Anzahl und der Typen der aktuellen
Parameter die passende Version.
In der Standard-Funktionsbibliothek von C finden Sie übrigens die beiden Funktionen unter den Namen abs
und fabs
(und zusätzlich noch die Variante labs
für Argumente vom
Typ long
). Das liegt daran, daß in C das Überladen von Funktionen noch nicht erlaubt war.
Sie werden vielleicht einwenden, daß es überladene Funktionen auch in Pascal gibt, denn die
Pascal-Funktion abs
ist auf Argumente beliebiger Zahltypen anwendbar. Das ist zwar richtig, aber gerade hier zeigt sich ein Schwachpunkt von Pascal: Die Sprache benutzt
Mittel, deren Verwendung dem Anwendungsprogrammierer verwehrt ist: Gäbe es die
Funktion abs
nicht als Standardfunktion in Pascal, könnte man sie in Pascal nicht implementieren. Ebenso verhält es sich mit der Pascal-Prozedur write
. Sie ist auf alle
Standard-Datentypen anwendbar, nicht jedoch auf neu definierte Typen. So ergibt sich eine Zwei-Klassen-Sprache, in der den neu definierten Typen weniger Möglichkeiten offenstehen als den etablierten Typen.
Als weiteres Beispiel nehmen wir an, daß Sie öfter das Maximum von zwei oder drei
Ganzzahlen bestimmen müssen. Sie könnten dann schreiben: int max (int a, int b) {
if (a > b) return a;
else return b;
} int max (int a, int b, int c) {
int m;
if (a > b) m = a;
else m = b;
if (c > m) m = c;
return m;
}
Jetzt kann der Compiler die passende Funktion an Hand der Anzahl der aktuellen
Parameter auswählen.
Beachten Sie, daß alle Varianten von überladenen Funktionen sich eindeutig an Hand der
Anzahl oder Typen der Parameterliste unterscheiden müssen. Eine Unterscheidung allein im Typ des Funktionswerts ist nicht erlaubt.
Überladen von Funktionen ist immer dann sinnvoll, wenn mehrere Funktionen etwas bedeutungsgleiches mit verschiedenen Datentypen tun, etwa sie auf dem Bildschirm darstellen.
Beim Entwurf von Funktionen steht man oft vor der Wahl, ob die Funktion unkompliziert in der Anwendung oder universell verwendbar sein soll. Betrachten wir als Beispiel die
Funktion, die Borland C++ zum Zeichnen von Ellipsen bietet: void ellipse (int x, int y,
int stangle, int endangle,
int xradius, int yradius);
Die ersten beiden Parameter geben den Mittelpunkt der Ellipse an, die letzen beiden
Parameter die beiden Halbachsen. Die Parameter stangle
und endangle
erlauben, einen
Teil der Ellipse zu zeichnen: Sie geben den Anfangs- und Endwinkel (in Altgrad) an. Für den
Normalfall einer vollständige Ellipse müssen hier stets die Werte 0 und 360 eingesetzt werden. Die Funktion wäre leichter zu merken, wenn man diese beiden Werte weglassen könnte. Deshalb ist es in C++ erlaubt, bei der Deklaration einer Funktion Argumenten
Vorgabewerte zu geben, die automatisch verwendet werden, wenn die Argumente weggelassen werden. Damit der Compiler eindeutig erkennen kann, welche Parameter weggelassen wurden, gilt die Einschränkung, daß solche Vorgabe-Argumente am Ende der
Parameterliste stehen müssen. Die Vorgabeargumente dürfen nur bei der ersten
Deklaration angegeben werden!
Als Beispiel schreiben wir eine Funktion (mit dem doppeldeutigen Namen
Ellipse
), bei der die Winkelargumente weggelassen werden können: void Ellipse (int x, int y, int a, int b, int w0=0, int w1=360) {
ellipse (x, y, w0, w1, a, b);
}
Dann werden durch die beiden Aufrufe
Ellipse (40, 40, 30, 20);
Ellipse (80, 40, 30, 20, 0, 90); eine volle und eine viertel Ellipse gezeichnet.
Übungsaufgabe 3:
Schreiben Sie eine Funktion max
, die das Maximum von zwei, drei oder vier int
-Werten ermittelt!
Wenn Sie in Pascal sehr einfache und kurze Programmstücke als Prozedur oder Funktion implementieren, erkaufen Sie sich die bessere Lesbarkeit des Programms durch
Verminderung der Programmgeschwindigkeit. In einem zeitkritischen Programm könnten
Sie in Versuchung geraten, anstelle von einfachen Funktionsaufrufen wie
// Version A: Funktionsaufruf: int max (int i, int j) {
if (i > j) return i; else return j;
}
...
m = max (a, b); direkt den Inhalt der Funktionsdefinition zu schreiben:
// Version B: Direkte Implementierung: if (a > b) m = a; else m = b;
Um Ihnen einen Anhaltspunkt für solche Entscheidungen zu geben, habe ich die
Codestücke, die der Compiler von Borland C++ (für den 80286-Prozessor) erzeugt, miteinander verglichen.
Der Code für den Funktionsaufruf (Version A) belegt 10 Bytes. Dazu kommen einmalig noch
22 Bytes für den Code der Funktion max
. Die Version B benötigt 13 Bytes. Hier gibt es keinen eindeutigen Gewinner: Bei bis zu 7 Aufrufen von max
ist die Version B platzsparender, ab dem 8. Aufruf liegt die Version A vorn.
Ganz anders sieht es allerdings bei der Geschwindigkeit aus: Version A benötigt 32
Prozessortakte für den Funktionsaufruf und 57 oder 59 Takte (je nach Ergebnis des
Vergleichs) für die Abarbeitung der Funktion, insgesamt also rund 90 Takte. Die Version B begnügt sich mit nur 15 oder 18 Takten, ist also 5 bis 6 mal schneller.
Dieser gewaltige Unterschied erklärt sich dadurch, daß vor einem Funktionsaufruf die
Parameter auf den Stack gebracht werden müssen, die Kontrolle an die Funktion
übergeben und anschließend die Parameter wieder vom Stack genommen werden müssen. So kommt es, daß bei einer so einfachen Funktion wie der max
-Funktion dieser rein organisatorische Aufwand über viermal soviel Zeit benötigt, wie die eigentliche Rechnung.
C++ bietet Ihnen glücklicherweise die Möglichkeit, die Lesbarkeit der Version A mit der
Effizienz der Version B zu vereinen: Sie können eine Funktion als inline-Funktion deklarieren, indem Sie ihr das Schlüsselwort inline
voranstellen. Damit wird der
Compiler angewiesen, jeden Funktionsaufruf so zu codieren, als ob er direkt implementiert wäre. Damit können wir also eine dritte Version schreiben:
// Version C: inline-Funktion: inline int max (int i, int j) {
if (i > j) return i; else return j;}
...
m = max (a, b);
Diese Version erzeugt den gleichen Code wie die Version B. Für das Beispiel der max
-
Funktion ist das die optimale Lösung. Wegen der fehlenden Parameterübergabe sind inline
-Funktionen immer schneller als normale Funktionen. Sie sollten allerdings nur extrem kurze Funktionen als inline
deklarieren, denn bei längeren inline
-Funktionen, die oft im Programmtext verwendet werden, macht sich der erhöhte Speicherbedarf schnell bemerkbar. Das Schlüsselwort inline
ist übrigens keine Garantie, daß die
Funktion auch wirklich als inline
-Funktion übersetzt wird. Borland C++ ignoriert es bei zu komplizierten Funktionen. Zur Erleichterung der Arbeit mit dem Debugger gibt es eine
Option, die das Schlüsselwort inline
wirkungslos macht.
C++ ist bei der Verwendung der elementaren Typen in Ausdrücken, Zuweisungen und
Prozeduraufrufen recht liberal. Im Gegensatz etwa zu Modula-2, wo jede Konvertierung ausdrücklich vom Programmierer angefordert werden muß, nimmt C++ eine ganze Menge von
Standard-Konvertierungen vor.
Innerhalb von Ausdrücken dürfen die vordefinierten Datentypen im Prinzip beliebig gemischt werden. Hiervon sollten Sie allerdings möglichst mit Maßen Gebrauch machen, denn bei der Konvertierung können, zum Beispiel durch Rundungsfehler oder Abschneiden,
Daten verfälscht werden. Deshalb sollten Sie sich über die Wirkung automatischer
Konvertierungen im Klaren sein.
Stehen links und rechts eines binären Operators Operanden verschiedener Typen, geht
C++ so vor:
•
•
•
•
1.
Operanden vom Typ char
und short
werden in int
umgewandelt.
2.
Operanden vom Typ float
werden in double
umgewandelt.
Jetzt enthält der Ausdruck nur noch Operanden der Typen
- long double
- double
- unsigned long
- long
- unsigned
- int
3.
Der niedrigere der beiden Operanden wird in den höheren Typ umgewandelt. Das
Ergebnis ist dann vom höheren Typ.
Bei Zuweisungen wird automatisch der Wert der rechten Seite in den Typ der linken Seite umgewandelt. Ebenso wird bei der Übergabe von Wertparametern an Prozeduren verfahren. In beiden Fällen können Werte verfälscht werden, wenn der Zieltyp das korrekte Ergebnis nicht aufnehmen kann. Viele Compiler weisen durch Warnungen auf solche Gefahren hin.
Bei der Konvertierung von einem ganzzahligen in einen anderen ganzzahligen Typ wird so verfahren:
•
gleichbleibende Länge
:
Der Inhalt wird unverändert übernommen. Er kann allerdings anders interpretiert werden. Beispiel:
unsigned u = 0xFFFF; // u = 65535 int i = u; // i = -1 (Zweierkomplement!)
•
•
Ziel kleiner als Quelle
:
Die Quelle wird durch Abschneiden der höherwertigen Bits auf die gewünschte
Länge gebracht.
Ziel größer als Quelle
:
- Quelle signed
: Die höherwertigen Stellen werden mit dem Vorzeichenbit aufgefüllt.
- Quelle unsigned
: Die höherwertigen Stellen werden mit Nullen aufgefüllt.
Sie können auch explizit eine Typumwandlung erzwingen, indem Sie den Namen des Zieltyps wie einen Funktionsaufruf vor den ursprünglichen Ausdruck setzen: long l; int i;
...
i = int (l); i = l;
Die Umwandlung von l
in den Typ int
wird hier in der vorletzten Zeile explizit angefordert. In der letzten Zeile wird die Konvertierung automatisch ausgeführt, allerdings mit einer Warnung des Compilers, da die Umwandlung von l
nach int
Unsinn ergibt, wenn der Wert von l
zu groß ist.
Bei der expliziten Konvertierung erhalten Sie keine Warnung. Sie geben dem Compiler die
Blanko-Vollmacht, Ihre Zahl notfalls zu verstümmeln.
Manchmal kann eine explizite Konvertierung den Compiler auch vor überflüssiger Arbeit bewahren: int i, n=1; double x = 1.2; i = n + x; i = n + int (x);
In der vorletzten Zeile wird zunächst n
nach double
konvertiert, anschließend die Summe mit Gleitkomma-Arithmetik errechnet und zum int
-Wert 2 konvertiert.
Die letzte Zeile kommt zum gleichen Ergebnis mit nur einer Konvertierung und
(schnellerer) Ganzzahl-Arithmetik.
Die soeben beschriebene explizite Konvertierung ist nur mit Typnamen möglich. Wollen Sie etwa einen Zeiger p
in einen Zeiger auf char
umwandeln, so müssen Sie zunächst diesem
Typ einen Namen geben: typedef char *charptr;
Jetzt läßt sich die Umwandlung so schreiben: charptr (p)
.
Wenn Sie nicht jedem Typ einen Namen geben wollen, können Sie auch eine alternative
Form der expliziten Typumwandlung verwenden, den cast-Operator (In Standard-C gibt es
übrigens nur den cast
-Operator): Der Zieltyp wird in runde Klammern gesetzt und vor den
Ausdruck geschrieben. Im letzten Beispiel könnte man also schreiben:
(char *) p
.
Die Möglichkeiten des Überladens von Funktionen sowie der Verwendung von Vorgabe-
Argumenten können in Extremfällen zu schwer durchschaubaren Konflikten führen.
Betrachten Sie folgendes Beispiel:
#include <iostream.h> char f (int a) {return 'A';} char g (int a, int b=0) {return 'B';} char g (long a) {return 'C';} main () {
cout << f(1L) << g(1,2) << g(1) << g(1L) << g(1L,2);
}
Was geschieht bei den fünf Funktionsaufrufen im Hauptprogramm?
• f(1L)
:
Die Funktion f
verlangt ein Argument vom Typ int
. Das übergebene Argument vom
Typ long
wird automatisch nach int
konvertiert. Der Funktionswert ist also
'A'
.
• g(1,2)
:
Die Funktion g
ist überladen. Zunächst prüft der Compiler, ob die Parametertypen einer Version exakt passen. Dies ist bei der ersten Version der Fall. Der
Funktionswert ist
'B'
.
• g(1)
:
Auch hier wird eine exakte Typübereinstimmung mit der ersten Version gefunden.
Für den nicht übergebenen zweiten Parameter wird der Vorgabewert eingesetzt.
Der Funktionswert ist
'B'
.
• g(1L)
:
Der Parameter vom Typ long
paßt auf die zweite Version. Der Funktionswert ist
'C'
.
• g(1L,2)
:
Von der Anzahl der Parameter her kommt nur die erste Version von g
in Frage. Der erste Parameter wird nach int
konvertiert. Der Funktionswert ist
'B'
.
Beachten Sie, daß die Aufrufe g(1)
und g(1L)
bei beiden Versionen von g
korrekt wären, wenn es die jeweils andere nicht gäbe. Sind beide vorhanden, sucht sich der Compiler die passendere heraus nach folgendem (etwas vereinfachten) Schema:
Vereindeutigung eines Aufrufs überladener Funktionen:
•
•
1.
Stimmt die Parameterliste einer Version in Anzahl und Typ mit den Argumenten
überein, so wird sie ausgewählt. Da sich alle Varianten von überladenen Funktionen eindeutig an Hand der Anzahl oder Typen der Parameterliste unterscheiden müssen, kann es höchstens eine solche Variante geben.
2.
Gibt es beim ersten Schritt keine exakte Übereinstimmung, werden folgende
Konvertierungen der Argumente vorgenommmen: char
nach int unsigned char
nach int float
nach double
.
•
Jetzt wird wieder wie im ersten Schritt auf exakte Übereinstimmung geprüft.
3.
Gibt es beim zweiten Schritt immer noch keine exakte Übereinstimmung, wird versucht, durch Standard-Konvertierungen eine Übereinstimmung herzustellen. Gibt es hier mehrere Möglichkeiten, wird dies vom Compiler als Fehler eingestuft.
Viele Probleme lassen sich vermeiden, wenn man die gewünschten Konvertierungen explizit vorgibt. Dehalb ist es nicht unbedingt nötig, daß Sie das Schema zur Vereindeutigung beherrschen. Am folgenden Beispiel können Sie sich die Wirkung der Regeln klarmachen.
#include <iostream.h> void f (int a) {cout << 'A';} void f (float a) {cout << 'B';} main () {
int i; unsigned u; float x; double y; char c;
// Schritt Ausgabe
f (i); // 1. 'A'
f (x); // 1. 'B'
f (c); // 2. 'A'
f (u); // 3. Fehler: Mehrdeutigkeit (float/int)
f (int(u)); // 1. 'A'
f (y); // 3. Fehler: Mehrdeutigkeit (float/int)
}
Übungsaufgabe 4:
Entfernen Sie die fehlerhaften Zeilen aus dem folgenden Programm! Was gibt das
Programm jetzt aus?
#include <iostream.h>
void f (int a, long b) {cout << 'A';} void f (long a, int b) {cout << 'B';} main () {
int i;
long l;
f (i,l);
f (l,i);'
f (i,i);
f (l,l);
f (i, long(i));
f (l, int(i));
}
C++ erlaubt ebenso wie Pascal die Definition von Verbundtypen, nämlich Datentypen, die aus mehreren Elementen (Komponenten) fester, aber beliebiger Typen zusammengesetzt sind. Ein einfaches Beispiel zeigt die syntaktischen Unterschiede: type Stadt =
record
Name : string[40];
PLZ : integer;
Einwohner: longint;
end; struct Stadt {
char Name[41];
int PLZ;
long Einwohner;
}; var s1, s2: Stadt; Stadt s1, s2;
Beachten Sie bitte folgenden wesentlichen Unterschied: In Pascal wird mit der record
-
Konstruktion ein namenloser Datentyp gebildet. Dieser kann entweder direkt einer
Variablen zugeordnet oder zur Deklaration eines Typnamens verwendet werden. In dieser
Weise mehrfach definierte Typen sind nicht kompatibel. In C++ wird mit der struct
-
Konstruktion ein neuer Datentyp deklariert, der im Rest der Übersetzungseinheit
Gültigkeit hat. Daher entfallen die Inkompatibilitätsprobleme. Gleichzeitig mit der
Struktur-Deklaration können auch Variablen dieses Typs definiert werden. Werden mit der Typdefinition keine Variablen definiert, muß trotzdem nach der schließenden geschweiften Klammer ein Semikolon stehen! Es folgt ein vergleichendes Beispiel: type komplex = record x, y: real end; var a : komplex;
b,c: record x, y: real end;
d : record x, y: real end; begin
a := b; (* Type mismatch *)
b := c;
b := d; (* Type mismatch *) end.
struct komplex {float x, y;} a; komplex b; main () {
a = b;
}
Strukturen sind in C++ Spezialfälle der Klassen, auf denen die objektorientierte
Programmierung aufbaut.
Ein Sprachmittel von Pascal, das in der Praxis meist zweckentfremdet eingesetzt wird, ist der variante Record. Er ist dafür vorgesehen, daß am Ende eines Records, abhängig vom
Inhalt einer Selektor-Komponente verschiedenartige Daten am gleichen Platz gespeichert werden können, um somit Speicherplatz zu sparen. Diese Konstruktion ist insofern etwas problematisch, als damit die Typstrenge von Pascal unterlaufen wird: Der Compiler kann das korrekte Ansprechen der varianten Komponenten nämlich nicht überprüfen.
In maschinennahen Programmen möchte man manchmal auch bewußt bestimmte Daten unter verschiedenen Aspekten interpretieren. Da es in Pascal hierzu kein offizielles
Sprachmittel gibt, wird zu diesem Zweck oft der variante Record mißbraucht. Tatsächlich ist es sogar erlaubt, die in diesem Fall überflüssige Selektorvariable ganz wegzulassen, wie das folgende Beispiel zeigt: var doppelt: record case integer of
1: (u: Word);
2: (i: integer);
end; begin
doppelt.i := -1;
writeln (doppelt.u:8); (* Ergebnis: 65535 *) end.
In Turbo Pascal gibt es für solche Zwecke eine direktere Lösung: var u: Word;
i: integer absolute u; begin
i := -1;
write (u:8); end.
C++ bietet zum gleichen Zweck die union
-Konstruktion an. Eine union
hat die gleiche syntaktische Struktur wie eine struct
. Während aber alle struct
-Komponenten im
Speicher hintereinander angelegt werden, beginnen alle union
-Komponenten an der gleichen Stelle. Der Speicherplatz einer union
ist also gleich dem Speicherplatz seiner größten Komponente. Damit läßt sich das Beispiel in C++ so schreiben:
#include <iostream.h> main () {
union doppelt {int i; unsigned u;} d;
d.i = -1;
cout << d.u; // Ergebnis: 65535
}
Natürlich läßt sich auch ein orthodoxer varianter Record in C++ direkt nachbilden: Ihm entspricht eine struct
, deren letzte Komponente eine union
ist.
Für unseren einfachen Spezialfall bietet sich in C++ eine sogenannte anonyme union an.
Dabei wird kein Name für die union
vergeben. Die Komponenten dürfen dann nicht mit anderen Namen ihres Geltungsbereichs kollidieren und können direkt angesprochen
werden:
#include <iostream.h> main () {
union {int i; unsigned u;};
i = -1;
cout << u; // Ergebnis: 65535
}
Aufzählungstypen sind ein sehr nützliches Mittel, implementierungsinterne Codierungen zu verbergen und damit Programme lesbarer zu machen. In Pascal könnte man etwa die 16
Standard-Farben des EGA-Adapters so definieren: type Farbe = (
BLACK, BLUE, GREEN, CYAN,
RED, MAGENTA, BROWN, LIGHTGRAY,
DARKGRAY, LIGHTBLUE, LIGHTGREEN, LIGHTCYAN,
LIGHTRED, LIGHTMAGENTA, YELLOW, WHITE);
Die so definierten Namen haben die Werte 0 bis 15. Wenn wir allerdings jetzt zum Beispiel eine Farbe setzen wollen mit setcolor (BLUE); (* Type mismatch *) bekommen wir eine Fehlermeldung ("Type mismatch"). In der Unit Graph sind die Farben nämlich nicht als Aufzählungstyp, sondern als Konstanten vom Typ word
definiert. Die
Prozedur setcolor
verlangt ein Argument vom Typ word
und nicht vom Typ
Farbe
.
Obgleich die Elemente unseres Aufzählungstyps die gleichen Werte repräsentieren, sind sie nicht typverträglich mit den word
-Konstanten. Wir können die Typanpassung allerdings explizit verlangen: setcolor (word (BLUE));
Der soeben definierte Aufzählungstyp sieht in C++ ganz ähnlich aus: enum Farbe = {
BLACK, BLUE, GREEN, CYAN,
RED, MAGENTA, BROWN, LIGHTGRAY,
DARKGRAY, LIGHTBLUE, LIGHTGREEN, LIGHTCYAN,
LIGHTRED, LIGHTMAGENTA, YELLOW, WHITE};
Der wesentliche Unterschied besteht in der etwas geringeren Typstrenge von C++: Ein mit enum
definierter Typ wird zwar vom Typ int
unterschieden, aber es gibt implizite
Konvertierungen in beiden Richtungen zwischen jedem enum
-Typ und int
. Da ein enum
-Typ nur int
-Werte enthalten kann, ist eine Konvertierung von enum
nach int
unproblematisch.
In der umgekehrten Richtung ergibt nicht jede int
-Zahl, in einen enum
-Typ umgewandelt, einen Sinn. Deshalb verlangt das Handbuch von Borland C++, daß einer enum
-Variablen nur
Werte der gleichen Aufzählung zugewiesen werden dürfen. Tatsächlich begnügt sich der
Compiler mit einer Warnung (Der Zortech-Compiler gibt hier eine Fehlermeldung aus).
Im Normalfall werden den Elementen eines Aufzählungstyps aufeinanderfolgende Werte ab Null zugewiesen. Manchmal ist es aus technischen Gründen nötig, den Elementen bestimmte, nicht unbedingt aufeinanderfolgende Werte zu geben. Deshalb ist es erlaubt, an einzelne Elemente explizite Werte zu vergeben: enum kleinePrimzahl {ZWEI=2, DREI, FUENF=5};
Elemente, denen kein Wert zugewiesen wurde, bekommen den um eins erhöhten Wert ihres
Vorgängers (das Erste Element den Wert Null). In der Praxis sollten Sie nur Werte in einer Aufzählung zusammenfassen, die auch irgendwie logisch zusammengehören.
Neben den enum
-Typen lassen sich auch unbenannte Aufzählungen definieren, indem man einfach den Namen wegläßt: enum {SCHWARZ, WEISS};
Unbenannte Aufzählungen definieren keinen eigenen Typ, sondern stellen eine Sammlung von int
-Konstanten bereit. Sie können so zum Beispiel die von Pascal gewohnten
Wahrheitswertkonstanten definieren: enum {false, true};
Naheliegend wäre auch die folgende Definition des Datentyps boolean
: enum boolean {false, true};
Da aber die logischen Operatoren in C++ int
-Werte liefern, bekommt man so
Verträglichkeitsprobleme. Man sollte also die unbenannte Aufzählung zusammen mit der
Definition typedef int boolean; verwenden. Das folgende Beispiel zeigt nochmals den Umgang mit Aufzählungstypen: enum FARBE {ROT, GRUEN}; // benannte enum: eigener Typ enum SPRACHE {C, PASCAL}; // dto.
enum {false, true}; // unbenannte enum ist int typedef int boolean; int main () {
FARBE f;
SPRACHE s;
boolean b;
f = s; // nicht erlaubt (in Borland C++ nur Warnung)
f = b; // nicht erlaubt (in Borland C++ nur Warnung)
f = true; // nicht erlaubt (in Borland C++ nur Warnung)
b = f; // erlaubt!
b = false; // erlaubt
}
In der systemnahen Programmierung gibt es oft eine Fülle von Variablen zu verwalten, deren Wertebereich sehr klein ist, typischerweise logische Werte (
Flags). Um hier nicht für jede Variable ein Byte oder gar Wort zu verschwenden, werden mehrere
Informationen in ein Byte gepackt. Typische Beispiele sind die Variablen im Systembereich
Ihres PCs. So gibt es etwa an der Speicheradresse 417H ein Byte, das in seinen einzelnen
Bits ständig den Zustand der Umschalttasten sowie der Strg- und Alt-Tasten widerspiegelt.
Da die kleinste adressierbare Einheit Ihres Rechners das Byte ist, sind schon einige
Verrenkungen nötig, um einzelne Bits zu manipulieren. Zum Prüfen eines Bits kann man etwa eine bitweise Und-Verknüfung mit einer Konstanten vornehmen, die genau an der richtigen
Stelle ein gesetztes Bit hat, und prüfen, ob das Ergebnis von Null verschieden ist. Solche
Kunststücke machen das Lesen eines Programms allerdings zur Qual und sollten nach
Möglichkeit vermieden werden.
In Pascal bietet sich zur Lösung solcher Probleme das Mengenkonzept an. So können etwa
16 durch einen Aufzählungstyp definierte Flags in einem Maschinenwort untergebracht werden.
Wir werden später sehen, wie sich solche Probleme mit Hilfe des Klassenkonzepts in C++ elegant lösen lassen. Deshalb können Sie diesen Abschnitt ohne Schaden überspringen.
Aus Standard-C steht die Möglichkeit zur Verfügung, innerhalb von Strukturen
Bitfelder zu definieren. Das sind Ganzzahl-Komponenten, die eine Breite von 1 bis 16 Bits haben. Im folgenden Beispiel teilen sich drei Bitfeld-Komponenten ein Maschinenwort: struct Beispiel1 {
int i:6; // Wertebereich: -32 ... 31
unsigned u:3; // Wertebereich: 0 ... 7
unsigned j:7; // Wertebereich: 0 ...127
};
Zum Auffüllen von Lücken können auch unbenannte Bitfelder verwendet werden: struct Beispiel2 {
unsigned i:4;
unsigned :3; // nicht ansprechbare Lücke von 3 Bits
unsigned j:9;
};
Falls Sie Bitfelder verwenden wollen, sollten Sie folgende Hinweise beherzigen:
•
Es gibt keine Vorschrift, wie ein Compiler die einzelnen Bitfelder einer Struktur im
Speicher anordnen soll. Wenn Sie Daten verarbeiten, die nicht von Ihrem Programm erzeugt worden sind, können Sie sich nicht darauf verlassen, daß ein anderer
Compiler die Daten ebenso interpretiert. In diese Abhängigkeit vom verwendeten
Compiler begeben Sie sich zum Beispiel, wenn Sie einem Zeiger auf eine geeignete
Struktur mit Bitfeldern die Adresse der Speicherstelle mit den Tastenflags
•
•
• zuweisen.
Die Sprachdefinition schreibt lediglich vor, daß unsigned
-Bitfelder unterstützt werden müssen. Bitfelder vom Typ int
(in Zweierkomplement-Darstellung) können, müssen aber nicht unterstützt werden. Borland C++ unterstützt int
-Bitfelder,
Zortech C++ nicht!
Bitfelder können nicht Elemente einer Union sein.
Da Bitfelder im allgemeinen nicht auf Byte-Grenzen beginnen, darf der Adreß-
Operator nicht auf Bitfelder angewendet werden.
Auch wenn Sie in Pascal bisher die Verwendung von Zeigern gemieden haben: in C++ können
Sie nicht erfolgreich programmieren, ohne sich um das Zeigerkonzept zu kümmern. Das liegt daran, daß es in C++ keine Arrays im Sinne von Pascal gibt (Wir werden die Pascal-
Arrays später mit Hilfe des Klassenkonzepts nachbilden).
Ein Zeiger (Pointer) ist zunächst einmal eine Speicheradresse. Dazu trägt er aber noch eine Typinformation. Diese wird zum Zugriff auf das Objekt an der Zeigeradresse
(
Dereferenzierung) benötigt. So können etwa ein char
-Zeiger und ein int
-Zeiger auf die gleiche Adresse, aber auf verschiedene Objekte zeigen.
Zeiger werden benötigt, wenn während der Laufzeit eines Programms dynamisch
Speicherplatz vergeben wird, so etwa zum Aufbau von Bäumen oder verketteten Listen.
Die folgende Gegenüberstellung zeigt die Unterschiede in der Bezeichnung und
Dereferenzierung von Zeigern:
Zeiger in Pascal Zeiger in C++
var p: ^integer;
...
new (p); p^ := 1; dispose (p); int *p;
...
p = new int;
*p = 1; delete p;
Es folgt eine systematische Gegenüberstellung:
Zeigertypen Pascal C++
Deklaration ^
Typ
*
Variable
Dereferenzierung
Speicherreservierung
Variable
new (
^
Variable
)
*
Variable
Variable
= new
Typ
;
Speicherfreigabe dispose (
Variable
) delete
Variable
;
Bei der Deklaration einer Zeigervariable in C++ kann man natürlich statt int *p;
auch int* p;
schreiben, um anzudeuten, daß die Variable p
heißt und der Typ Zeiger auf int ist. Wenn Sie aber mehrere Zeigervariablen hintereinander erklären, müssen Sie
aufpassen: int* p1, p2;
deklariert nicht etwa zwei Zeigervariablen, wie die
Schreibweise suggeriert, sondern eine Zeigervariable p1
und eine int
-Variable p2
. Hier müßten Sie int *p1, *p2;
schreiben! new Typ
reserviert Speicherplatz für ein Objekt des Datentyps
Typ
und liefert einen
Zeiger auf dieses Objekt. Dies kann dann einem entsprechenden Zeiger zugewiesen werden. Beispiel: int *ptr; ptr = new int;
Wahlweise kann man mit Hilfe des new
-Operators auch ein Vielfaches des Speicherplatzes für den Zeigertyp anfordern. In der Anweisung ptr = new int [4]; reserviert new
zusammenhängenden Speicher für vier int
-Zahlen und liefert einen Zeiger auf den Anfang dieses Speicherplatzes zurück. Nach der Zuweisung zeigt ptr
also auf die erste der vier int
-Zahlen. Wie können aber die übrigen Zahlen angesprochen werden?
Erinnern Sie sich: In Pascal sind alle arithmetischen Operationen mit Zeigern verboten.
Welchen Sinn hätte auch etwa das Produkt zweier Zeiger?
Um die soeben definierten vier int
-Objekte ansprechen zu können, wäre es hilfreich, wenn man Zeigeradressen erhöhen könnte, etwa so: ptr1 = ptr + sizeof (int); // hypothetische Zeigerarithmetik ptr2 = ptr + 2 * sizeof (int); ptr3 = ptr + 3 * sizeof (int);
C++ erlaubt tatsächlich die Addition oder Subtraktion von int
-Zahlen zu Zeigertypen. Um das umständliche und fehleranfällige Multiplizieren mit der Typgröße zu vermeiden, rechnet C++ hierbei automatisch in Vielfachen der Typgröße und nicht in Bytes! Die
Zuweisung ptr3 = ptr + 3; addiert zur Zeigeradresse von ptr
dreimal die Typgröße (von int
), also zum Beispiel 6
Bytes. Wir können also die drei zusätzlichen Objekte so ansprechen: ptr1 = ptr + 1; // Zeigerarithmetik in C++ ptr2 = ptr + 2; ptr3 = ptr + 3;
Additionen und Subtraktionen zu Zeigern sind auch mit Hilfe der Operatoren
++
,
--
,
+= und
-=
erlaubt. Außerdem kann man die Differenz zweier Zeiger gleichen Typs berechnen.
Das Ergebnis ist eine int
-Zahl. Sie gibt die Differenz der Zeiger-Adressen in Typ-
Einheiten an.
Wir wollen jetzt sehen, wie wir die Array-Behandlung von Pascal nach C++ übertragen können:
Array in Pascal Array in C++ (1. Version)
program ArrayTest; var i: integer;
A: array[0..3] of integer; begin for i := 0 to 3 do
A[i] := i; end.
main ()
{ int i; int *A;
A = new int [4]; for (i=0; i<4; i++)
*(A+i) = i; delete A;
}
Glücklicherweise gibt es alternativ eine besser lesbare Schreibweise. Ist
A
ein Zeiger und i
eine int
-Zahl, so darf man statt
*(A+i)
auch
A[i]
schreiben:
Array in Pascal Array in C++ (2. Version)
program ArrayTest; var i: integer;
A: array[0..3] of integer; begin for i := 0 to 3 do
A[i] := i; end.
main ()
{ int i; int *A;
A = new int [4]; for (i=0; i<4; i++)
A[i] = i; delete A;
}
Das folgende Diagramm verdeutlicht die Möglichkeiten der Dereferenzierung von Zeiger-
Objekten:
Jetzt stört noch, daß wir den Speicherplatz explizit belegen und zum Schluß wieder freigeben müssen. In Pascal geschieht dies automatisch: der Speicherplatz wird mit der
Deklaration reserviert und bei Verlassen des Gültigkeitsbereichs automatisch freigegeben.
Dafür muß die Größe ein konstanter Ausdruck sein, während es in C++ auch möglich ist, die
Größe erst zur Laufzeit zu bestimmen. Von dieser neuen Freiheit haben wir in unserem
Beispiel gar keinen Gebrauch gemacht. In solchen Fällen können wir uns noch weiter der
Pascal-Schreibweise nähern:
Array in Pascal Array in C++ (3. Version)
program ArrayTest; var i: integer;
A: array[0..3] of integer; begin for i := 0 to 3 do
A[i] := i; end.
main ()
{ int i; int A[4]; for (i=0; i<4; i++)
A[i] = i;
}
Durch eine Deklaration der Form
Vektor-Deklaration:
Typname Variablenname
[
int-Konstante
]; wird Speicherplatz für
int-Konstante
Objekte von
Typname
reserviert und
Variablenname
mit der Adresse dieses Speicherplatzes initialisiert. Der Zeiger
Variablenname
wird als
Konstante aufgefaßt. Beim Verlassen des Gültigkeitsbereichs wird der so belegte Speicher automatisch freigegeben.
Obgleich die letzte Version schon fast wie in Pascal aussieht, sollten Sie sich immer wieder vergegenwärtigen, daß es eigentlich keine Arrays in C++ gibt. Was geschieht etwa in folgendem Beispiel? main ()
{
int A[4], B[4];
...
A = B;
}
Da A und B Zeiger sind, hätte die Zuweisung die Wirkung, daß A auf die gleiche Adresse wie B zeigt. Der für A ursprünglich reservierte Speicherplatz wäre nun unerreichbar.
Tatsächlich verbietet der Compiler hier die Zuweisung, weil A als konstantem Zeiger kein
Wert zugewiesen werden kann. Das vielleicht beabsichtigte Kopieren könnte man stattdessen etwa so formulieren: for (i=0; i<4; i++) A[i] = B[i];
Wie Sie gesehen haben, gibt es zu jedem Datentyp in C++ einen entsprechenden Zeigertyp.
Daneben gibt es den universellen Zeigertyp void *
. Einer Variablen dieses Typs dürfen
Sie jeden beliebigen Zeiger zuweisen. In der umgekehrten Richtung müssen Sie die
Typumwandlung explizit angeben: void *univZeiger; int *intZeiger; univZeiger = intZeiger; intZeiger = (int *) univZeiger; // korrekte Typumwandlung intZeiger = univZeiger; // Syntaxfehler!
Universelle Zeiger können nicht dereferenziert werden, weil ihnen ja kein Datentyp zugeordnet ist. Ein Beispiel für die Anwendung von universellen Zeigertypen ist die
Bibliotheksfunktion memcpy
: void *memcpy (void* dest, const void *src, size_t n); memcpy
kopiert n
Bytes ab der Adresse src
an die Adresse dest
. Dabei dürfen sich Ziel und Quelle nicht überlappen. Der Datentyp size_t
ist ein maschinenabhängig definierter
Ganzzahltyp. Als Funktionswert wird der Zeiger dest
zurückgegeben. Wir könnten die
Funktion etwa so nachimplementieren:
void *memcpy (void *dest, const void *src, size_t n)
{
size_t i;
char *Ziel;
char *Quelle;
Ziel = (char *) dest;
Quelle = (char *) src;
for (i=0; i<n; i++) Ziel[i] = Quelle[i];
return dest;
}
Um jede beliebige Anzahl von Bytes kopieren zu können, haben wir die universellen Zeiger hier in char
-Zeiger umgewandelt (Der Typ char
belegt in jeder Implementierung genau ein Byte). Damit können wir das oben formulierte Kopieren von Vektoren kurz so schreiben: memcpy (A, B, 4 * sizeof (int));
Vielleicht fragen Sie sich, wie man in C++ Vektoren darstellen kann, deren Indizes nicht bei
Null anfangen. Eine etwas undurchsichtige Lösung dieses Problems ist die Verschiebung eines Zeigers auf den Vektoranfang: var A: array[n0..n1] of integer; int A0[n1-n0+1]; int *A = A0 - n0;
Für eine saubere Lösung mit Indexüberprüfung müssen Sie sich bis zur Einführung des
Klassenkonzepts gedulden.
In Pascal gibt es eine spezielle Zeigerkonstante nil
, die auf kein Objekt zeigt und mit jedem Zeigertyp verträglich ist. Diese Konstante nil
hat intern den Wert Null. In C++ ist es ganz ähnlich: Obwohl Zeigertypen nicht mit dem Typ int
verträglich sind, kann die int
-
Konstante Null jedem Zeigertyp zugewiesen werden. Man sollte hierfür aber besser die in der Datei stddef.h
vordefinierte Konstante
NULL
verwenden. Das ist lesbarer, und erhöht die Chance, mit zukünftigen Sprachänderungen verträglich zu bleiben.
Übungsaufgabe 5:
Warum ergibt die doppelte Zuweisung im folgenden Programm einen Syntaxfehler, während die beiden einfachen Zuweisungen korrekt sind?
#include <stddef.h> int *p1; long *p2; main () {
p1 = NULL;
p2 = NULL;
p1 = p2 = NULL;
}
Die Behandlung von Strings unterscheidet sich in C++ grundsätzlich von der in Turbo
Pascal. In Turbo Pascal beginnen Strings mit einem Byte, das die aktuelle Längenangabe enthält. Deshalb können Strings nicht länger als 255 Bytes werden. In C++ sind Strings im
Prinzip char
-Vektoren und dürfen beliebig lang werden. Wenn Sie eine String-Konstante definieren, hängt der Compiler automatisch ein zusätzliches Byte mit dem Wert Null an.
Alle Standardfunktionen zur String-Behandlung halten sich ebenfalls an die Konvention, daß das Ende eines Strings durch ein Nullbyte angezeigt wird.
Wenn Sie beim Programmieren mit den String-Funktionen von Turbo Pascal Fehler machen, wie etwa im folgenden Programm, sind die Auswirkungen gewöhnlich ziemlich harmlos. program StringFehler; var s1, s2: string[10]; begin
s1 := 'String-';
s2 := 'Verkettung';
s1 := concat (s1, s2);
writeln (s1); end.
Die Verkettung von s1
und s2
ergibt
String-Verkettung
. Dies ist aber zu lang, um der
Variablen s1
komplett zugewiesen zu werden. Deshalb geht der Rest verloren, und am
Bildschirm erscheint nur
String-Ver
.
In C++ sind die Folgen solcher Fehler viel bedrohlicher: Ein String ist ein Vektor und damit im Prinzip nur ein Zeiger auf den Anfang. Somit gibt es keine Möglichkeit, zu überprüfen, ob der rechtmäßige Bereich eines Strings verlassen wird. Es folgt eine Umsetzung des
Pascal-Programms nach C++:
#include <iostream.h>
#include <string.h> // Deklarationen der String-Funktionen char s1[10], s2[10]; main () {
strcpy (s1, "String-");
strcpy (s2, "Verkettung");
strcat (s1, s2);
cout << s1;
}
Betrachten wir dieses Programm Schritt für Schritt:
Die Deklaration char s1[10]
reserviert 10 Bytes. Jetzt ist s1
ein konstanter Zeiger auf char
. Das gleiche gilt für s2
.
Die Pascal-Anweisung s1 := 'String-'
kann man in C++ nicht etwa durch s1 = "String-";
ersetzen. Sie wissen ja bereits, daß man Vektorinhalte nicht einfach zuweisen kann, weil die Variable nur für eine Adresse steht. Beim Zuweisen von Strings verwendet man in C++ die Bibliotheksfunktion strcpy
("string-copy"). Diese Funktion kopiert einfach Byte für Byte vom zweiten String zum ersten, bis sie auf ein Null-Byte stößt.
Mit strcpy (s1, "String-");
werden also die acht Bytes
'S'
,
't'
,
'r'
,
'i'
,
'n'
,
'g'
,
'-'
,
'\0'
nach s1
kopiert. In der nächsten Zeile soll der String
"Verkettung"
nach s2
kopiert werden. Hier geschieht ein Malheur, denn
"Verkettung" hat 10 Buchstaben, es werden also mit dem Null-Byte 11 Bytes kopiert, eines mehr als dafür reserviert wurde.
Zum Verketten von Zeichenketten dient die Bibliotheksfunktion strcat
. Damit wird der
Inhalt des zweiten Arguments dem ersten Argument angehängt
Obwohl die verwendeten Funktionen strcpy
und strcat
sich in der Standard-Bibliothek befinden, wollen wir hier zur Vertiefung des Verständnisses zumindest die Funktion strcpy
nachimplementieren. An diesem Beispiel möchte ich Ihnen auch exemplarisch zeigen, welche Fülle von verschiedenartigen Formulierungen C++ für ein und dieselbe
Aufgabe anbietet. Sie sollten versuchen, zumindest passiv die verschiedenen Varianten in ihrer Bedeutung zu erfassen.
Zunächst fassen wir die beiden Strings als Vektoren auf und sprechen die einzelnen
Positionen durch Indizes an: enum {false, true}; // Nachbildung des Pascal-Typs boolean typedef int boolean; char *strcpy1 (char *Ziel, const char *Quelle) {
int i = 0;
boolean fertig = false;
while (! fertig) {
Ziel[i] = Quelle[i];
if (Quelle[i] == '\0') fertig = true;
else i = i+1;}
}
Byte für Byte wird der Quellstring zum Zielstring kopiert. Wenn das erste Nullbyte kopiert wurde, ist die Arbeit getan. Die boolesche Variable fertig
dient hier zum
Verlassen der Schleife. Diese Variable läßt sich einsparen, wenn wir eine Endlosschleife verwenden, die mit der Schleifenabbruch-Anweisung break
verlassen wird: char *strcpy (char *Ziel, const char *Quelle) {
int i = 0;
for (;;) { // Endlosschleife
Ziel[i] = Quelle[i];
if (Quelle[i] == '\0') break;
i++;} // Kurzschreibweise für i = i+1;
}
Eine weitere Vereinfachung ergibt sich, wenn man berücksichtigt, daß der Wert einer
Anweisung in C++ gleich dem Wert der rechten Seite ist: char *strcpy (char *Ziel, const char *Quelle) {
int i = 0;
while ((Ziel[i] = Quelle[i]) != '\0') i++;
}
Ein solch knapper Programmierstil ist sicher eine Zumutung für die meisten Leser.
Trotzdem müssen Sie darauf gefaßt sein, immer wieder solchen Formulierungen von C-
Programmierern zu begegnen.
Fast jedes Programm läßt sich noch weiter vereinfachen. Da es in C++ keinen Unterschied zwischen Wahrheitswerten und Zahlen gibt (und da char
mit int
verträglich ist), läßt sich jede Abfrage der Form
(Variable != 0)
durch
(Variable)
ersetzen: char *strcpy (char *Ziel, const char *Quelle) {
int i = 0;
while (Ziel[i] = Quelle[i]) i++;
}
Manche C-Programmierer mögen die while
-Anweisung nicht und schreiben lieber: char *strcpy (char *Ziel, const char *Quelle) {
for (int i=0; Ziel[i] = Quelle[i]; i++);
}
Der Aufwand für die indizierten Variablen läßt sich einsparen. Es steht ja s[0]
für
*s
, s[1]
für
*(s+1)
usw. Anstatt jeweils den Index zu erhöhen, können wir auch die Zeiger selbst erhöhen: char *strcpy (char *Ziel, const char *Quelle) {
while (*Ziel = *Quelle) {Quelle++; Ziel++;}
}
Im Standardwerk von Kernighan/Ritchie [Kern88] wird folgende Extrem-Lösung vorgeschlagen: char *strcpy (char *Ziel, const char *Quelle) {
while (*Ziel++ = *Quelle++);
}
Hier wird in der while
-Bedingung das Kopieren und das Erhöhen der Zeiger verpackt. Zum
Verständnis müssen Sie wissen, daß unäre Operatoren rechtsassoziativ sind.
*Ziel++ steht also für
*(Ziel++)
und nicht etwa für
(*Ziel)++
. Weiter müssen Sie sich klarmachen, daß der Wert von
Ziel++
die Adresse vor dem Erhöhen ist. Ein solch kryptischer Stil wird immerhin von modernen Compilern mit mehreren Warnungen bedacht:
Die Form while (a = b)
könnte ein Pascal-Programmierer versehentlich statt while (a
== b)
geschrieben haben. Außerdem ist eine leere Anweisung nach einer while
-Bedingung verdächtig. In diesem Fall kann man diese Warnungen ignorieren, aber im allgemeinen können Sie sich viel Kummer ersparen, wenn Sie jede Warnung des Compilers überprüfen.
Schließlich sehen Sie das Beispiel noch einmal für for
-Liebhaber: char *strcpy (char *Ziel, const char *Quelle) {
for (; *Ziel++ = *Quelle++;);
}
Übungsaufgabe 6:
Was bewirkt das folgende Programmstück? char s[10]; strcpy (s, "Vorsicht!"); strcpy (s+1, s);
Übungsaufgabe 7:
Testen Sie folgendes Programm und erklären Sie das merkwürdige Verhalten:
#include <iostream.h>
#include <string.h> main () {
char s1[6], s2[4], s3[6];
strcpy (s3, "BAR");
strcpy (s2, "WIRKLICH");
strcpy (s1, "WUNDER");
strcat (s1, s3);
cout << "\n" << s1;
}
In C++ können Sie keine Variablen erklären, die Funktionen als Wert haben. Dies scheitert technisch daran, daß je zwei Funktionen im allgemeinen verschiedenen Speicherbedarf haben. Erlaubt sind dagegen Zeiger auf Funktionen. Ebenso wie Datenzeiger sind
Funktionszeiger typisiert. Zwei Funktionszeiger sind nur dann kompatibel, wenn sie auf
Funktionen mit dem gleichen Parameterschema (gleiche Anzahl, gleiche Typen) und typgleichem Funktionswert zeigen.
Wie werden Zeiger auf Funktionen definiert? Nehmen Sie eine Funktionsdeklaration in der
Kurzform ohne Parameternamen, setzen Sie vor den Funktionsnamen einen Stern und klammern Sie den Funktionsnamen mit dem Stern ein! Zum Beispiel wird mit char Summe (int, int); eine Funktion deklariert, die zwei Argumente vom Typ int
verlangt und einen Wert vom
Typ char
zurückgibt. Mit char (*FktZeiger) (int, int); wird ein Zeiger auf eine solche Funktion definiert. Die Klammern um den Namen mit dem
Stern dürfen Sie nicht vergessen, denn mit char *Zeiger (int, int); wird kein Zeiger auf eine Funktion deklariert, sondern eine Funktion, die einen Zeiger auf char
zurückgibt.
Wenn Sie einem Zeiger auf eine Funktion etwas zuweisen möchten, müßten Sie eigentlich
die Adresse einer Funktion bilden. Um die Schreibweise zu vereinfachen, gilt hier eine
ähnliche Konvention wie bei den Vektortypen: Funktionsnamen stehen prinzipiell für einen
Zeiger auf die Funktion. Erst durch die runden Klammern der Parameter dahinter wird der
Zeiger dereferenziert und die Funktion aufgerufen. Deshalb müssen in C++ auch beim
Aufruf einer Funktion ohne Parameter die runden Klammern stehen!
Das folgende Beispiel zeigt, wie Zeiger auf Funktionen definiert werden, und wie man ihnen
Funktionen als Wert zuweist: int (*f) (int); // f ist Zeiger auf Funktion mit einem Argument
// vom Typ int und Ergebnistyp int int f1 (int i) {return i;} int f2 (int n) {return 2*n;} int f3 (float x) {return 3*x;} main () {
f = f1; // steht für f = &f1;
n = f(1); // steht für n = (*f)(1); jetzt ist n = 1
f = f2;
n = f(1); // jetzt ist n = 2
f = f3; // Fehler: unverträgliche Zeigertypen!
}
Das folgende Beispiel eines einfachen Sortieralgorithmus ( Bubblesort) zeigt eine sinnvolle
Anwendung von Funktionszeigern. Die Funktion bsort
kann Vektoren eines beliebigen
Datentyps sortieren. Dazu wird die Adresse des Vektors, die Anzahl der Elemente und die
Größe des Datentyps in Bytes übergeben. Zum Sortieren muß außerdem noch gesagt werden, wie zwei Elemente verglichen werden, denn das hängt von der Interpretation der
Daten ab. So kann zum Beispiel ein Speicherwort (2 Bytes), in dem alle Bits gesetzt sind, die unsigned
-Zahl 65535 oder die int
-Zahl -1 bedeuten.
// BSORT.CPP
#include <iostream.h> void vertausche (void *p1, void *p2, size_t Groesse) {
char h;
char *cp1 = p1, *cp2 = p2;
for (int i=0; i<Groesse; i++) {
h = cp1[i];
cp1[i] = cp2[i];
cp2[i] = h;}
} void bsort (void *Basis, // Adresse des Sortierbereichs
size_t Anzahl, // Anzahl der Elemente
size_t Groesse, // Größe eines Elements
int (*Vergleichsfunktion)(const void*, const void*)) {
char *i, *j;
for (i=Basis+(Anzahl-1)*Groesse; i>Basis; i-=Groesse)
for (j=Basis; j<i; j+=Groesse)
if ((*Vergleichsfunktion) (i, j) < 0)
vertausche (i, j, Groesse);
} int VglInt (int *i, int *j) {return *i - *j;} main () {
int i;
static int A[10] = {31, 41, 59, 26, 53, 58, 97, 93, 23, 84};
cout << "\nunsortiert:\n";
for (i=0; i<10; i++) cout << A[i] << " ";
bsort (A, 10, sizeof (int), VglInt);
cout << "\nsortiert:\n";
for (i=0; i<10; i++) cout << A[i] << " ";
cout << "\n";
}
Bitte bedenken Sie, daß der Bubblesort-Algorithmus bei größeren Datenmengen ziemlich ineffektiv arbeitet. In der Standard-Bibliothek von C (
STDLIB.H
) gibt es auch eine
Quicksort-Implementierung qsort
, die mit genau den gleichen Parametern aufgerufen wird.
Die beiden Begriffe Deklaration und Definition sind geeignet, vollständige Verwirrung hervorzurufen, weil ihre Bedeutung in verschiedenen Kontexten völlig unterschiedlich gehandhabt wird.
Folgen wir einem modernen Standardwerk der Informatik [Watt90], so ist eine
Deklaration ein Programmstück, das einen Namen mit einem Objekt im weitesten Sinne verbindet. Existiert dieses Objekt bereits, so spricht man von einer Definition. Einige
Beispiele in Pascal mögen dies klären: const n = 1; (* Definition: Bindung des Namens n an Zahl *) type Zahl = integer;(* Deklaration: Zahl ist neuer Typ! *) var i: integer; (* Deklaration: neue Variable wird erzeugt *)
In der Pascal-Terminologie spricht man gewöhnlich nur von Deklarationen. Durch eine
Deklaration wird im Normalfall eine Variable eingeführt oder eine Funktion (oder
Prozedur) definiert. Im Gegensatz dazu wird durch eine forward
-Deklaration keine
Funktion definiert, sondern nur die Schnittstelle einer an anderer Stelle definierten
Funktion erklärt.
Die Standardwerke über C++ sind sich darin einig, daß eine Deklaration, die die angesprochene Variable oder Funktion wirklich bereitstellt,
Definition genannt wird. Der
Begriff
Deklaration wird nicht einheitlich verwendet. Bei Stroustrup [Stro86] wird er als
Oberbegriff benutzt. Es fehlt also ein Begriff für eine "Nur-Deklaration". Lippman
[Lipm89] versteht unter einer Deklaration eine "Nur-Deklaration". In diesem Fall fehlt der
Oberbegriff. Um nicht ein neues Wort schöpfen zu müssen, wollen wir uns im allgemeinen der Sprechweise von Stroustrup anschließen, aber Ausnahmen machen, wenn dies aus dem
Zusammenhang klar wird.
In Pascal besteht eine klare Trennung zwischen Deklarationen und Anweisungen: Jeder
Block besteht aus einem Deklarationsteil und einem Anweisungsteil. Im Deklarationsteil gibt es wiederum einen speziellen Abschnitt für Variablendeklarationen (beginnend mit dem Schlüsselwort var
). Turbo Pascal hat zwar die Reihenfolge innerhalb des
Deklarationsteils liberalisiert, die Trennung zwischen Deklarations- und Anweisungsteil bleibt aber bestehen. In C++ gibt es diese Trennung nicht. Hier dürfen Deklarationen fast
überall stehen. Wir wollen auf die Möglichkeiten im einzelnen eingehen:
Eine
Variablendefinition besteht aus der Typbezeichnung, gefolgt vom Namen. Die Variable kann bei der Definition schon mit einem
Anfangswert initialisiert werden. int n; // Einfache Definition int a = 1; // Initialisierung int i, j = 3, k; // mehrere Variable gleichen Typs
// können gemeinsam definiert werden
Globale Variablen sind alle Variablen, die außerhalb einer Funktionsdefinition definiert oder deklariert werden. Eine globale Variable lebt während der ganzen Laufzeit des
Programms. Sie muß in genau einer Datei einmal definiert werden. Wenn sie in anderen
Dateien angesprochen werden soll, muß sie dort deklariert werden. Eine Deklaration wird durch das vorangestellte Schlüsselwort extern
gekennzeichnet. Im folgenden Beispiel wird in der Datei 1 eine Variable n
vom Typ int
definiert. Diese Variable kann zunächst nur in der Datei angesprochen werden, in der sie definiert wurde. Soll sie in einer anderen
Datei angesprochen werden, muß sie dort zunächst deklariert werden.
// Datei 1: int n; // hier wird die Variable n definiert
...
// Datei 2: extern int n; // Deklaration der anderswo definierten Variablen n
Bei einer Deklaration stellt der Compiler keinen Speicherplatz zur Verfügung, sondern erhält nur die Information, daß anderswo eine solche Variable existiert. Wenn Sie versehentlich eine Variable deklarieren, die nirgendwo definiert ist, kann dieser Fehler erst vom Binder erkannt werden.
Das Schlüsselwort extern
suggeriert leicht ein Mißverständnis. Das mit extern deklarierte Objekt muß nicht in einer anderen Datei definiert werden. Es kann auch in der gleichen Datei an anderer Stelle definiert sein. Mit extern
wird lediglich eine Deklaration von einer Definition unterschieden.
Manchmal ist es angebracht, die Sichtbarkeit einer globalen Variablen auf die Datei zu beschränken, in der sie definiert ist. Hierzu dient das Schlüsselwort static
. static int n; // n ist aus anderen Dateien nicht ansprechbar
Lokale Variablen werden innerhalb eines Blocks (also auch innerhalb einer Funktion) definiert. Sie sind nur in dem Block sichtbar, in dem sie definiert sind. Sie können an beliebiger Stelle im Block definiert werden, dürfen aber nicht vor ihrer Definition angesprochen werden. Lokale Variablen können nicht deklariert werden. Dies wäre auch nicht sinnvoll, da sie außerhalb des Blocks nicht sichtbar sind.
In Pascal werden alle lokalen Variablen bei ihrer Definition (auf dem Stack) angelegt und beim Verlassen des Blocks wieder beseitigt. Dadurch wird es oft notwendig, globale
Variablen anzulegen, die nur für eine bestimmte Funktion relevant sind. In C++ können Sie auch lokale Variablen definieren, deren Lebensdauer die Programmlaufzeit ist. Hierzu wird ebenfalls das Schlüsselwort static
verwendet. Damit ist es möglich, Funktionen mit einem
"Gedächtnis" auszustatten. Betrachten Sie als Beispiel folgenden Zufallszahlengenerator: int random (int n) { // erzeugt Zufallszahl im Bereich 0 ... n-1
static unsigned long r = 1;
r = r * 1103515245 + 12345;
return (unsigned) (r >> 16) % n;
}
Gegenüber einer globalen Variablen, wie es in Pascal erforderlich wäre, bietet die lokale static
-Variable zwei Vorteile: Erstens wird so vermieden, daß inhaltlich zusammengehörende Informationen im Programm verstreut werden müssen. Zweitens ist die lokale Variable vor versehentlichem Zugriff von außerhalb der Funktion geschützt.
Wenn eine globale und eine lokale Variable den gleichen Namen haben, kann in Pascal nur die lokale Variable angesprochen werden. In C++ können mit dem unären Präfix-Operator
::
globale Variablen auf Programmebene angesprochen werden, wenn sie mit einer lokalen
Variablen kollidieren. Im folgenden Beispiel werden drei Variablen n
definiert: Auf
Programmebene (mit Wert 1), im Hauptblock der Funktion main
(Wert 2) und in einem inneren Block der Funktion main
(Wert 3). Vom inneren Block aus ist die Variable des umfassenden Blocks nicht ansprechbar!
// Test des Global-Operators ::
#include <stream.h> int n = 1; main () {
int n = 2;
{
int n = 3;
cout << "lokal: " << n << ", global: " << ::n << "\n"; // 3, 1
}
cout << "lokal: " << n << ", global: " << ::n << "\n"; // 2, 1
}
Sie wissen bereits, wie Funktionen definiert werden. Für eine Funktionsdeklaration nimmt man die Funktionsdefinition ohne den Funktionsblock, setzt ein Semikolon dahinter und das
Schlüsselwort extern
davor. extern int Summe (int a, int b); // Deklaration der Funktion Summe
...
int Summe (int a, int b) { // Definition der Funktion Summe
return a + b;
}
Das Schlüsselwort extern
darf bei der Deklaration auch weggelassen werden.
Ebenso wie bei Variablen kann die
Sichtbarkeit einer Funktion mit dem Schlüsselwort
static
auf die Datei beschränkt werden, in der sie definiert ist.
Funktionsdeklarationen können zum einen wie die forward
-Deklarationen in Pascal verwendet werden, etwa bei sich wechselseitig aufrufenden rekursiven Funktionen. Zum anderen werden sie benötigt, um Funktionen verwenden zu können, die in einer anderen
Datei definiert sind.
globale Variablen, Funktionen
Definition
Deklaration
Sichtbarkeit
Lebensdauer
außerhalb von Funktionen mit extern
: außerhalb oder innerhalb von Funktionen mit static
: Datei sonst: alle Dateien
Programmlaufzeit
lokale Variablen
innerhalb von Funktionen keine weitere Deklaration
Block mit static
: Programmlaufzeit sonst: Block-Ausführung
Verschachtelte Deklarationen sind in C++ nicht ganz einfach zu durchschauen, weil der
Aufbau nicht, wie in Pascal, geradlinig von links nach rechts geschieht, sondern unter
Berücksichtigung der Vorrangregeln. Eine Deklaration in C++ besteht aus einem Typnamen und dahinter einem verschachtelten Ausdruck, der den Namen des zu deklarierenden
Objekts enthält. Zum Verständnis muß man wissen, daß dieser Ausdruck ein Objekt vom
Typ des davorstehenden Typnamens bezeichnet. Ein Beispiel soll dies verdeutlichen: int (*a)[n];
Diese Deklaration definiert ein Objekt a
mit der Eigenschaft, daß
(*a)[n]
vom Typ int ist. Welchen Typ hat demnach a
selbst? Die Frage läßt sich schrittweise beantworten:
(*a)[n]
ist von Typ int
.
(*a)
ist vom Typ Vektor von int
. a
ist vom Typ Zeiger auf Vektor von int
.
Wichtig für die richtige Interpretation verschachtelter Deklarationen ist die Kenntnis der
Vorrangregel: Unäre (einstellige) Operatoren - und dazu gehören insbesondere die
Vektorklammern
[]
und die Funktionsklammern
()
- sind rechtsassoziativ. Um es nochmals zu wiederholen: Wenn rechts und links von einem Ausdruck ein solcher Operator steht, wird zunächst der rechte Operator angewendet. Lassen wir in der obigen Deklaration die runden Klammern weg, ändert sich die Bedeutung: int *a[n]; // vollständig geklammert: int *(a[n]);
*(a[n]
) ist von Typ int
. a[n]
ist vom Typ Zeiger auf int
. a
ist vom Typ Vektor von Zeigern auf int
.
Statt dieser schrittweisen Zerlegung von außen nach innen lassen sich komplexe
Deklarationen mit etwas Übung unmittelbar von innen (beim zu deklarierenden Namen beginnend) nach außen (beim Typnamen endend) lesen, indem man gemäß der Vorrangregel, wenn möglich, zunächst nach rechts geht: int *(a[n]);
3 2 0 1
Die Zahlen 0 bis 3 zeigen hier die Schritte: (0) a
ist (1) Vektor von (2) Zeigern auf (3) int
.
Machen Sie sich bitte den Unterschied zwischen den beiden folgenden Deklarationen klar: int (* f)()
3 1 0 2 (0) f
ist (1) Zeiger auf (2) Funktion mit Wert (3) int
.
int * f ()
3 2 0 1 (0) f
ist (1) Funktion mit Wert (2) Zeiger auf (3) int
.
Ein letztes Beispiel dient als nicht nachahmenswerter Härtetest. Im nächsten Abschnitt sehen Sie, wie man komplexe Deklarationen vermeiden kann.
int * ( * ( * f ) () ) []
6 5 3 1 0 2 4
(0) f
ist (1) Zeiger auf (2) Funktion mit Wert (3) Zeiger auf (4) Vektor von (5) Zeigern auf
(6) int
.
Übungsaufgabe 8:
Orden Sie die Variablen
A
...
H
im folgenden Pascal-Programm den entsprechenden
Variablen a
... h
im anschließenden C++ Programm zu: program PascalProgramm; const n=9; type
ArrayOf_Int = array[0..n-1] of integer;
ArrayOf_ArrayOf_Int = array[0..n-1] of ArrayOf_Int;
PointerTo_Int = ^integer;
PointerTo_ArrayOf_Int = ^ArrayOf_Int;
ArrayOf_PointerTo_Int = array[0..n-1] of PointerTo_Int;
PointerTo_PointerTo_Int = ^PointerTo_Int; var
A: array[0..n] of array[0..n-1] of array[0..n-1] of integer;
B: array[0..n] of array[0..n-1] of ^integer;
C: array[0..n] of ^ArrayOf_Int;
D: ^ArrayOf_ArrayOf_Int;
E: array[0..n] of ^PointerTo_Int;
F: ^ArrayOf_PointerTo_Int;
G: ^PointerTo_ArrayOf_Int;
H: ^PointerTo_PointerTo_Int; begin end.
// C++ Programm const int n=10; int ***a, *(*b)[n], **c[n], (**d)[n],
e[n][n][n], (*f)[n][n], *g[n][n],
(*h[n])[n]; main () {}
Von Pascal kennen Sie die Möglichkeit, eigene Typen zu definieren. Dabei ist zu beachten, daß jede Typdefinition einen eigenen Typ definiert. Betrachten wir ein Beispiel: type
typ1 = ^integer;
typ2 = ^integer; var
a,b: typ1;
c : typ2;
d : ^integer; begin
a := b; (* korrekt: a und b vom gleichen Typ *)
a := c; (* Type mismatch *)
a := d; (* Type mismatch *) end.
Sie sehen, daß zwei Typen auch dann inkompatibel sind, wenn sie identisch definiert sind.
In C++ ist die Situation etwas verworren. Vergegenwärtigen Sie sich bitte, daß es in C++ zwei unterschiedliche Methoden zur Bildung abgeleiteter Datentypen gibt: Zum einen gibt es die Operatoren
*
,
[]
und
()
zur Bildung von Zeiger-, Vektor- und Funktionstypen, zum anderen die Schlüsselwörter struct
und union
zur Bildung von Strukturen und Varianten.
Zwischen diesen beiden Gruppen besteht auch ein wesentlicher formaler Unterschied. Mit den Operatoren werden namenlose Typen gebildet, während die beiden Schlüsselwörter neue Typen im Sinne von Pascal bilden.
Um den namenlosen Typen auch Bezeichnungen geben zu können, hat C++ die typedef
-
Anweisung von Standard-C übernommen. Diese gleicht einer Variablendefinition mit dem vorangestellten Schlüsselwort typedef
. Hiermit wird anstelle eines Variablennamens eine
Typbezeichnung eingeführt. So wird etwa mit typedef int *intZeiger; eine Bezeichnung für den Datentyp Zeiger auf int
eingeführt.
Mit der typedef
-Anweisung wird nur ein alternativer Name (Synonym) für einen Typ eingeführt, jedoch kein neuer Typ definiert.
Betrachten Sie die feinen Unterschiede im folgenden Beispiel: struct typ1 {int x, y;}; // typ1 und typ2 sind strukturgleich, struct typ2 {int x, y;}; // aber nicht verträglich!
typedef typ2 typ3; // typ3 ist Synonym zu typ2 typedef int *intZeiger; typ1 a; typ2 b; typ3 c; intZeiger p; int *q; main () {
a = b; // Fehler: typ1 und typ2 sind verschiedene Datentypen
b = c; // korrekt: typ2 und typ3 sind Synonyme
p = q; // korrekt: intZeiger ist Synonym für int *
}
Übungsaufgabe 9:
Welcher Datentyp wird durch folgende Deklaration eingeführt? int (*(* A[n])(int))[n];
Definieren Sie diesen Typ mit einer Folge von einfachen Typdeklarationen!
Nachdem Sie nun die Sprachmittel für die konventionelle Programmierung in C++ kennengelernt haben, befassen wir uns in diesem Kapitel mit dem Prozeß der
Programmentwicklung und den dazu nötigen Hilfsmitteln.
Wir haben schon in vielen Beispielen die Präprozessor-Direktiven
#include
und
#define verwendet. Hier finden Sie eine Zusammenstellung, die auch weitere Möglichkeiten des
Präprozessors umfaßt.
Vor jedem Compilerlauf wird die Quelldatei mit dem Präprozessor bearbeitet. Dabei werden Kommentare ausgeblendet und Präprozessor-Direktiven ausgeführt. Der Compiler von Borland C++ umfaßt den Präprozessor und erzeugt keine Zwischendatei. Es kann aber manchmal nützlich sein, die Wirkung des Präprozessors zu sehen, vor allem, um unerklärliche Fehler aufzuspüren. Deshalb gibt es bei Borland C++ den Präprozessor auch als eigenständiges Programm.
Präprozessor-Direktiven sind vom eigentlichen Programmtext durch das Zeichen
#
am
Zeilenanfang klar zu unterscheiden. Im Gegensatz zur Syntax von C++, für die
Zeilenwechsel keine Bedeutung haben, muß jede Compiler-Direktive mit einem
Zeilenwechsel abgeschlossen werden.
Eine Direktive der Form
#include "
Dateiname
" veranlaßt, daß diese Zeile durch den Inhalt der Datei
Dateiname
ersetzt wird. Die Datei wird zunächst im aktuellen Verzeichnis gesucht, anschließend werden die voreingestellten
Include-Verzeichnisse nach der Datei durchsucht. Wird der Dateiname statt in
Anführungszeichen in spitze Klammern eingeschlossen, werden nur die voreingestellten
Include-Verzeichnisse nach der Datei durchsucht.
#include <stdlib.h> // im Include-Verzeichnis
#include "hilfe.h" // im aktuellen Verzeichnis
Wie die Include-Verzeichnisse spezifiziert werden, ist von der Implementierung des
Compilers abhängig. Bei den meisten C-Compilern wird dafür eine Umgebungsvariable des
Betriebssystems gesetzt. In der integrierten Umgebung von Borland C++ werden die
Verzeichnisse im Menü (
Options - Directories) angegeben.
#include
-Direktiven dürfen verschachtelt werden: Eingebundene Dateien dürfen wieder
#include
-Direktiven enthalten.
Die
#define
-Direktive erlaubt Textersetzungen (Makros) mit oder ohne Parameter. Sie sind ein wichtiges Hilfsmittel in Standard-C. Sie haben aber auch einen großen Nachteil:
Mit Textersetzungen läßt sich sehr leicht die Typstrenge von C++ unterlaufen. In C++ sollten Konstanten-Definitionen, Aufzählungen oder inline-Funktionen bevorzugt werden.
Sie dürfen den kurz gehaltenen Rest dieses Abschnitts deshalb mit gutem Gewissen
überfliegen.
Ein Makro ohne Parameter hat folgende Form:
#define
Name Ersetzungstext
Dabei muß der Name ein gültiger Bezeichner sein, für den Ersetzungtext gibt es keine
Einschränkungen. Er kann sogar leer sein. Hinter dem Namen darf auch eine Parameterliste stehen (Argumente: gültige Bezeichner, durch Kommata getrennt, in runde Klammern eingeschlossen). Zwischen dem Namen und der öffnenden Klammer der Parameterliste darf kein Lückenzeichen (Leerzeichen, Tabulator etc.) stehen.
Definierte Makros können mit der
#undef
-Direktive wieder aufgehoben werden.
Übungsaufgabe 10:
Untersuchen Sie das folgende Programm. Erklären Sie die Fehler und ersetzen Sie die
Makros durch inline
-Funktionen:
#define Quadrat(x) x*x
#define Doppelt (x) x+x main () {
int a,b,c;
a = Quadrat (b+c);
a = Doppelt (b+c);
}
Oft ist es nötig, mehrere Versionen eines Programms zu entwickeln, die sich nur in wenigen
Details unterscheiden. Diese Unterschiede können in der Anpassung an eine bestimmte
Hardware-Konfiguration (Grafik-Auflösung, Koprozessor etc.) oder an verschiedene
Compiler begründet sein. Man könnte hierzu eine Kopie des Quelltextes anfertigen und darin die Änderungen anbringen. Oft fallen aber später Korrekturen im versionsunabhängigen Programmteil an. Diese müssen dann immer in allen Versionen angebracht werden. Dabei geht leicht die Übersicht verloren.
Man kann sich viel Verwirrung und Speicheraufwand ersparen, indem man die verschiedenen Versionen mit den Compiler-Direktiven zur bedingten Compilierung verwaltet:
#ifdef
Bezeichner
Programmstück1
#else
Programmstück2
#endif
Das
Programmstück1
wird übersetzt, falls der Bezeichner (mit
#define
) definiert ist. Ist er nicht definiert, so wird das
Programmstück2
übersetzt. Die Definition des Bezeichners sollte ganz am Anfang des Programms stehen. So läßt sich durch Ändern einer Zeile zwischen zwei Programmversionen umschalten. Wie üblich, darf der else
-Zweig auch
entfallen (
#else
-Direktive und Programmstück2). Mit der
#ifndef
-Direktive kann auch geprüft werden, ob der Bezeichner nicht definiert ist. Die Direktiven zur bedingten
Compilierung dürfen selbstverständlich verschachtelt werden.
In der integrierten Umgebung von Borland C++ können
#define
-Anweisungen auch im Menü
Options - Compiler - Code Generation - Defines angegeben werden. Das ist vor allem dann praktisch, wenn diese Definitionen in mehreren Dateien benötigt werden.
Bisher haben wir nur Programme betrachtet, deren Quelltext in einer Datei abgespeichert ist. Nicht ganz kleine Programmierprojekte werden aber in der Praxis sinnvollerweise modular aufgebaut: Der Programmtext wird so in mehrere Dateien zerlegt, daß einzelne dieser Dateien möglichst für andere Zwecke wiederverwendet werden können. Jede dieser
Dateien wird einzeln übersetzt und die übersetzten Objekte zu einem lauffähigen
Programm zusammengebunden. Turbo Pascal bietet hierfür (ab der Version 4.0) das
Unit-
Konzept an.
Eine erste Vorstellung, wie Sie in C++ Programme modular aufbauen können, sollen Ihnen die folgenden Programmskelette geben: unit Hilf; interface
var i: integer;
function f (i: integer): char; implementation
var k: integer;
procedure p (i: integer);
begin
...
end;
// hilf.cpp
int i; static int k; static void p (int i) {
...
}
function f (i: integer): char;
begin
...
end;
end.
program Haupt; uses Hilf; var k: integer; begin
...
end.
char f (int i) {
...
}
// haupt.cpp
extern int i; extern char f (int i); static int k; void main () {
...
}
Beachten Sie folgenden Unterschied: Zu einem Pascal-Programm gehören genau eine program
-Datei und beliebig viele unit
-Dateien. In C++ gibt es keinen formalen
Unterschied zwischen den einzelnen Dateien. Alle können Deklarationen und Funktionen in beliebiger Folge enthalten, aber genau eine Datei muß die Hauptfunktion main
enthalten.
Die mit einem Modul benötigten Deklarationen faßt man üblicherweise in einer
Header-
Datei zusammen. Diese erhält die Extension
.h
. Damit sieht die Gegenüberstellung der
Hauptmoduln so aus: program Haupt; uses Hilf;
// haupt.cpp
#include <hilf.h> var k: integer; begin
...
end.
static int k; void main () {
...
}
Die Header-Datei könnte so aussehen:
// hilf.h
extern int i; extern char f (i: integer);
Innerhalb von Include-Dateien können wiederum Dateien mit
#include
eingebunden werden.
// a.h
#include <b.h>
#include <c.h>
...
// b.h
#include <d.h>
...
// c.h
#include <d.h>
...
Mit dem Präprozessorbefehl
#include <a.h>
wird die Datei d.h
zweimal eingebunden.
Um dies zu verhindern, sollte man alle Header-Dateien nach folgendem Schema absichern:
// hilf.h
#ifndef HILF_H
#define HILF_H
extern int i; char f (i: integer): char;
#endif
Beim ersten Einbinden dieser Datei ist dem Präprozessor der Name
HILF_H
unbekannt.
Deshalb wird der Text zwischen
#ifndef
und
#endif
eingelesen. Hierbei wird der Name
HILF_H
definiert (als leere Zeichenkette) und ist dem Präprozessor beim zweiten
Einbinden dieser Datei bekannt. In diesem Fall wird der Text zwischen
#ifndef
und
#endif
übersprungen.
Wenn Sie ein Programm in einzelne Moduln zerlegen, können Sie nicht nur die
Überschaubarkeit erhöhen, Sie sparen auch noch wertvolle Entwicklungszeit ein. Denn nach einer Änderung in einem der Programm-Moduln brauchen Sie nicht Ihr ganzes Programm neu zu übersetzen, sondern nur den veränderten Modul.
Bei größeren Projekten ist es aber nicht ganz einfach, die notwendigen Konsequenzen einer
Änderung zu überschauen. Haben Sie etwa Korrekturen in einer Header-Datei angebracht, so müssen alle Quelltexte neu übersetzt werden, die diese Datei (eventuell indirekt) importieren. Anschließend muß der Linker aufgerufen werden, um das ausführbare
Programm auf den neuesten Stand zu bringen.
Eine unbefriedigende Lösung des Problems wäre eine Stapeldatei, die pauschal sämtliche
Schritte der Übersetzung und Bindung erledigt. Damit ginge aber der zeitliche Vorteil der getrennten Übersetzung verloren. Im Betriebssystem UNIX gibt es deshalb das
Dienstprogramm
MAKE
, das automatisch die erforderlichen Schritte ausführt. Dazu müssen die Abhängigkeiten der Datei zunächst einmal in einer Projektbeschreibungsdatei
(
MAKEFILE
) dokumentiert werden. Es folgt ein Beispiel (für den Zortech-Compiler), für ein
Programm, das von zwei Quelldateien und einer Header-Datei abhängt. grafdemo.exe: grafdemo.obj grafik.obj
ztc -ml -ografdemo grafdemo.obj grafik.obj
grafdemo.obj: grafik.h grafdemo.cpp
ztc -ml -c -ografdemo.obj grafdemo.cpp
grafik.obj: grafik.h grafik.cpp
ztc -ml -c -ografik.obj grafik.cpp
Diese Datei besteht aus drei Regeln. Jede Regel besteht aus zwei Zeilen. In der ersten
Zeile steht der Name des gewünschten Produkts, dann ein Doppelpunkt und danach die benötigten "Zutaten". In der zweiten Zeile (die eingerückt sein muß) folgt das "Rezept" zur Herstellung des Produkts.
Das Dienstprogramm
MAKE
prüft, ob eine oder mehrere der Zutaten neueren Datums als das Produkt sind. Nur in diesem Fall wird das Rezept neu ausgeführt. Wurde in unserem
Beispiel die Datei grafdemo.cpp
geändert, so betrifft dies zunächst die zweite Regel: grafdemo.cpp
wird neu übersetzt. Danach ist grafdemo.obj
neuer als grafdemo.exe
.
Es wird also auch die erste Regel ausgeführt. Danach ist das Projekt auf dem neuesten
Stand, ohne daß grafik.cpp
neu übersetzt wurde.
Dieses einfache Beispiel konnte natürlich nur ein Schlaglicht auf
MAKE
werfen. Tatsächlich gibt es noch weitere Möglichkeiten, wie implizite Regeln und Makros.
Wenn Sie in Borland C++ programmieren, steht Ihnen das MAKE-Programm zwar zur
Verfügung, Sie benötigen es aber nur, wenn Sie die Kommandozeilenversion des Compilers verwenden. Innerhalb der integrierten Oberfläche von C++ können Sie die eingebaute automatische Projektverwaltung verwenden. Sie erklärt sich fast selbst. Sie brauchen nur
in einem Dialog die Programmdateien anzugeben, aus denen Ihr Projekt besteht. Die
Abhängigkeit von den darin importierten Header-Dateien findet die Projektverwaltung selbst heraus!
Wenn Sie Programmdateien geschrieben haben, die Sie immer wieder in verschiedenen
Projekten benötigen, und die soweit ausgereift sind, daß sie nicht mehr ständig geändert werden müssen, gibt es einen bequemeren Weg als die Dateien mit Hilfe der
Projektverwaltung einzubinden. Sie können mehrere Objekt-Dateien mit Hilfe des
Bibliotheksverwaltungsprogramms (bei Borland C++ heißt es TLIB.EXE) in eine Bibliothek zusammenfassen. Entweder erzeugen Sie eine neue Bibliothek oder fügen die Dateien in die Standard-Bibliothek ein. Die Bedienung von TLIB ist im Benutzerhandbuch von Borland
C++ (bei Turbo C++ in einer mitgelieferten Datei) genau beschrieben.
Falls Sie daran interessiert sind, Programme zu erzeugen, die bescheiden mit dem
Speicherplatz umgehen, sollten Sie folgendes beachten: Während Turbo Pascal beim
Binden optimiert und nur die Prozeduren einbindet, die auch wirklich verwendet werden, werden bei C++ immer komplette Objekt-Dateien eingebunden. Deshalb kann es sich auszahlen, wenn Sie anstelle einer großen Objektdatei eine Bibliothek mit vielen kleinen
Objektdateien verwenden.
Zur Sprache C gehört nach der ANSI-Norm eine reichhaltige Funktionsbibliothek. Dies ermöglicht eine weitgehende Portabilität von C-Programmen, denn man kann sich darauf verlassen, daß diese Funktionen in jeder neueren Implementierung vorhanden sind.
Hier finden Sie nur eine Auswahl wichtiger Funktionen. Zunächst werden die Funktionen zur Datei-Behandlung von ANSI-C dargestellt. Sie sind in der Datei
STDIO.H
deklariert.
Anschließend finden Sie kurze Hinweise zu weiteren wichtigen Standard-Funktionen.
In C++ gibt es auch eine objektorientierte Ein-/Ausgabe-Bibliothek (Header-Datei:
IOSTREAM.H
), die allerdings noch nicht genormt ist. In diesem Buch verwenden wir nur elementare Möglichkeiten der objektorientierten Ein-/Ausgabe. Über weitere Details und
über weitere Funktionen können Sie sich jederzeit mit der eingebauten Hilfe-Funktion von
Borland C++ informieren (Cursor auf den Funktionsnamen setzen und Strg-F1 drücken).
printf
(const char *format, ...);
Die Funktion printf
(Abkürzung für "print formatted") dient zur formatierten Ausgabe
am Bildschirm und bietet eine Fülle möglicher Darstellungsarten, für deren Details Sie bitte Ihr Referenzhandbuch oder die eingebaute Hilfe von Borland C++ zu Rate ziehen.
Zunächst fallen die drei Punkte am Ende der Deklaration auf. Damit wird dem Compiler mitgeteilt, daß die Funktion eine variable Anzahl von Argumenten akzeptiert. Nur der Typ der ersten Arguments wird geprüft. Das erste Argument ist der
Format-String. Er kann beliebig viele Konvertierungs-Anweisungen enthalten, die jeweils mit einem Prozent-
Zeichen beginnen. Zu jeder Konvertierungsanweisung im Format-String muß anschließend genau ein Argument des entsprechenden Typs folgen.
int puts (const char *str); puts
(Abkürzung für "put string") gibt den mit einem Nullbyte abgeschlossenen String auf stdout
aus und macht einen Zeilenvorschub.
In Standard-Pascal werden Dateitypen mit Hilfe der Konstruktion file of <Datentyp> deklariert. Damit ist eine sehr starre Form vorgegeben: Die Datei muß in exakt gleich strukturierten Portionen abgearbeitet werden. In der Praxis ist es aber oft sinnvoll, Daten verschiedener Typen hintereinander zu speichern. So kann etwa am Anfang einer Datei ein
Vorspann stehen, der eine Typinformation enthält, und anschließend je nach Typ Daten unterschiedlicher Formate.
Dieser freien Form der Dateiverwaltung kommen in Turbo Pascal die untypisierten Dateien mit den Prozeduren
BlockWrite
und
BlockRead
entgegen. Wenn Sie diese Prozeduren kennen, werden Sie schnell mit den entsprechenden Bibliotheksfunktionen von C zurechtkommen.
Neben den typisierten Dateien gibt es in Pascal noch den vordefinierten Dateityp text
für
Textdateien, die in variabel lange Zeilen gegliedert sind. In C++ gibt es keinen formalen
Unterschied zwischen Text- und Binärdateien. Es ist möglich, in einer Datei Text- und
Binärabschnitte unterzubringen.
Wir betrachten hier die Dateifunktionen nach der ANSI-Norm. Alternativ dazu gibt es in
Borland C++ auch die Dateifunktionen des Betriebssystems UNIX.
Wenn Sie die ANSI-Dateifunktionen verwenden, müssen Sie die Header-Datei
STDIO.H einbinden. Neben den Funktionsdeklarationen ist dort der Datentyp
FILE
definiert. Er enthält alle Komponenten, die zur Bearbeitung einer Datei benötigt werden. Obwohl Ihnen diese Komponenten zugänglich sind (in Standard C gab es im Gegensatz zu C++ noch keinen
Schutzmechanismus zum Verbergen von Typ-Details), sollten Sie den Typ
FILE
als Black
Box auffassen und auf ihn nur mit den im folgenden besprochenen Funktionen zugreifen!
Eine Datei wird mit der Funktion
FILE *fopen (const char *path, // Dateiname const char *mode); // Art des Zugriffs eröfffnet. Für die Art des Zugriffs kann eingesetzt werden:
mode Datei wird geöffnet:
"r"
"w" zum Lesen (read) zum Schreiben (write)
"a"
"r+"
"w+" zum Schreiben ans Ende der Datei (append) existierende Datei zum Lesen und Schreiben neue Datei zum Lesen und Schreiben
"a+"
überall lesen, schreiben nur am Ende
Ohne eine Datei zu öffnen, können folgende
FILE
-Zeiger verwendet werden:
•
•
• stdin
Standard-Eingabe stdout
Standard-Ausgabe stderr
Fehlerausgabe, (mit stdout
identisch, wenn stdout
nicht umgeleitet wird)
Unter MS-DOS ist noch eine kleine Komplikation zu beachten. Während unter UNIX die
Konvention gilt, daß der Zeilenwechsel in einer Textdatei durch das Zeichen
'\n'
(Zeilenvorschub) gekennzeichnet wird, gilt in der MS-DOS-Welt die merkwürdige
Vereinbarung, daß die Textzeilen durch eine Folge von zwei Bytes, nämlich
"\r\n"
(Wagenrücklauf, Zeilenvorschub) getrennt werden. Das ist ein Relikt aus den Zeiten der
Fernschreiber: Mit der Trennung von Wagenrücklauf und Zeilenvorschub war es beispielsweise möglich, eine Zeile zunächst zu schreiben und anschließend nach einem
Wagenrücklauf zu unterstreichen.
Abgesehen davon, daß dieses historische Relikt unnötig Speicher verschlingt, ist sie auch ein Fallstrick beim Programmieren in C. Um die Portabilität von C-Programmen zu garantieren, die auf Textdateien zugreifen, werden Textdateien auch in den MS-DOS-
Versionen von C intern im UNIX-Format behandelt. Obwohl C keinen formalen Unterschied zwischen Text- und Binärdateien macht, muß man unter MS-DOS beim Öffnen der Datei angeben, ob sie als Binärdatei oder als Textdatei behandelt werden soll. Im letzteren Fall wird Lesen aus der Datei automatisch die Byte-Folge
"\r\n"
in das Zeichen
'\n' umgewandelt. Beim Schreiben geschieht dies in umgekehrter Richtung.
Um die Art der Umsetzung anzugeben, können Sie an den Modus-String den Buchstaben
't'
für Textdateien oder
'b'
für Binärdateien anhängen. Wenn Sie keinen Buchstaben anhängen, wird ein Standardwert gewählt, der in einer globalen Variablen
_fmode gespeichert und auf
't'
voreingestellt ist.
size_t fread (void *ptr, // Puffer-Adresse size_t size, // Elementgröße in Bytes size_t n, // Anzahl derElemente
FILE *stream);// Dateizeiger
Die Funktion fread
(file read) entspricht der Turbo Pascal-Prozedur
BlockRead
.
Während Sie bei
BlockRead
eine Puffer-Variable übergeben können, müssen Sie bei fread
die Adresse explizit übergeben. Beim Lesen von Vektoren ersparen die beiden
Parameter size
und n
eine Multiplikation.
Als Funktionswert wird die Anzahl der tatsächlich gelesenen Elemente (nicht Bytes!) zurückgegeben. char *fgets (char *s, // Pufferadresse int n, // Puffergröße
FILE *stream); // Dateizeiger fgets
(file put string) entspricht in etwa der readln
-Prozedur in Pascal. Die Funktion liest bis zum nächsten Zeilenwechsel und schreibt die Zeile mit dem Zeilenende-Zeichen als String (mit
'\0'
abgeschlossen) in den Puffer s
. Das Lesen wird vorzeitig abgebrochen, wenn die Puffergröße n
nicht ausreicht.
Als Funktionswert wird die Pufferadresse s
zurückgegeben. Wird beim Lesen das
Dateiende überschritten, ist das Ergebnis
NULL
. int fgetc (FILE *stream); fgetc
(file get character) liest ein Zeichen in einer Datei und gibt das gelesene Zeichen zurück, nachdem es zunächst nach unsigned char
und dann nach int
konvertiert wurde.
Ist das Dateiende erreicht, wird die Konstante
EOF
zurückgegeben.
size_t fwrite (const void *ptr, // Puffer-Adresse size_t size, // Elementgröße in Bytes size_t n, // Anzahl der Elemente
FILE *stream); // Dateizeiger
fwrite
entspricht
BlockWrite
in Turbo Pascal und hat die gleichen Parameter wie fread
. int fputs (const char *s, FILE *stream); fputs
ist das Gegenstück von fgets
. Es schreibt den String s
, der mit
'\0' abgeschlossen sein muß, in die Datei und hängt nicht automatisch ein Zeilenwechselzeichen an.
Das folgende einfache Beispiel gibt eine Textdatei am Bildschirm aus. In der Praxis sollte man auf mögliche Fehler abfragen.
#include <stdio.h> int main () {
FILE *f;
char s[256];
f = fopen ("test.cpp", "rt");
while (fgets (s, 256, f))
fputs (s, stdout);
fclose (f);
return 0;
}
Wenn wir hier fputs (s, stdout)
durch puts (s)
ersetzen, wird am Bildschirm nach jeder Zeile eine Leerzeile eingefügt, weil fgets
den Zeilenwechsel mitliest und puts
die
Ausgabe immer mit einem Zeilenvorschub beendet.
int fputc (int c, FILE *stream); fputc
schreibt das Zeichen char (c)
in die Datei und gibt es als Funktionswert zurück, bei Erreichen des Dateiendes wird
EOF
zurückgegeben.
Im Gegensatz zu Standard-Pascal ist auch ein nicht-sequentieller Dateizugriff möglich.
Mit fseek
kann der Dateizeiger positioniert werden, mit ftell
kann die Position abgefragt werden: int fseek (FILE *stream, // Dateizeiger long offset, // Relativposition int whence); // Bezug der Relativposition
Für den Parameter whence
kann eingesetzt werden:
SEEK_SET
relativ zum Dateianfang (absolute Position)
SEEK_CUR
relativ zur aktuellen Position
SEEK_END
relativ zum Dateiende fseek
muß immer vor einem Wechsel zwischen Lesen und Schreiben (und umgekehrt) aufgerufen werden, außer wenn beim Lesen das Dateiende erreicht wird.
long ftell (FILE *stream); // gibt die absolute Position zurück
int fclose (FILE *stream);
int feof (FILE *fp); feof
(file end of file) gibt an, ob beim letzten Zugriff das Dateiende erreicht wurde. int ferror (FILE *fp); ferror
gibt an, ob beim Dateizugriff ein Fehler aufgetreten ist.
Das folgende Beispielprogramm zeigt einige der besprochenen Dateifunktionen und erlaubt es, mit dem nicht-sequentiellen Zugriff ein wenig zu experimentieren. Versuchen Sie zum
Beispiel einmal, hinter das Dateiende zu positionieren und dann zu schreiben!
Die Funktion
Auswahl
dient zum Abfragen eines Menü-Buchstabens und ist auch außerhalb dieses Programms einsetzbar. Als Parameter wird ein String aus den möglichen Buchstaben
übergeben. Die Funktion wartet dann solange. bis einer dieser Buchstaben eingegeben wurde und gibt ihn als Funktionswert zurück. Ein eingegebener Buchstabe wird in
Großbuchstaben umgewandelt. Dann wird mit der Bibliotheksfunktion strchr
geprüft, ob er im String vorkommt. Deshalb ist es wichtig, daß im String nur Großbuchstaben eingesetzt werden.
Die Funktion filesize
ermittelt die Länge einer geöffneten Datei, indem sie den
Dateizeiger ans Ende positioniert.
Im Hauptprogramm wird eine Datei zum Lesen und Schreiben geöffnet. Anschließend kann man menügesteuert auf beliebige Byte-Positionen positionieren und dort int
-Werte lesen oder schreiben. In der Zeile if (f = fopen ("test.dat", "r+b")) ...
wird der Wert von fopen
(Dateizeiger) der Variablen f
zugewiesen und gleichzeitig geprüft, ob er von
NULL
verschieden ist. Solche Abfragen von Zuweisungswerten sind zwar in der C-Programmierung allgemein üblich, können aber auch darauf hinweisen, daß versehentlich
==
mit
=
verwechselt wurde. Deshalb gibt der Compiler hier eine Warnung aus. Wenn Sie diesen Stil vermeiden wollen, können Sie solche Anweisungen auch in zwei
Anweisungen auflösen: f = fopen ("test.dat", "r+b")) ...
if (f) ...
Beachten Sie auch, daß die Funktion main
einen Rückgabewert vom Typ int
hat. Hier wird normalerweise der Wert Null zurückgegeben, bei einem Scheitern des Programms ein von
Null verschiedener Wert, der unter MS-DOS mit dem Stapel-Befehl
IF ERRORLEVEL
... abgefragt werden kann.
Hier beginnt das vollständige Programm:
// FILETEST.CPP
#include <stdio.h>
#include <conio.h> // getch
#include <string.h>
#include <ctype.h> char Auswahl (const char *erlaubt) {
char z;
do {
z = toupper (getch ());
if (z == '\0') // erweiterter IBM-Code ?
getch (); // nächstes Zeichen aus dem Tastaturpuffer holen
}
while (strchr (erlaubt, z) == NULL);
return z;
} long filesize (FILE *f) {
long pos, laenge;
pos = ftell (f); // aktuelle Position des Dateizeigers
fseek (f, 0L, SEEK_END); // positioniert ans Ende der Datei
laenge = ftell (f);
fseek (f, pos, SEEK_SET); // setzt den Dateizeiger zurück
return laenge;
} int main () {
int n, Wert, Ergebnis;
long Pos;
FILE *f;
char z;
printf ("\n\n");
if (f = fopen ("test.dat", "r+b")) {
printf ("Die bestehende Datei wurde eröffnet\n");
printf ("Dateigröße = %ld Bytes\n", filesize (f));
}
else if (f = fopen ("test.dat", "w+b"))
printf ("die Datei wurde neu angelegt!\n");
else {
printf ("Datei kann nicht angelegt werden\n");
return 1;
}
while (1) {
Pos = ftell (f);
printf ("aktuellePosition: %ld. [L]esen [S]chreiben "
"[P]ositionieren [E]nde\n", Pos);
z = Auswahl ("LSPE");
switch (z) {
case 'L': n = fread (&Wert, sizeof(int), 1, f);
if (n == 1) printf ("Wert[%ld] = %d\n", Pos, Wert);
else printf ("Lesen gescheitert (%d).\n", n);
break;
case 'S': printf ("Wert = ");
scanf ("%d", &Wert); printf ("\n");
n = fwrite (&Wert, sizeof(int), 1, f);
if (n == 1) printf ("Wert wurde geschrieben\n");
else printf ("Schreiben gescheitert (%d).\n", n);
break;
case 'P': printf ("neue Position = ");
scanf ("%ld", &Pos); printf ("\n");
Ergebnis = fseek (f, Pos, SEEK_SET);
if (Ergebnis == 0)
printf ("Positionieren erfolgreich\n");
else printf ("Positionieren gescheitert.\n");
break;
case 'E': fclose (f);
return 0;
}
}
}
Die Funktionen strcpy
und strcat
haben wir bereits mehrfach verwendet. Bevor Sie eigene Funktionen zur String-Manipulation schreiben, sollten Sie einen Blick in die lange
Liste der vorhandenen Bibliotheksfunktionen werfen (Datei
STRING.H
). Hier gibt es
Funktionen zum Suchen, Vergleichen, zur Umwandlung von Groß- und Kleinbuchstaben und vieles mehr.
Die Header-Datei
MATH.H
enthält die Deklarationen der mathematischen Funktionen nach der ANSI-Norm. Dazu gehören fast alle Pascal-Standard-Funktionen (bis auf trunc
, round
, odd
, sqr
) und viele weitere Funktionen.
Hier konnte nur eine kleine Auswahl wichtiger Bibliotheksfunktionen vorgestellt werden. Es gibt eine Fülle weiterer Bibliotheksfunktionen nach der ANSI-Norm, die Ihnen viel Arbeit abnehmen können, wie etwa Quicksort ( qsort
), binäre Suche ( bsearch
),
Zufallszahlengenerator ( rand
), Zeit- und Datums-Routinen. Sie sollten einmal einen
Streifzug durch das Referenzhandbuch machen, damit Sie später bei Bedarf das richtige
Werkzeug finden.
Wenn Sie gefragt würden, warum Sie in Pascal lieber programmieren als beispielsweise in
BASIC, würden Sie höchstwahrscheinlich auf das Prozedurkonzept zu sprechen kommen.
Nachdem Sie sich einmal Gedanken darüber gemacht haben, wie ein bestimmtes Problem zu lösen ist, verpacken Sie die gefundene Lösung in eine Prozedur. Anschließend brauchen Sie nur noch zu wissen, was die Prozedur mit den übergebenen Parametern tut, nicht wie. Diese
Möglichkeit der
Prozedurabstraktion ermöglicht es, schrittweise immer komplexere
Operationen als elementare Einheiten anzusehen und somit zur überschaubaren Behandlung immer komplexerer Probleme vorzudringen. Steht Ihnen etwa die Bibliotheksfunktion sin
(für die Sinusfunktion) zur Verfügung, ist ihre Verwendung ebenso einfach, wie die der
Funktion abs
(für den Absolutbetrag). Mit Hilfe selbst definierter Prozeduren ist es möglich, die Programmiersprache zu erweitern und sich selbst ein immer mächtigeres
Werkzeug zu schaffen.
Ein wichtiges Kriterium für einen guten Programmierstil ist die Leichtigkeit der Anpassung an veränderte Bedingungen. Wer kennt nicht jene Programme, die als holographisches
Gesamtkunstwerk im Kopf ihres Schöpfers entstehen, sich später aber jedem Versuch einer Korrektur oder Anpassung mit unerklärlichem Fehlverhalten entziehen?
Die Prozedurabstraktion kommt einem in diesem Sinne guten Programmierstil entgegen.
Die Implementierung einer Prozedur kann jederzeit geändert oder verbessert werden, ohne daß der Rest des Programms davon beeinträchtigt wird, solange die Anzahl und die
Typen der Parameter gleich bleiben. Gerade hier stößt man aber an die Grenzen der
Prozedurabstraktion. Oft stellt sich später heraus, daß bestimmte Datentypen besser anders definiert werden sollten. Dann müssen aber alle Prozeduren und Programmteile geändert werden, die auf diese Datentypen zugreifen.
Wir wollen das einfache Beispiel eines Puffers betrachten, wie er zum Beispiel als
Tastaturpuffer oder als Druckerpuffer verwendet wird: Ein Puffer (auch Warteschlange,
Queue) dient dazu, Datenelemente (in unserem Beipiel wollen wir einzelne Bytes puffern) bis zu einer maximalen Anzahl zwischenzuspeichern und bei Bedarf in richtiger
Reihenfolge wieder auszugeben. Mit einem Puffer müssen also folgende Operationen durchgeführt werden können:
•
•
•
• den Puffer in den Anfangszustand (leer) bringen; angeben, ob der Puffer voll ist; angeben, ob der Puffer leer ist; ein Byte puffern;
• ein Byte ausgeben.
Prozeduren, die diese Operationen ausführen, müssen auf die Datenstruktur des Puffers zugreifen. Nun ist es aber keineswegs klar, wie man einen Datentyp
Puffer
implementiert.
Man könnte eine einfach verkettete Liste verwenden mit je einem Zeiger auf den Anfang und das Ende. Ebensogut ist es aber möglich, eine zyklische Liste mit nur einem Zeiger auf das Ende zu verwenden (der Anfang ist dann einfach der Nachfolger des Endes). In vielen
Fällen genügt aber auch ein Vektor fester Länge mit zwei Indexvariablen. Die
Implementierung aller Puffer-Prozeduren hängt nun entscheidend von der gewählten
Darstellung der Daten ab.
Ebenso, wie man mit einer Prozedur die Details einer Implementierung verbirgt, wäre es wünschenswert, auch die Details eines Datentyps zu verbergen. Wenn ein Datentyp aber seine Struktur nicht mehr preisgibt, wie kann man dann Operationen mit diesem Datentyp programmieren? Die Antwort ist: Der Datentyp muß selbst alle mit ihm möglichen
Operationen ausführen können.
In unserem Beispiel eines Puffers haben wir vier Grundoperationen aufgezählt. Das Prinzip der Datenabstraktion besteht nun darin, die Datenstruktur Puffer zusammen mit den
Grundoperationen, den sogenannten Methoden des Datentyps zu einem Paket so zusammenzuschnüren, daß nur noch indirekt, nämlich mit Hilfe der Methoden, auf die
Datenstruktur zugegriffen werden muß. Man spricht von einem abstrakten Datentyp, wenn nur beschrieben wird, welche Operationen man mit den Daten ausführen kann, nicht aber wie dies geschieht. Die Methoden werden also unabhängig von einer konkreten
Datenstruktur beschrieben. In der Theorie stellt man dazu ein Axiomensystem auf, das den Datentyp mit seinen Methoden eindeutig beschreibt. In der Praxis genügt es meist, die Methoden verbal zu beschreiben.
Wir könnten den abstrakten Datentyp
Puffer
etwa durch folgende konkrete
Implementierung darstellen: const int MAX = 10; struct Puffer {
char D[MAX];
int Anfang, Ende;
}; void leere (Puffer& P) { // Referenzparameter (wird verändert)
P.Anfang = P.Ende = 0;
} boolean istLeer (Puffer P) { // Wertparameter (wird nur abgefragt)
return P.Anfang == P.Ende;
} boolean istVoll (Puffer P) {
return P.Anfang == (P.Ende + 1) % MAX;
} void lege (Puffer& P, char c) { // Einschränkung:
if (istVoll (P)) return; // Fehler wird ignoriert!
P.D[P.Ende++] = c;
if (P.Ende >= MAX) P.Ende = 0;
}
char entnimm (Puffer& P) {
if (istLeer (P)) return '\0'; // dto.
char z = P.D[P.Anfang++];
if (P.Anfang >= MAX) P.Anfang = 0;
return z;
}
Hier wird eine zyklische Anordnung der Elemente des Puffers in einem Vektor der Länge
MAX
verwendet. Das erste Element hat den Index
Anfang
, das letzte den Index
Ende-1
.
Ist
Anfang
gleich
Ende
, so ist der Puffer leer. Um einen vollen von einem leeren Puffer unterscheiden zu können, dürfen höchstens
MAX-1
Elemente gepuffert werden. Der Puffer ist voll, wenn dem Index
Ende
(in der zyklischen Anordnung) der Index
Anfang
direkt folgt.
Wenn man sich jetzt konsequent daran hält, nur über die Methoden auf die Objekte vom
Typ
Puffer
zuzugreifen, darf die Datenstruktur später beliebig geändert werden, vorausgesetzt, die Methoden werden so angepaßt, daß sie den gleichen Axiomen genügen.
Wir könnten also ohne Probleme auf eine der oben erwähnten verketteten
Listendarstellungen umsteigen.
Unsere konkrete Darstellung des Typs
Puffer
entspricht übrigens genau der
Implementierung des Tastaturpuffers, in den das BIOS (Basic Input/Output System)
Ihres PC Tastenanschläge auf Abruf zwischenspeichert. Das BIOS bietet auch Methoden an, mit deren Hilfe man auf diesen Puffer zugreifen kann, ohne seine Struktur zu kennen.
Die oben entwickelte Implementierung des abstrakten Datentyps
Puffer
steht und fällt mit der Konsequenz, mit der wir uns an das Gebot halten, nicht direkt auf die
Datenstruktur zuzugreifen. Ein wirksamer Schutz wäre es, wenn man den direkten Zugriff einfach verbieten könnte. Man spricht dann vom
Geheimnisprinzip (Data hiding). Man kann zwar in jeder Programmiersprache abstrakte Datentypen realisieren; um das
Geheimnisprinzip einhalten zu können, bedarf es aber einer Spracherweiterung.
C++ bietet eine elegante Möglichkeit, abstrakte Datentypen unter Wahrung des
Geheimnisprinzips darzustellen. Betrachten wir einfach einmal eine Darstellung unseres
Puffers in C++: const int MAX = 10; struct Puffer { public:
boolean istLeer () const;
boolean istVoll () const;
void leere ();
void lege (char c);
char entnimm (); private:
char D[MAX];
int Anfang, Ende;
};
Wenn Sie diese Struktur mit der zuvor definierten vergleichen, bemerken Sie folgende
Neuerungen:
•
•
Die Strukturdefinition ist in zwei Abschnitte aufgeteilt: Mit public:
beginnt der
öffentliche Teil, mit private:
der private Teil. Elemente (Komponenten, members) des privaten Teils sind für Benutzer dieser Struktur nicht zugänglich. Läßt man diese Bereichsmarken weg, ist die ganze Struktur öffentlich.
Die Struktur enthält neben Datenelementen auch Funktionen. Das sind die Methoden der Datenstruktur.
Die Methodenfunktionen haben jeweils einen Parameter weniger als in unserer vorherigen
Definition. Das hängt mit der veränderten Sichtweise zusammen: Während man in der prozedurorientierten Programmierung einer Prozedur die Daten übergibt, sind in der objektorientierten Programmierung die Daten die zentralen Objekte. Diese Objekte können selbst alle denkbaren Operationen ausführen. Deshalb können sie ihre interne
Struktur nach außen verbergen. In der Smalltalk-Sprechweise werden Botschaften an die
Objekte geschickt; die Objekte veranlassen dann selbständig das Nötige. In unserem jetzigen Stadium ist das einfach nur eine andere Sichtweise des gleichen Sachverhalts.
Daß daraus völlig neue Möglichkeiten erwachsen, werden Sie später sehen.
Elementfunktionen werden in der gleichen Weise angesprochen wie Datenelemente: Dem
Namen des Objekts folgt, durch einen Punkt getrennt, der Name des Elements:
Puffer P; // Definition eines Objekts vom Typ Puffer
P.Anfang = 0; // versuchter Zugriff auf privates Element
P.leere (); // Elementfunktion
Die zweite Zeile dieses Beispiels führt zu einem Fehler, weil das Element
Anfang
privat ist. Die dritte Zeile zeigt den Aufruf der Elementfunktion leere
. Welches Objekt geleert werden soll, sagt der Objektname P. Es gibt also bei jeder Elementfunktion einen impliziten Parameter, nämlich einen Zeiger auf das Objekt, von dem aus die Funktion aufgerufen wurde. Dieser Zeiger ist übrigens auch explizit unter dem Schlüsselwort this ansprechbar, was allerdings selten nötig ist.
Ein abstrakter Datentyp, der so realisiert wird, wie wir es hier getan haben, wird in der objektorientierten Programmierung
Klasse genannt. Deshalb wird in C++ statt struct vorzugsweise das Schlüsselwort class
verwendet. Der einzige Unterschied zwischen struct
und class
besteht darin, daß in einer struct
alle Elemente öffenlich sind, falls sie nicht als private
erklärt werden, in einer class
dagegen sind alle Elemente privat, wenn sie nicht als public
erklärt werden.
Es gibt (vielleicht zu Ihrer Beruhigung) noch einen Unterschied zwischen Funktions- und
Datenelementen: Jedes Objekt einer Klasse hat seine eigenen Datenelemente. Die
Funktionen werden dagegen nur einmal für die Klasse gespeichert.
Im allgemeinen sollte man sich beim Entwurf von Klassen an folgende
Faustregel halten:
Die Klassenelemente sind privat, außer wenn sie wirklich von Benutzern der Klasse verwendet werden sollen. Daraus folgt insbesondere, daß im Regelfall alle Datenelemente privat sein sollten. Der Zugriff auf die Datenelemente soll nur über spezielle
Zugriffsmethoden möglich sein, die vor allem unerlaubte Zuweisungen verhindern sollen.
Betrachten wir folgendes Klassenfragment: class Pixel { public:
void setze (int x, int y);
int getX () const;
int getY () const;
void plot (int Farbe); // zeigt den Grafik-Punkt private:
int px, py;
};
Hier sind die Koordinaten des Punkts nicht direkt zugänglich, sondern nur über die
Methoden setze
, getX
und getY
. Daher können wir in der Implementierung dieser
Methoden dafür sorgen, daß ein
Pixel
immer innerhalb des Bildschirms liegt. Wären die
Koordinaten öffentlich, müßte die Funktion plot
jedesmal auf korrekte Koordinaten prüfen. Der Nachteil solcher
Datenkapselung besteht darin, daß zusätzliche
Zugriffsfunktionen zu schreiben sind. Diese Funktionen sind meist so einfach, daß sie als inline
deklariert werden sollten. Dann erkauft man die zusätzliche Sicherheit nicht mit langsamerem Programmlauf.
Natürlich muß man sich nicht sklavisch an diese Regeln halten. Wenn klar ist, daß die
Datenstrukturen später nicht verändert werden und es keine verbotenen Werte gibt, kann man natürlich auch auf die Privatheit der Daten verzichten. So wäre es sicher unkritisch, wenn man in einer Klasse für komplexe Zahlen den Real- und Imaginärteil einer komplexen
Zahl öffentlich deklarieren würde. Borland C++ hält sich allerdings an die Regel und vereinbart die Elemente des Datentyps complex
als private
.
In der Klassendefinition sind die Methoden zwar deklariert, aber nicht definiert. Die
Implementierung einer Klasse wird außerhalb der Klassendefinition nachgeholt. Dabei wird vor jeden Funktionsnamen der Klassenname geschrieben, getrennt durch zwei
Doppelpunkte. In den Funktionsdefinitionen kann direkt auf die Elemente zugegriffen werden, auch (und nur hier!) auf die privaten Elemente: boolean Puffer::istLeer () const {
return Anfang == Ende;
} boolean Puffer::istVoll () const {
return Anfang == (Ende + 1) % MAX;
} void Puffer::leere () {
Anfang = Ende = 0;
} void Puffer::lege (char c) {
if (istVoll ()) return;
D[Ende++] = c;
if (Ende >= MAX) Ende = 0;
} char Puffer::entnimm () {
if (istLeer ()) return '\0';
char z = D[Anfang++];
if (Anfang >= MAX) Anfang = 0;
return z;
}
Wir wollen als zweites Beispiel ein vollständiges Programm betrachten und schrittweise die erweiterten Möglichkeiten von C++ einführen. In Pascal kann man mit Hilfe des Operators set of
Teilmengentypen eines Ordinaltyps definieren. Betrachten Sie bitte folgendes
Pascal-Programm program BitsetTest; type
bitset = set of 0..15; var
i : integer;
a,b,c: bitset; procedure zeigeBitset (b: bitset);
var i: integer;
begin
for i := 0 to 15 do
if i in b then write ('+')
else write ('-');
writeln;
end; begin
a := []; (* leere Menge *)
b := [];
for i := 2 to 9 do a := a + [i];
for i := 6 to 13 do b := b + [i];
write ('a = '); zeigeBitset (a);
write ('b = '); zeigeBitset (b);
c := a + b; write ('a + b = '); zeigeBitset (c);
c := a * b; write ('a * b = '); zeigeBitset (c);
c := a - b; write ('a - b = '); zeigeBitset (c); end.
Hier wird der (in Modula-2 übrigens schon vordefinierte) Datentyp bitset
eingeführt.
Anschließend werden die in Pascal verfügbaren Mengenoperationen Vereinigung,
Durchschnitt und Mengendifferenz getestet. In C++ gibt es keine Mengentypen. Deshalb setzen wir in einer ersten Version die Mengenoperationen direkt in die entsprechenden
Bit-Operationen um:
////////////////////////////////////////////////////////////////////
// BITSET0.CPP //
// Konventionelle Lösung: Direkter Zugriff auf einzelne Bits //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#define INT_BITS 16 // abhängig vom Compiler void zeigeBits (unsigned s) {
cout << "{";
for (int i=0; i<INT_BITS; i++)
cout << (((s & (1<<i)) != 0) ? "+" : "-");
cout << "}\n";
}
///////////////////// Test der Zugriffsmethoden: /////////////////// main () {
unsigned a,b,c,i;
a = b = 0; // a und b = leere Menge
a = ~a; // a = Komplement (a) = {0..15}
for (i=2; i<10; i++)
a &= ~(1 << i); // Elemente 2..9 entfernen: a = {0,1,10..15}
for (i=4; i<12; i++)
b |= 1 << i;; // Elemente 4..11 einfügen: b = {4..11}
cout << "\n"; // neue Zeile
zeigeBits (a);
zeigeBits (b);
c = a | b; // c = Vereinigung von a und b = {0,1,4..15}
zeigeBits (c);
c = a & b; // c = Durchschnitt von a und b = {10,11}
zeigeBits (c);
}
Die Nachteile eines solchen Programmierstils ohne Prozedurabstraktionen dürften klar auf der Hand liegen. Hier können sich bei jeder Mengenoperation Fehler einschleichen, weil immer wieder maschinennahe Operationen verwendet werden. Überdies sind solche
Programme nur mit ausführlichen Kommentaren lesbar. Bei Kommentaren kann man sich aber nie sicher sein, ob sie wirklich den aktuellen Stand des Programms wiedergeben.
Schon deshalb dürfte es vernünftiger sein, eine lesbare Version eines Programms zu verfassen. Die nächste Version zeigt, wie man in nicht objektorientierten Sprachen abstrakte Datentypen realisieren kann (indem sie das Klassenkonzept von C++ ignoriert):
////////////////////////////////////////////////////////////////////
// BITSET1.CPP //
// Realisierung eines abstrakten Datentyps //
// mit konventionellen Mitteln //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#define INT_BITS 16
#ifndef BOOLEAN // Definition des Datentyps boolean
#define BOOLEAN enum {false, true}; typedef int boolean;
#endif
///////////////// Definition des Datentyps bitset: ///////////////// typedef unsigned bitset;
////////////////// Zugriffsfunktionen von bitset: ////////////////// void loesche (bitset& s) {s = 0;}
void incl (bitset& s, int i) {s |= 1<<i;} void excl (bitset& s, int i) {s &= ~(1<<i);} boolean enthaelt (bitset s, int i) {return ((s & (1<<i)) > 0);} void vereinige (bitset& s, bitset s1){s |= s1;} void schneide (bitset& s, bitset s1) {s &= s1;} void komplementiere (bitset& s) {s = ~s;} void zeige (bitset s) {
cout << "{";
for (int i=0; i<INT_BITS; i++) cout << (enthaelt (s, i) ? "+" : "-");
cout << "}\n";
}
///////////////////// Test der Zugriffsmethoden: /////////////////// main () {
bitset a,b,c;
loesche (a);
loesche (b);
for (int i=0; i<8; i++) incl (a, i);
for (i=4; i<12; i++) incl (b, i);
cout << "\n"; // neue Zeile
cout << " a = "; zeige (a);
cout << " b = "; zeige (b);
c = a;
vereinige (c, b);
cout << "Vereinigung von a und b = "; zeige (c);
c = a;
schneide (c, b);
cout << "Durchschnitt von a und b = "; zeige (c);
komplementiere (c);
cout << "Komplement davon = "; zeige (c);
}
Wir haben auf die Variablen vom Typ bitset
hier diszipliniert zugegriffen. Das
Geheimnisprinzip ist allerdings nicht verwirklicht: bitset
ist als unsigned
deklariert und kann beliebig manipuliert werden.
Nun sehen Sie das gleiche Programm noch einmal mit einer Klasse, in der die Daten vor direktem Zugriff verborgen sind:
////////////////////////////////////////////////////////////////////
// BITSET2.CPP //
// Verwendung von Klassen //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#define INT_BITS 16
#ifndef BOOLEAN // Definition des Datentyps boolean
#define BOOLEAN enum {false, true}; typedef int boolean;
#endif
/////////////////// Definition der Klasse bitset: ////////////////// class bitset { public:
void loesche ();
void incl (int i); // Element einfügen
void excl (int i); // Element entfernen
void vereinige (bitset s); // Mengenvereinigung mit s bilden
void schneide (bitset s); // Mengendurchschnitt mit s bilden
void komplementiere (); // Mengenkomplement bilden
boolean enthaelt (int i); // ist i Element?
void zeige (); private:
unsigned bits;
};
//////////////// Implementierung der Klasse bitset: //////////////// void bitset::loesche () {bits = 0;} void bitset::incl (int i) {bits |= 1<<i;} void bitset::excl (int i) {bits &= ~(1<<i);} boolean bitset::enthaelt (int i) {return ((bits & (1<<i)) > 0);} void bitset::vereinige (bitset s) {bits |= s.bits;} void bitset::schneide (bitset s) {bits &= s.bits;} void bitset::komplementiere () {bits = ~bits;} void bitset::zeige () {
cout << "{";
for (int i=0; i<INT_BITS; i++) cout << (enthaelt (i) ? "+" : "-");
cout << "}\n";
}
////////////////////// Test der Klasse bitset: ///////////////////// main () {
bitset a,b,c;
a.loesche ();
b.loesche ();
for (int i=0; i<8; i++) a.incl (i);
for (i=4; i<12; i++) b.incl (i);
cout << " a = "; a.zeige ();
cout << " b = "; b.zeige ();
c = a;
c.vereinige (b);
cout << "Vereinigung von a und b = "; c.zeige ();
c = a;
c.schneide (b);
cout << "Durchschnitt von a und b = "; c.zeige ();
c.komplementiere ();
cout << "Komplement davon = "; c.zeige ();
}
Wenn nun der private Teil der Klasse bitset
(hier nur die Variable bits
) geändert und die Implementierung entsprechend angepaßt wird, kann die Anwendung der Klasse (hier die
Funktion main
) völlig unverändert weiterverwendet werden. Ein realistischeres Beispiel für diese Trennung zwischen abstrakter Beschreibung und konkreter Implementierung eines
Datentyps finden Sie im Projektteil dieses Buchs (Abschnitt 4.6). Hier wird für einen
Funktionsinterpreter eine Symboltabelle benötigt. Der hierfür beschriebene Datentyp wird alternativ als lineare Liste, und als AVL-Baum implementiert.
Selbstdefinierte Klassen bringen uns dem Ziel, mit eigenen Datentypen ebenso unkompliziert umgehen zu können, wie mit den Standard-Datentypen, einen großen Schritt näher. Bisher fehlen uns aber bei eigenen Datentypen noch ein paar Möglichkeiten.
Ein Problem ist die dynamische Speicherplatzverwaltung. Wenn Sie eine Variable
definieren, wird beim Eintritt des Programms in den Gültigkeitsbereich der Variablen
Speicherplatz bereitgestellt und beim Verlassen des Gültigkeitsbereichs automatisch wieder freigegeben. Das war bei unserer bitset
-Klasse kein Problem, weil der benötigte
Speicher direkt als Datenelement deklariert wurde. Bei Objekten, deren Speicherbedarf nicht immer gleich ist, muß man aber zusätzlichen Speicher anfordern und darf am Ende nicht vergessen, diesen Speicher wieder freizugeben. Das folgende Turbo Pascal-
Programm definiert zwei Strings und führt eine einfache Operation damit aus: var
s: string[21];
t: string[20]; begin
s := 'Einsturzgefahr';
t := ' Computer ist ';
writeln (s);
writeln (t);
insert (t, s, 4);
writeln (s); end.
Wir wollen jetzt versuchen, eine Klasse zu definieren, die die Turbo Pascal-Strings nachbildet. Ein String hat in Turbo Pascal eine Maximallänge, die bei der Deklaration in eckigen Klammern angegeben wird. Es wäre Speicherplatzverschwendung, wenn wir pauschal 256 Bytes reservieren würden. Stattdessen verwenden wir einen Zeiger, dem der wirklich benötigte Speicher zur Verfügung gestellt wird.
C++ erlaubt es, zu jeder Klasse einen oder mehrere Konstruktoren zu definieren. Ein
Konstruktor ist eine Methode, die automatisch aufgerufen wird, wenn das Programm in den
Gültigkeitsbereich eines Objekts eintritt. Um verschiedenartige Objekte einer Klasse definieren zu können, darf der Konstruktor auch eine nichtleere Parameterliste haben. In unserem Beispiel könnten wir einen Parameter verwenden, in dem die Maximallänge des
Strings angegeben wird.
Wie wird nun ein Konstruktor definiert? Formal ist er eine Elementfunktion ohne
Funktionswert, deren Name der Klassenname ist. Allerdings wird bei einem Konstruktor auch der Pseudo-Typ void
weggelassen, der sonst für einen nicht vorhandenen
Funktionswert angegeben wird. Sie sehen hier eine (unvollständige) String-Klasse mit einem Konstruktor: class string { public:
string (int n = 255); // Konstruktor
...
private:
char *s;
int aktLg;
int maxLg;
};
Hier hat der Konstruktor ein Argument mit einem Standardwert. Genau genommen, haben
wir damit zwei Konstruktoren definiert, nämlich einen ohne Argument und einen mit einem int
-Argument. Damit können wir Strings fast wie in Pascal definieren. Die Definition string s1, s2 (20); soll einen String s1
der Maximallänge 255 (Standard) und einen String s2
der
Maximallänge 20 erzeugen. Die Implementierung könnte so aussehen: string::string (int n) {
if (n < 1) n = 1; // Maximallänge 0 sinnlos
else if (n > 255) n = 255;
aktLg = 0;
maxLg = n;
s = new char[n+1];
}
Unsere Implementierung unterscheidet sich von der in Turbo Pascal darin, daß die Länge in einem int
-Element gespeichert wird. Deshalb sind wir hier nicht an die Beschränkung der
Maximallänge auf 255 gebunden.
Wenn wir bei der Implementierung die Bibliotheksfunktionen zur String-Verarbeitung verwenden wollen, müssen wir dem String noch ein Nullbyte anhängen können. Deshalb werden in der letzten Zeile n+1
Bytes reserviert.
Mit dem Bereitstellen des Speicherplatzes allein ist es allerdings nicht getan. Wir müssen auch dafür sorgen, daß beim Verlassen des Gültigkeitsbereichs des Objekts der dynamische Speicher wieder freigegeben wird. Hierfür dient ein Destruktor. Ein
Destruktor wird wie ein Konstruktor geschrieben, nur wird das Tilde-Zeichen (
~
) davorgestellt. Destruktoren dürfen keine Parameter haben. Da unser Konstruktor der string
-Klasse zusätzlichen Speicherplatz anfordert, benötigen wir einen Destruktor, der den Speicher wieder freigibt. Es folgt ein komplettes Programm, in dem exemplarisch die
String-Prozedur insert
wie in Turbo Pascal implementiert ist:
////////////////////////////////////////////////////////////////////
// //
// TPSTRING.CPP - Turbo Pascal-Strings in C++ //
// Beispiel für die Verwendung von Konstruktoren und Destruktoren //
// //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#include <string.h> class string { public:
string (int n = 255); // Konstruktor
~string (); // Destruktor
int length ();
void insert (string& Einfg, int Index);
void setze (char *s);
void writeln (); private:
char *s;
int aktLg;
int maxLg;
}; string::string (int n) {
if (n < 1) n = 1;
else if (n > 255) n = 255;
aktLg = 0;
maxLg = n;
s = new char[n+1];
} string::~string () {
delete s;
} int string::length () {
return aktLg;
} void string::insert (string& Einfg, int Index) {
int AnfLg; // Länge des Anfangsstücks (bleibt stehen)
int EndLg; // Länge des Endstücks (wird verschoben)
int EinfgLg; // Länge des einzufügenden Strings
AnfLg = Index - 1; // Indizierung in Pascal ab 1
if (AnfLg < 0) AnfLg = 0; // Fehler abfangen
else if (AnfLg > aktLg) AnfLg = aktLg; // dto.
EinfgLg = Einfg.length ();
// falls Einfg keinen Platz hat: beschneiden:
if (AnfLg + EinfgLg > maxLg) EinfgLg = maxLg - AnfLg;
EndLg = aktLg - AnfLg;
// falls verschobenes Endstück keinen Platz hat: beschneiden:
if (AnfLg + EinfgLg + EndLg > maxLg) EndLg = maxLg - AnfLg - EinfgLg;
memmove (s+AnfLg+EinfgLg, s+AnfLg, EndLg); // Ende aufrücken
memmove (s+AnfLg, Einfg.s, EinfgLg); // Einfg in Lücke kopieren
aktLg = AnfLg + EinfgLg + EndLg;
} void string::setze (char *source) {
int n = strlen (source);
if (n > maxLg) n = maxLg; // notfalls beschneiden
memmove (s, source, n);
aktLg = n;
} void string::writeln () {
s[aktLg] = '\0';
cout << s << '\n';
} main () {
string s (21), t (20);
s.setze ("Einsturzgefahr");
t.setze (" Computer ist ");
cout << "\n";
s.writeln ();
t.writeln ();
s.insert (t, 4);
s.writeln ();
}
Wenn Sie eine Klasse ohne Konstruktor definieren, erzeugt C++ automatisch einen
Konstruktor ohne Argumente, den
Standard-Konstruktor. Dieser Standard-Konstruktor tut einfach das, was Sie von Pascal her gewohnt sind: Den Datenelementen wird beim
Eintritt des Programms in den Gültigkeitsbereich des Objekts der entsprechende
Speicherplatz bereitgestellt. Dabei werden die Daten nicht initialisiert, sondern haben unvorhersagbare Anfangswerte. Wenn Sie wollen, daß Objekte automatisch einen bestimmten Anfangswert bekommen, können Sie dafür einen eigenen Konstruktor definieren, der die Elemente nach Wunsch initialisiert.
Wenn Sie keinen Destruktor definieren, wird der Standard-Destruktor verwendet, der einfach nur die direkten Datenelemente beseitigt. Sobald Sie aber irgendeinen
Konstruktor definieren, ist der Standard-Konstruktor nicht mehr verfügbar.
Noch ein zweiter Typ von Konstruktoren wird vom Compiler automatisch erzeugt, wenn Sie ihn nicht explizit definieren, nämlich der Kopier-Konstruktor. Der Kopier-Konstruktor dient der Initialisierung eines Objekts mit dem Wert eines anderen Objekts der gleichen
Klasse. Wenn Definitionen, wie int a = 7; int b = a; möglich sind, warum sollte man dann nicht auch string s (20); string t = s; schreiben dürfen? Sie dürfen es, nur ist die Wirkung nicht so, wie sie sein sollte. Da wir in der string
-Klasse keinen Kopier-Konstruktor definiert haben, wird hier der Standard-
Kopier-Konstruktor verwendet. Dieser erzeugt eine Kopie aller Datenelemente. Das bedeutet aber, daß auch der Zeiger s.s
nach t.s
kopiert wird. Damit teilen sich die
Strings s
und t
den gleichen Speicherplatz. Das hat recht unangenehme Folgen: Wenn ein
String geändert wird, wird der Inhalt des anderen mitgeändert, die Länge ( aktLg
) aber nicht. Außerdem wird später beim Aufruf der Destruktoren versucht, den gleichen
Speicherplatz zweimal freizugeben.
Hier müssen wir also einen Kopier-Konstruktor definieren. Ist
X
eine Klasse, so hat der
Kopier-Konstruktor die Form
X (const X&)
. Wir ergänzen also in der Klassendefinition die Zeile
string (const string& t);
Wie sieht nun die Implementierung aus? Hätten wir keinen Kopier-Konstruktor definiert, würde automatisch einer erzeugt, der alle Datenelemente kopiert: string::string (const string& t) {
maxLg = t.maxLg;
aktLg = t.aktLg;
s = t.s;
}
Wir wollen aber, daß die Kopie eigenen Speicherplatz für einen String bekommt und schreiben daher: string::string (const string& t) {
maxLg = t.maxLg;
aktLg = t.aktLg;
s = new char[maxLg+1];
memmove (s, t.s, aktLg);
}
Wir haben gesehen, daß bei der Definition eines Objekts dem Konstruktor Parameter
übergeben werden können. In der Definitions-Anweisung string s (20); wird dem Konstruktor für die Klasse string
der aktuelle Parameter 20 übergeben. Solche
Parameterübergaben sind in C++ nur in einfachen Definitionen möglich, nicht aber in
Vektordefinitionen. Vektoren von Objekten können deshalb nur definiert werden, wenn für die Klasse entweder gar kein Konstruktor oder ein Konstruktor ohne Parameter definiert wurde. Im ersten Fall wird für jedes Element der Standard-Konstruktor verwendet.
Die Definitions-Anweisung string s[10]; erzeugt einen Vektor aus 10 Elementen vom Typ string
. Dabei wird 10 mal der
Konstruktor ohne Argumente aufgerufen. In unserer Implementierung wird also für jeden
String die maximale Länge von 255 Bytes reserviert.
Wenn wir einen Vektor aus 10 Strings mit der Maximallänge 20 benötigen, ist das mit unserer string
-Klasse nicht möglich. In solchen Fällen muß man die Klasse anders konzipieren. Wir könnten etwa die Reservierung des Speicherplatzes für den String aus dem Konstruktor entfernen und hierfür eine separate Elementfunktion einführen: class string { // Alternative für Vektoren von Strings public:
initialisiere (int n = 255);
~string ();
...
private:
char *s;
int aktLg;
int maxLg;
}; int main () {
string S[10];
for (int i=0; i<10; i++) S[i].initialisiere (20);
...
}
Damit gehen allerdings die Vorteile des Konstruktors verloren. Jetzt besteht die Gefahr, daß die Initialisieruung versehentlich mehrfach aufgerufen wird. Der Destruktor der
Klasse string
kann unverändert weiterverwendet werden.
Manchmal ist es sinnvoll, Funktionen mit Argumenten einer Klasse außerhalb der Klasse zu definieren. Unsere Bitset-Klasse bietet zum Beispiel eine Methode void bitset::schneide (bitset s);
Hier wird einer Menge der Durchschnitt dieser Menge mit einer anderen Menge zugewiesen. Manchmal wäre aber auch eine Funktion sinnvoll, die den Durchschnitt zweier
Mengen als Ergebnis zurückgibt. Man könnte auch hier eine Methode bitset bitset::Durchschnitt (bitset s); innerhalb der Klasse definieren. Wollen wir den Durchschnitt d zweier Bitsets a und b berechnen, könnten wir wahlweise d = a.Durchschnitt (b); oder d = b.Durchschnitt (a); schreiben. Diese Asymmetrie ist hier ästhetisch unbefriedigend, denn beide Argumente sind ja völlig gleichberechtigt. Deshalb bietet sich hier eine Funktion außerhalb der Klasse an, die zwei Klassenargumente annimmt. bitset Durchschnitt (bitset a, bitset b) { // Durchschnitt
bitset s;
s.bits = a.bits & b.bits;
return s;
}
Leider ist das aber so nicht möglich, weil das Element bits
privat ist. Würde man aber deshalb bits
als public
erklären, hieße das, auf alle Vorteile des Geheimnisprinzips zu verzichten.
C++ bietet hier die Möglichkeit, gezielte Ausnahmen der Privatheit zu erklären: Eine Klasse kann einer Funktion außerhalb der Klasse den Zugriff auf ihre privaten Elemente erlauben.
Hierzu wird innerhalb der Klassendefinition eine Deklaration der zugriffsberechtigten
Funktion eingefügt und das Schlüsselwort friend
davorgestellt. In unserem Beispiel sähe das so aus: class bitset {
...
friend bitset Durchschnitt (bitset a, bitset b);
...
};
Es ist auch erlaubt, eine andere Klasse als friend
zu erklären: class A {
..
friend class B;
...
};
Mit dieser Erklärung haben alle Funktionen der Klasse B das Zugriffsrecht auf die privaten
Elemente der Klasse A.
Es gibt keine Vorschrift, an welcher Stelle in einer Klassendefinition die friend
-
Erklärungen stehen sollen. Da dies Informationen sind, die für den Benutzer der Klasse unwesentlich sind, empfiehlt es sich, sie im privaten Teil der Klassendefinition unterzubringen.
Zwei Klassen können sich auch gegenseitig das Zugriffsrecht geben. Hier muß die Klasse, die im Text zuerst erscheint, die andere Klasse als Freund erklären. Da die andere Klasse hier aber noch nicht definiert ist, wird eine vorherige Deklaration der zweiten Klasse benötigt, wie das folgende konstruierte Beispiel zeigt: class LONG; // Deklaration der Klasse LONG class INT { public:
void addiere (LONG l); private:
friend class LONG;
int n;
}; class LONG { // Definition der Klasse LONG public:
void addiere (INT i); private:
friend class INT;
long n;
}; void INT::addiere (LONG l) {n += int (l.n);} void LONG::addiere (INT i) {n += long (i.n);}
Sie wissen bereits, daß in C++ Funktionen überladen werden dürfen. Da Operatoren nur eine andere Schreibweise für Funktionen sind, ist es naheliegend, auch das Überladen von
Operatoren zu erlauben. Der Compiler kommt ja ohnehin schon mit den vordefinierten arithmetischen Operatoren zurecht, die je nach Datentyp völlig verschiedene Funktionen aufrufen.
C++ erlaubt das
Überladen von Operatoren, allerdings mit zwei Einschränkungen:
•
•
Es dürfen nur die in der Sprache schon vorhandenen Operatoren (bis auf wenige
Ausnahmen) überladen werden.
Mindestens ein Argument des Operators muß ein Klassentyp sein.
Bei der Deklaration überladener Operatoren wird der Operator formal wie eine gewöhnliche Funktion dargestellt, indem ihm das Schlüsselwort operator
vorangestellt wird. Wenn Sie etwa eine Klasse
Zahl
(z. B. für große Zahlen) definieren, können Sie die
Summenfunktion für diesen Typ so deklarieren:
Zahl operator + (Zahl z1, Zahl z2);
Verwendet wird dieser überladene Operator dann wie üblich.
Es gibt zwei verschiedene Möglichkeiten, überladene Operatoren zu definieren:
•
•
Definition außerhalb der Klasse
Falls der Operator auf ein privates Element der Klasse zugreift (das ist der
Normalfall!), muß die Klasse ihn als friend
deklarieren.
Definition innerhalb der Klasse
Überladene Operatoren können auch als Elementfunktion definiert werden, falls das erste Argumet eine Referenz auf ein Objekt der Klasse ist. In diesem Fall wird das erste Argument nicht explizit angegeben.
Im folgenden (sinnlosen) Beispiel sehen Sie beide Arten der Definition von überladenen
Operatoren: class Zahl { public:
void operator += (Zahl b); private:
friend void operator -= (Zahl& a, Zahl b);
friend Zahl operator + (Zahl a, Zahl b);
int n;
};
Der Operator
+=
ist innerhalb der Klasse definiert,
-=
außerhalb der Klasse. Hier sollen die verschiedenen Möglichkeiten demonstriert werden. In der Praxis sollte man natürlich einen einheitlichen Stil wählen.
Für den Operator
+
haben wir keine andere Wahl, als ihn außerhalb der Klasse zu definieren, weil sein erstes Argument keine Referenz auf die Klasse ist.
Es folgt die Implementierung der Klasse: void Zahl::operator += (Zahl b) {n += b.n;} void operator -= (Zahl& a, Zahl b) {a.n -= b.n;}
Zahl operator + (Zahl a, Zahl b) {
Zahl s;
s.n = a.n + b.n;
return s;
}
Wir setzen die neu gewonnenen Möglichkeiten nun für eine abschließende Version der
Klasse bitset
ein. Dies sind die Neuerungen:
•
•
•
Ein Konstruktor (ohne Argumente) sorgt dafür, daß jedes Objekt mit der leeren
Menge initialisiert wird.
Es werden implizite Typkonvertierungen von int
nach bitset
und umgekehrt eingeführt.
Die von Pascal gewohnten Operatoren für Vereinigung, Durchschnitt und
Differenzmenge werden übernommen und durch weitere naheliegende Operatoren für das Einfügen und Löschen von Elementen, Komplement, und Elementrelation ergänzt. Den Pascal-Operator in
können wir nicht übernehmen, weil in C++ leider keine neuen Operatorsymbole eingeführt werden können. Stattdessen überladen wir hier das Kleiner-Zeichen (
<
).
In der Implementierung werden die Mengenoperationen durch die entsprechenden Bit-
Operatoren ersetzt.
Als praktische Anwendung dient die Funktion
TastenDemo
, in der der Zustand der
Umschalttasten, der Strg- (Ctrl-) und der Alt-Taste auf einem IBM-kompatiblen PC abgefragt wird. Eigentlich sollte hierzu die entsprechende BIOS-Routine aufgerufen werden (in Borland C++ auch unter bioskey (2)
verfügbar), um Portabilität zu garantieren. Hier soll aber ein konkretes Beispiel für Referenzen und für volatile
-
Variablen gegeben werden. Deshalb wird die Speicherstelle 417H abgefragt, die ständig in ihren einzelnen Bits die zur Zeit gedrückten Umschalttasten wiedergibt.
FlagPtr
ist ein
Zeiger auf diese Speicherstelle. Die Adresse wird mit der Funktion
MK_FP
("
M a
K e
F ar
P ointer") aus Segment und Offset gebildet: bitset far *FlagPtr = (bitset far *) MK_FP (0x0040, 0x17);
Das Attribut far
bewirkt, daß auch bei einem kleinen Datenmodell hier eine vollständige
Adressierung (mit Segment und Offset) vorgenommen wird. Um die Dereferenzierung dieses Zeigers zu verbergen, definieren wir eine bitset
-Referenz auf diese Adresse: volatile bitset far &Flags = *FlagPtr;
Die Variable
Flags
ändert sich bei Tastendrücken außerhalb der Kontrolle unseres
Programms (durch eine Interrupt-Routine). Dies könnte dem Programm entgehen, wenn der
Compiler das Programm optimiert und den Wert in ein Prozessor-Register kopiert. Deshalb muß mit dem Attribut volatile
verhindert werden, daß der Compiler erneutes Lesen aus
dem Hauptspeicher wegoptimiert.
////////////////////////////////////////////////////////////////////
// BITSET3.CPP //
// Mit Konstruktoren, und Überladen von Operatoren //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#include <dos.h> // nur für Tastendemo ()
#define INT_BITS 16
#ifndef BOOLEAN
#define BOOLEAN enum {false, true}; typedef int boolean;
#endif class bitset { public:
bitset (); // Konstruktor (Initialisierung)
void operator += (int i); // Element einfügen
void operator -= (int i); // Element entfernen
boolean leer (); // Leere Menge private:
friend boolean operator < (int i, bitset s); // Element
friend bitset operator + (bitset s1, bitset s2); // Vereinigung
friend bitset operator * (bitset s1, bitset s2); // Durchschnitt
friend bitset operator - (bitset s1, bitset s2); // Mengendiff.
friend boolean operator < (bitset s1, bitset s2); // Teilmenge
friend bitset operator ~ (bitset s1); // Komplement
friend ostream& operator << (ostream& s, bitset b);
unsigned bits;
}; inline bitset::bitset () {bits = 0;} inline void bitset::operator += (int i) {bits |= 1<<i;} inline void bitset::operator -= (int i) {bits &= ~(1<<i);} inline boolean bitset::leer () {return bits == 0;} boolean operator < (int i, bitset s) { // Element
return ((s.bits & (1<<i)) > 0);
} bitset operator + (bitset s1, bitset s2) { // Vereinigung
bitset s;
s.bits = s1.bits | s2.bits;
return s;
} bitset operator * (bitset s1, bitset s2) { // Durchschnitt
bitset s;
s.bits = s1.bits & s2.bits;
return s;
} bitset operator - (bitset s1, bitset s2) { // Mengendifferenz
bitset s;
s.bits = s1.bits & ~s2.bits;
return s;
} boolean operator < (bitset s1, bitset s2) { // Teilmenge
return (s1.bits & ~s2.bits) == 0; // s1-s2 = Ý
} bitset operator ~ (bitset s1) { // Komplement
bitset s;
s.bits = ~s1.bits;
return s;
} ostream& operator << (ostream& s, bitset b) {
s << "{";
for (int i=0; i<INT_BITS; i++) s << ((i < b) ? "|" : "·");
s << "}";
return s;
} void BitsetDemo () {
bitset a,b,c;
for (int i=2; i<10; i++) a += i;
for (i=6; i<14; i++) b += i;
cout << " a = " << a << "\n";
cout << " b = " << b << "\n";
c = a + b; cout << "a + b = " << c << "\n";
c = a * b; cout << "a * b = " << c << "\n";
c = a - b; cout << "a - b = " << c << "\n";
c = ~ a; cout << " ~a = " << c << "\n";
} void TastenDemo () {
enum shiftstate {
RShift, LShift, Ctrl, Alt, ScrollLock, NumLock, CapsLock, Insert
};
bitset far *FlagPtr = (bitset far *) MK_FP (0x0040, 0x17);
volatile bitset far &Flags = *FlagPtr;
bitset alteFlags, neueFlags, AbbruchFlags, gesuchteFlags;
cout << "\n Anzeige der Tasten Shift, Ctrl, Alt "
"(Ende: beide Shift-Tasten)\n";
gesuchteFlags += RShift;
gesuchteFlags += LShift;
gesuchteFlags += Ctrl;
gesuchteFlags += Alt;
AbbruchFlags += RShift;
AbbruchFlags += LShift;
do {
neueFlags = Flags;
neueFlags = neueFlags * gesuchteFlags;
if (neueFlags != alteFlags) {
alteFlags = neueFlags;
cout << "\ngedrückte Umschalttasten: ";
if (neueFlags.leer ()) cout << "-----";
if (RShift < neueFlags) cout << "RShift ";
if (LShift < neueFlags) cout << "LShift ";
if (Ctrl < neueFlags) cout << "Ctrl ";
if (Alt < neueFlags) cout << "Alt ";
}
}
while (! (AbbruchFlags < neueFlags));
} main () {
BitsetDemo ();
TastenDemo ();
}
•
Allgemeines
Beim Überladen von Operatoren haben Sie die Freiheit, jedem Operator im
Zusammenhang mit einer Klasse eine beliebige Bedeutung zu geben. Dabei sollten aber in jedem Fall Erwartungen berücksichtigt werden, die sich aus der üblichen
•
•
•
•
•
•
Bedeutung des Operators aufdrängen. So kann es sinnvoll sein, den Operator
+
wie in
Pascal für die Verkettung von Strings oder für die Vereinigung von Mengen zu verwenden. Wenn keine naheliegende Analogie zur üblichen Verwendung eines
Operators besteht, sollte man lieber auf Operatoren verzichten und eine sinnvoll benannte Funktion verwenden.
Der Zuweisungoperator (
=
)
Wenn der Zuweisungsoperator nicht überladen wird, wird ein Standard-Mechanismus verwendet: Alle Datenelemente werden einzeln zugewiesen. Das ist oftmals nicht das gewünschte, nämlich meist dann, wenn die Klasse Zeigerelemente enthält. Wenn statt der Zeiger die Daten kopiert werden sollen, auf die die Zeiger zeigen, muß der
Zuweisungsoperator überladen werden.
Die arithmetischen Zuweisungsoperatoren (
+=
,
-= usw.)
Diese Operatoren ergeben sich nicht automatisch aus den entsprechenden arithmetischen Operatoren und dem Zuweisungsoperator: Wenn Sie
=
und
+
überladen haben, müssen Sie den Operator
+=
trotzdem explizit überladen, wenn Sie ihn verwenden wollen. Trotz dieser Unabhängigkeit sollten Sie diese Operatoren so implementieren, daß sie die Kombination des arithmetischen Operators und der
Zuweisung darstellen.
Die Inkrementierungsoperatoren (
++
,
--
)
Diese Operatoren können nach dem Überladen sowohl als Präfix- wie auch als
Postfix-Operatoren verwendet werden. Die Wirkung ist aber in beiden Fällen die gleiche.
Die Gleichheitsrelation (
==
) und die Ungleichheitsrelation (
!=
)
Im Gegensatz zum Zuweisungsoperator gibt es keine Standard-Behandlung der
Gleichheits- bzw. Ungleichheitsrelation. Wenn Sie Klassenobjekte auf Gleichheit oder Ungleichheit prüfen möchten, müssen Sie diese Operatoren überladen, es sei denn, Sie definieren einen Konvertierungsoperator in einen Standard-Datentyp, und der Vergleich der konvertierten Werte liefert das von Ihnen gewünschte Ergebnis.
Der Ungleichheitsoperator ist auch nicht automatisch das logische Gegenteil des
Gleichheitsoperators. Es müssen also, wenn sie benötigt werden, stets beide
Operatoren überladen werden.
Der Vektorklammer-Operator (
[]
)
Dieser Operator wird verwendet, um bei selbstdefinierten Vektor- oder
Matrixtypen die übliche Schreibweise anwenden zu können. Der Operator
[]
ist ein zweistelliger Operator, der aber in seiner Syntax etwas aus dem Rahmen fällt.
Während die üblichen zweistelligen Operatoren zwischen den beiden Operanden stehen, verteilt sich der
[]
-Operator: das Zeichen
[
steht zwischen den
Operanden, das Zeichen
]
steht hinter dem zweiten Operanden.
Wenn Sie den Operator sowohl in Ausdrücken wie a = b[i]
, als auch in Ausdrücken wie b[i] = a
anwenden wollen, muß die Operatorfunktion eine Referenz zurückgeben ( b[i]
muß ein L-Wert sein)! Im folgenden Abschnitt finden Sie hierzu ein Beispiel, in dem große Vektoren auf eine temporäre Datei ausgelagert werden.
Es ist wichtig, daß Sie sich den Unterschied zwischen Zuweisungen und Initialisierungen klarmachen: Bei der
Zuweisung bekommt ein schon initialisiertes Objekt einen neuen Wert, bei der Initialisierung wird ein neues Objekt mit Hilfe des Kopier-Konstruktors erst erzeugt. Bei den Standard-Datentypen ist der Unterschied unerheblich: int i = 1; // i wird mit 1 initialisiert int j; // j wird nicht initialisiert (unbestimmter Wert) j = 2; // jetzt bekommt j den Wert 2;
Ganz anders sieht es aber oft bei Klassentypen aus. Betrachten wir nochmals die string
-
Klasse. Das Problem der Initialisierung durch einen anderen String haben wir mit Hilfe des
Kopier-Konstruktors gelöst. Für den neuen String wird Speicherplatz bereitgestellt und der Inhalt des anderen Strings kopiert. Bei einer Zuweisung eines Strings an einen anderen haben beide schon ihren eigenen Speicher. Jetzt sollte nur der Inhalt kopiert werden. In der bisherigen Form der String-Klasse funktioniert das aber nicht, weil der Standard-
Mechanismus für die Zuweisung verwendet wird: Es wird einfach das Zeiger-Element s kopiert mit den oben diskutierten Folgen. Deshalb muß hier auch die Zuweisung überladen werden: class string { public:
string& operator = (string& source);
...
}; string& string::operator = (string& source) {
int n = source.aktLg;
if (n > maxLg) n = maxLg;
memmove (s, source.s, n);
aktLg = n;
return *this;
}
Hier liefert die Zuweisung eine Referenz auf das Zugewiesene zurück, wie dies in C++
üblich ist. Nur so können auch überladene Zuweisungen verkettet werden.
Zum Vergleich folgt nochmals die Implementierung des Kopier-Konstruktors: string::string (const string& t) {
maxLg = t.maxLg;
aktLg = t.aktLg;
s = new char[maxLg+1];
memmove (s, t.s, aktLg);
}
Die Zuweisung ähnelt zwar sehr dem Kopier-Konstruktor. Beide kopieren nicht das Zeiger-
Element sondern die Daten, auf die es zeigt. Während aber der Kopier-Konstruktor erst ein Objekt erzeugen muß, existiert das Objekt bei der Zuweisung schon.
Bei der Division zweier ganzer Zahlen erhält man rationale Zahlen. Näherungsweise läßt sich eine rationale Zahl durch eine Gleitkommazahl darstellen. Die Darstellung wird aber ungenau, wenn die Gleitkomma-Darstellung periodisch ist. Es folgen einige Beispiele: float x = 1/3; // Rundungsfehler, weil 1/3 periodischer Dualbruch float y = 1/4; // kein Fehler float z = 1/5; // Rundungsfehler, weil 1/5 periodischer Dualbruch
Bei der dritten Zuweisung ergibt sich im Dezimalsystem ein abbrechender Bruch (0,2). Im
Computer wird die Zahl jedoch im Dualsystem dargestellt und ergibt den periodischen
Dualbruch 0,00110011...
In manchen Anwendungen (zum Beispiel in der Computergrafik bei der Speicherung von
Schnittpunkten zweier Geraden) wird eine exakte Darstellung von rationalen Zahlen benötigt. Für solche Anwendungen wollen wir eine Klasse rational
entwickeln, die rationale Zahlen exakt darstellt, indem sie Zähler und Nenner speichert. Das erschwert natürlich die arithmetischen Operationen etwas.
Zähler und Nenner werden in den privaten Elementen p
und q
vom Typ long
gespeichert.
Zunächst sehen Sie die Header-Datei:
/////////////////////////// RATIONAL.H /////////////////////////////
// //
// Arithmetik mit rationalen Zahlen //
// //
////////////////////////////////////////////////////////////////////
#ifndef RATIONAL_H
#define RATIONAL_H
#include <iostream.h> class rational { public:
rational (long Zaehler = 0, long Nenner = 1); private:
friend rational operator - (rational r); // unäres Minus
friend rational operator + (rational r1, rational r2);
friend rational operator - (rational r1 , rational r2);
friend rational operator * (rational r1, rational r2);
friend rational operator / (rational r1, rational r2);
friend rational& operator += (rational& r, rational r2);
friend rational& operator -= (rational& r, rational r2);
friend rational& operator *= (rational& r, rational r2);
friend rational& operator /= (rational& r, rational r2);
friend int operator == (rational& r1, rational& r2);
friend int operator != (rational& r1, rational& r2);
friend ostream& operator << (ostream& s, rational r);
long p, q;
};
#endif RATIONAL_H
Nun folgt die Implementierung der Klasse rational
. Hier wird ein möglicher Überlauf bei der Arithmetik mit long
-Zahlen ignoriert. In Borland C++ gibt es keine Möglichkeit,
Bereichsüberschreitungen bei Ganzzahltypen abzufangen. Man könnte beispielsweise die
long
-Multiplikation durch folgende Funktion ersetzen: long Produkt (long a, long b) {
if (LONG_MAX / a < b) {
cerr << "\nÜberlauf bei Rational-Arithmetik.\n";
exit (1);
}
else return a * b;
}
Das ginge allerdings sehr auf Kosten der Effizienz. Für den praktischen Einsatz könnte man auch eine Assembler-Routine für die Multiplikation schreiben, die das Überlauf-Flag des Prozessors abfragt.
Der Konstruktor bringt die rationale Zahl auf eine eindeutige
Normalform: Zähler und
Nenner sind gekürzt, und der Nenner ist positiv. Auch alle Operatoren bringen ihre
Ergebnisse immer in diese Normalform. Das ist die Voraussetzung für die einfache
Implementierung des Operators
==
. Zum Kürzen wird der bekannte Euklidische
Algorithmus verwendet.
////////////////////////// RATIONAL.CPP ////////////////////////////
// //
// Arithmetik mit rationalen Zahlen //
// //
////////////////////////////////////////////////////////////////////
#include "rational.h"
#include <stdlib.h> long ggT (long a, long b) { // Euklidischer Algorithmus,
while (b > 0) { // Voraussetzung: a, b >= 0
long m = a % b;
a = b;
b = m;
}
return a;
} rational::rational (long Zaehler, long Nenner) {
p = Zaehler;
q = Nenner;
if (q < 0) {p = -p; q = -q;} // Nenner immer positiv
long t = ggT (labs (p), q);
if (t > 1) {p /= t; q /= t;} // kürzen, falls möglich
} rational operator - (rational r) {
r.p = -r.p;
return r;
} rational operator + (rational r1, rational r2) {
return rational (r1.p * r2.q + r2.p * r1.q, r1.q * r2.q);
} rational operator - (rational r1, rational r2) {
return r1 + (-r2);
} rational operator * (rational r1, rational r2) {
return rational (r1.p * r2.p, r1.q * r2.q);
}
rational operator / (rational r1, rational r2) {
return rational (r1.p * r2.q, r1.q * r2.p);
} rational& operator += (rational& r1, rational r2) {
r1 = r1 + r2;
return r1;
} rational& operator -= (rational& r1, rational r2) {
r1 = r1 - r2;
return r1;
} rational& operator *= (rational& r1, rational r2) {
r1 = r1 * r2;
return r1;
} rational& operator /= (rational& r1, rational r2) {
r1 = r1 / r2;
return r1;
} int operator == (rational& r1, rational& r2) {
// Zahlen sind immer normiert!
return r1.p == r2.p && r1.q == r2.q;
} int operator != (rational& r1, rational& r2) {
return ! (r1 == r2);
} ostream& operator << (ostream& s, rational r) {
return s << r.p << "/" << r.q;
}
Die Gefahr eines Überlaufs bei der long
-Arithmetik läßt sich verringern, wenn wir bei einigen Operatoren zusätzliche Kürzungen versuchen. Hier sind die Alternativen: rational operator + (rational r1, rational r2) {
rational r;
long t = ggT (r1.q, r2.q);
r.q = r1.q / t;
r.p = r.q * r2.p + (r2.q / t) * r1.p;
r.q *= r2.q;
return rational (r.p, r.q); // kürzen!
} rational operator * (rational r1, rational r2) {
long t = ggT (r1.p, r2.q);
if (t > 1) {r1.p /= t; r2.q /= t;}
t = ggT (r1.q, r2.p);
if (t > 1) {r1.q /= t; r2.p /= t;}
return rational (r1.p * r2.p, r1.q * r2.q);
} rational operator / (rational r1, rational r2) {
long t = ggT (r1.p, r2.p);
if (t > 1) {r1.p /= t; r2.p /= t;}
t = ggT (r1.q, r2.q);
if (t > 1) {r1.q /= t; r2.q /= t;}
return rational (r1.p * r2.q, r1.q * r2.p);
}
Es folgt ein kleines Programm, das einige der Operatoren testet:
////////////////////////// RATIOTST.CPP ////////////////////////////
// //
// Test von RATIONAL.CPP (Arithmetik mit rationalen Zahlen) //
// //
////////////////////////////////////////////////////////////////////
//
// Projekt besteht aus RATIOTST.CPP, RATIONAL.H, RATIONAL.CPP
//
#include "rational.h" void main (void) {
cout << "\n\n";
for (int i=10; i<15; i++)
for (int j=11; j<15; j++) {
rational r1 (15,i);
rational r2 (10,j);
rational d = r1 - r2;
rational p = r1 * r2;
rational q = r1 / r2;
cout << "r1=" << r1 << ", r2=" << r2 << ", r1-r2=" << d
<< ", r1*r2=" << p << ", r1/r2=" << q << "\n";
}
}
Dieses Programm erzeugt folgende Ausgabe: r1=3/2, r2=10/11, r1-r2=13/22, r1*r2=15/11, r1/r2=33/20 r1=3/2, r2=5/6, r1-r2=2/3, r1*r2=5/4, r1/r2=9/5 r1=3/2, r2=10/13, r1-r2=19/26, r1*r2=15/13, r1/r2=39/20 r1=3/2, r2=5/7, r1-r2=11/14, r1*r2=15/14, r1/r2=21/10 r1=15/11, r2=10/11, r1-r2=5/11, r1*r2=150/121, r1/r2=3/2 r1=15/11, r2=5/6, r1-r2=35/66, r1*r2=25/22, r1/r2=18/11 r1=15/11, r2=10/13, r1-r2=85/143, r1*r2=150/143, r1/r2=39/22 r1=15/11, r2=5/7, r1-r2=50/77, r1*r2=75/77, r1/r2=21/11 r1=5/4, r2=10/11, r1-r2=15/44, r1*r2=25/22, r1/r2=11/8 r1=5/4, r2=5/6, r1-r2=5/12, r1*r2=25/24, r1/r2=3/2 r1=5/4, r2=10/13, r1-r2=25/52, r1*r2=25/26, r1/r2=13/8 r1=5/4, r2=5/7, r1-r2=15/28, r1*r2=25/28, r1/r2=7/4 r1=15/13, r2=10/11, r1-r2=35/143, r1*r2=150/143, r1/r2=33/26 r1=15/13, r2=5/6, r1-r2=25/78, r1*r2=25/26, r1/r2=18/13 r1=15/13, r2=10/13, r1-r2=5/13, r1*r2=150/169, r1/r2=3/2 r1=15/13, r2=5/7, r1-r2=40/91, r1*r2=75/91, r1/r2=21/13 r1=15/14, r2=10/11, r1-r2=25/154, r1*r2=75/77, r1/r2=33/28 r1=15/14, r2=5/6, r1-r2=5/21, r1*r2=25/28, r1/r2=9/7 r1=15/14, r2=10/13, r1-r2=55/182, r1*r2=75/91, r1/r2=39/28 r1=15/14, r2=5/7, r1-r2=5/14, r1*r2=75/98, r1/r2=3/2
Als weiteres Beispiel für das Überladen von Operatoren betrachten wir eine Klasse
Datum für Berechnungen mit Kalenderdaten. Zur Berechnung der Zeitspanne in Tagen zwischen zwei Kalenderdaten bietet sich der Minus-Operator an, zur Erhöhung eines
Kalenderdatums um eine Anzahl von Tagen der Operator
+=
. Intern wird zu jedem Datum eine fortlaufende Tageszahl berechnet. Dabei ist es gleichgültig, auf welches
Anfangsdatum sich diese Zahl bezieht, weil sie nur für die Berechnung von
Datumsdifferenzen verwendet wird. Weiter sollen Wochentage und bewegliche Feste
berechnet werden und Kalenderdaten im Klartext angegeben werden können. So kommen wir zur folgenden Header-Datei:
////////////////////////////////////////////////////////////////////
// DATUM.H - Klasse Datum für Kalenderberechnungen //
////////////////////////////////////////////////////////////////////
#ifndef DATUM_H
#define DATUM_H
#ifndef BOOLEAN
#define BOOLEAN enum {false, true}; typedef int boolean;
#endif BOOLEAN class Datum { public:
Datum (int Tag=1, int Monat=1, int Jahr=2000);
void heute (); // übernimmt das Datum von MS-DOS
int Wochentag (); // 1=Mo, 2=Di, 3=Mi, ..., 7=So
char *String (); // z.B. "Mo, 31.12.1990"
int Tag ();
int Monat ();
int Jahr ();
void Ostern (int Jahr);
void Busstag (int Jahr);
void operator += (long Tage);
void operator -= (long Tage);
friend long operator - (Datum, Datum); // Differenz in Tagen protected:
int t, m, j; // Tag, Monat, Jahr
long Tagnummer; // Laufende Nummer
void berechneTagnummer (); // aus t, m, j
void berechneTMJ (); // aus Tagnummer
}; extern char *MoName[12]; // Monatsnamen extern char WoTag[7][3]; // Namen der Wochentage (2 Buchstaben) extern int Monatslaenge (int Monat, int Jahr=1); extern boolean Schaltjahr (int Jahr);
///////////////////////// inline-Funktionen: /////////////////////// inline int Datum::Tag () {return t;} inline int Datum::Monat () {return m;} inline int Datum::Jahr () {return j;}
#endif DATUM_H
Das folgende kurze Beispielprogramm demonstriert einige typische Methoden der Klasse
Datum:
////////////////////////////////////////////////////////////////////
// DAT-TEST.CPP - Demonstration der Klasse Datum //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#include "datum.h" main () {
Datum Mozart (27,1,1756), jetzt, Fest;
jetzt.heute ();
int J = jetzt.Jahr ();
cout << "\nBewegliche Feste " << J << "\n\n";
Fest.Ostern (J);
cout << "Ostersonntag: " << Fest.String()+4 << "\n";
Fest += 49;
cout << "Pfingstsonntag: " << Fest.String()+4 << "\n";
Fest.Busstag (J);
cout << "Buß- und Bettag: " << Fest.String()+4 << "\n\n";
cout << "Heute ist " << jetzt.String() << ".\n";
cout << "Mozart ist am " << Mozart.String() << " geboren.\n";
cout << "Seitdem sind " << jetzt-Mozart << " Tage vergangen.\n";
}
Es folgt die Implementierung der Klasse
Datum
. Um innerhalb eines Jahres nicht zwischen
Tagen vor und nach dem Schalttag unterscheiden zu müssen, rechnen wir hier intern so, als ob das Jahr erst am 1. März anfinge (So war es tatsächlich vor der Julianischen
Kalenderreform!). Weiter sei zum Verständnis daran erinnert, daß (seit der
Gregorianischen Kalenderreform) die durch 100, aber nicht durch 400 teilbaren Jahre keine Schaltjahre sind (zum Beispiel war 1900 kein Schaltjahr, 2000 ist aber ein
Schaltjahr).
////////////////////////////////////////////////////////////////////
// DATUM.CPP - Klasse Datum für Kalenderberechnungen //
////////////////////////////////////////////////////////////////////
#include <string.h>
#include <stdlib.h>
#include <dos.h> // verwendet: MK_FP, intdos
#include "datum.h" void DosGetDate (int& Tag, int& Monat, int& Jahr) {
REGS Reg;
Reg.h.ah = 0x2A; // MS-DOS 0x2A: Get Date
intdos (&Reg, &Reg);
Tag = Reg.h.dl;
Monat = Reg.h.dh;
Jahr = Reg.x.cx;
} char *MoName[12] = {
"Januar","Februar","März","April","Mai","Juni",
"Juli","August","September","Oktober","November","Dezember"
}; char WoTag[7][3] = {"Mo","Di","Mi","Do","Fr","Sa","So"}; static int MoAnf[12] = {
0, 31, 61, 92, 122, 153, 184, 214, 245, 275, 306, 337};
//Mrz Apr Mai Jun Jul Aug Sep Okt Nov Dez Jan Feb
Datum::Datum (int Tag, int Monat, int Jahr) {
t = Tag;
m = Monat;
j = Jahr;
berechneTagnummer ();
} void Datum::heute() {
int t,m,j;
DosGetDate (t,m,j);
*this = Datum (t,m,j);
} int Datum::Wochentag () {
return 1 + (Tagnummer+2) % 7;
} char *Datum::String () {
static char s[15]; // Beispiel: 01.07.1991
strcpy (s, WoTag[Wochentag()-1]); // "Di"
s[2] = ','; // "Di,???????????"
itoa (t + 100, s+3, 10); // "Di,101"
s[3] = ' '; // "Di, 01"
itoa (m + 100, s+6, 10); // "Di, 01107"
s[6] = s[9] = '.'; // "Di, 01.07.????"
itoa (j, s+10, 10); // "Di, 01.07.1991"
return s;
} void Datum::Ostern (int Jahr) { // Formel von Gauß/Hartmann
// nach "Schlag nach!",
int q = Jahr / 4; // Bibliographisches Institut, Mannheim 1960
int a = Jahr % 19; // gilt in dieser Form nur von 1900...2099
int b = (204 - 11 * a) % 30;
if (b > 27) b--;
int c = (Jahr + q + b - 13) % 7;
int t = 28 + b - c;
if (t > 31) *this = Datum (t-31, 4, Jahr);
else *this = Datum (t, 3, Jahr);
} void Datum::Busstag (int Jahr) {
// Bußtag ist Mittwoch, 16. bis 22. November
*this = Datum (22, 11, Jahr);
*this -= (Wochentag() + 4) % 7;
} void Datum::operator += (long Tage) {
Tagnummer += Tage;
berechneTMJ ();
} void Datum::operator -= (long Tage) {
Tagnummer -= Tage;
berechneTMJ ();
} long operator - (Datum D1, Datum D2) {
return D1.Tagnummer - D2.Tagnummer;
} static long Maerztag (int j) {
// berechnet fortlaufende Tagesnummer des 0. März im Jahr j
// (Basis: 0. März 0 steht für 28.Februar 1 v.Chr.)
return 365L * j // Länge der Gemeinjahre
+ j / 4 // Alle 4 Jahre ist Schaltjahr ...
- j / 100 // außer in den durch 100 ...
+ j / 400; // aber nicht durch 400 teilbaren Jahren
} void Datum::berechneTagnummer () {
int M = m, J = j;
if (M > 2) M -= 3; // März = 0, April = 1 ...
else {M +=9; J--;} // Jan. u. Feb. zum alten Jahr
Tagnummer = Maerztag (J) // Tagnummer des 1.März
+ MoAnf[M] // Länge der verstrichenen Monate ab März
+ t - 1; // Tage im laufenden Monat
} void Datum::berechneTMJ () {
int Rest;
j = 1 + Tagnummer / 365; // j ist mindestens um 1 zu groß
do {
j--; // römisches Jahr suchen
Rest = Tagnummer - Maerztag (j);
}
while (Rest < 0); // Rest = verstrichene Tage ab 1. März
m = 11; // Anfang mit letzem Monat (Februar)
while (MoAnf[m] > Rest)
m--; // richtigen Monat suchen
t = Rest + 1 - MoAnf[m];
if (m > 9) {m -= 9; j++;} // in "richtige" Monate umrechnen
else m += 3;
} int Monatslaenge (int Monat, int Jahr) {
static int MoLg[12] = {
31,28,31,30,31,30,31,31,30,31,30,31};
if (Monat == 2) return Schaltjahr (Jahr) ? 29 : 28;
else return MoLg[Monat-1];
} boolean Schaltjahr (int Jahr) {
return (Jahr % 4 == 0)
&& ((Jahr % 100 != 0) || (Jahr % 400 == 0));
}
Auf der Diskette zum Buch finden Sie ein Programm
KALENDER
, das einen Jahreskalender einschließlich der beweglichen Feste auf dem Bildschirm zeigt. Zunächst wird das aktuelle
Jahr dargestellt. Mit den Cursortasten können beliebige Jahre gewählt werden.
Ganze Zahlen sind spezielle rationale Zahlen. Es ist daher natürlich, arithmetische
Operationen zwischen ganzen und rationalen Zahlen zu erlauben. Wenn wir alle möglichen
Kombinationen berücksichtigen wollen, müssen wir die Klassendeklaration allein für die
Addition so erweitern: class rational { public:
...
friend rational operator + (rational r1, rational r2);
friend rational operator + (int i, rational r2);
friend rational operator + (rational r1, int i);
...
};
Sicher sind Sie nicht begeistert von der Vorstellung, für alle Operatoren alle möglichen
Typ-Kombinationen zu implementieren. Zum Glück geht es auch einfacher: Es genügt, die
Addition zwischen zwei Operanden vom Typ rational
zu definieren, wenn der Compiler weiß, wie er int
-Zahlen in den Typ rational
umwandeln kann.
Bei den Standard-Datentypen haben Sie die impliziten Typ-Konvertierungen bereits kennengelernt. Aufgrund dieser automatischen Umwandlungen dürfen Sie zum Beispiel der
Bibliotheksfunktion sin
Argumente eines beliebigen Zahltyps übergeben.
Jetzt wollen wir sehen, wie man implizite Typ-Umwandlungen bei eigenen Typen selbst definieren kann. Wir unterscheiden zwei Fälle:
Hierzu muß in der Klasse ein Konstruktor mit einem Argument des beliebigen Typs definiert werden. Der Konstruktor der Klasse rational
ist ein Beispiel hierfür:
rational (long Zaehler = 0, long Nenner = 1);
Hiermit werden eigentlich drei Konstruktoren deklariert: ohne Argument, mit einem und mit zwei Argumenten. Hier interessiert der Konstruktor mit einem Argument. Er ermöglicht zum Beispiel die folgende Zuweisung: rational r; long i;
.....
r = i; // Konvertierung von long nach rational
Hierfür kann das Schlüsselwort operator
in einer neuen Bedeutung verwendet werden: Es wird eine Elementfunktion der Form operator
Typ
();
deklariert.
Als Beispiel fügen wir in die Klasse rational
eine implizite Typ-Umwandlung in den Typ double
ein. Dazu setzen wir im public
-Abschnitt der Klassen-Deklaration die zusätzliche
Zeile
operator double (); ein. Die Implementierung sieht so aus: rational::operator double () {
return double(p) / double (q);
}
Es folgt ein etwas komplizierteres, kommentiertes Skelett-Beispiel, das beide
Umwandlungsarten und mögliche Fehlerquellen aufzeigt. Hier ist die Umwandlung von
TypA nach
TypB
mit beiden Methoden definert. Der Compiler meldet diese Mehrdeutigkeit als
Fehler (aber nur, wenn im Programm wirklich eine Umwandlung benötigt wird):
// Demonstration von Typ-Umwandlungen: class TypB; class TypA { public:
TypA ();
TypA (int i); // Konstruktor zur Umwandlung int --> TypA
operator int (); // Umwandlung TypA --> int
operator TypB (); // Umwandlung TypA --> TypB private:
int n;
}; class TypB { public:
TypB ();
TypB (TypA a); // Konstruktor zur Umwandlung TypA --> TypB
operator TypA (); // Umwandlung TypB --> TypA private:
int n;
};
TypA::TypA () {}
TypA::operator int () {return n;}
TypA::TypA (int i) {n = i;}
TypB::TypB () {}
TypB::TypB (TypA a) {n = a;}
TypB::operator TypA () {return TypA (n);} int main () {
TypA a;
TypB b;
int i;
i = a;
i = b; // Fehler: keine Umwandlung von TypB nach int definiert
a = i;
a = b;
b = i;
b = a; // Fehler: Typumwandlung mehrdeutig:
// (a) TypA::operator TypB
// (b) TypB::TypB (TypA)
}
Übungsaufgabe 11:
Erweitern Sie die Klasse bitset
im Programm
BITSET3.CPP
um implizite Typ-
Umwandlungen von und nach int
.
Übungsaufgabe12:
Entfernen Sie aus der Klasse rational
die Operatoren
==
und
!=
. Schreiben Sie dann ein
Testprogramm, in dem zwei rationale Zahlen auf Gleichheit geprüft werden. Warum meldet der Compiler keinen Fehler, obwohl die Klasse keinen Gleichheitsoperator mehr hat, und was ist der Unterschied in der Behandlung der Gleichheit?
Das folgende Beispiel gehört eigentlich zum Thema
Überladen von Operatoren, zeigt aber auch eine Anwendung der Typumwandlungen. Wir wollen einen Typ fVektor
definieren, der wie ein Pascal-Array einen beliebigen Indexbereich hat. Dazu müssen wir die Indizes, die in
C++ immer ab Null gezählt werden, umrechnen. Weiter wollen wir annehmen, daß der
Hauptspeicher zu klein ist, um den Vektor unterzubringen. Wir lagern den Vektor daher auf eine Datei aus. Trotz allem soll er in der üblichen Weise mit dem Operator
[]
indiziert werden.
Eine naheliegende Möglichkeit wäre es nun, den Operator
[]
so zu überladen, daß er den
Wert des angesprochenen Elements zurückliefert. Damit wären zwar Anweisungen wie n = A[i];
möglich, nicht aber eine Zuweisung der Form
A[i] = n;
Offenbar darf der Operator
[]
nicht den Wert des Objekts (R-Wert), sondern eine
Referenz (L-Wert) zurückliefern. Aber auch das löst unser Problem noch nicht, denn eine
Referenz steht für ein Objekt im Hauptspeicher, unsere Objekte befinden sich aber an einer bestimmten Stelle einer Datei. Deshalb definieren wir einen Typ
DateiPos
, der für eine bestimmte Position in einer geöffneten Datei steht. Jetzt bekommen wir die
Zuweisung in den Griff, indem wir eine Typumwandlung von
DateiPos
nach int
definieren.
Um die zweite Zuweisung zu ermöglichen, überladen wir den Operator
=
für die Zuweisung von int
nach
DateiPos
. Jetzt läuft die Zuweisung
A[i] = n; so ab:
A[i]
ist vom Typ
DateiPos
. Dieser wird der Wert n
zugewiesen. Das hat die
Wirkung, daß an der richtigen Stelle in der Datei der Wert von n
geschrieben wird.
Die nun folgende Implementierung soll nur das Prinzip darstellen und verzichtet bewußt auf eine Fehlerbehandlung.
//////////////////////////// FVEKTOR.CPP ///////////////////////////
// //
// Auf Datei ausgelagerte große Vektoren (Prinzip-Version) //
// //
////////////////////////////////////////////////////////////////////
#include <stdio.h>
#include <string.h>
#include <iostream.h>
#include <dir.h> // MAXPATH
#define INT int // zur einfachen Umstellung auf andere Typen
//////////////////// Klassen DateiPos und fVektor //////////////////
// //
// fVektor definiert einen Vektor, dessen Inhalt in einer Datei //
// gehalten wird. //
// DateiPos dient dem Verweis auf eine Dateiposition. //
// //
//////////////////////////////////////////////////////////////////// class DateiPos { public:
operator INT (); // Typkonvertierung
void operator = (INT i);
friend ostream& operator << (ostream& s, DateiPos p); protected:
friend class fVektor;
FILE *Dateizeiger;
long Position;
}; class fVektor { public:
fVektor (long i0, long i1, char *Datei);
~fVektor ();
DateiPos operator[] (long i); protected:
FILE *Dateizeiger;
char Dateiname[MAXPATH];
long Groesse, Basis;
};
///////////////////////// Implementierung: /////////////////////////
DateiPos::operator INT () { // Typkonvertierung
INT n;
fseek (Dateizeiger, Position, SEEK_SET);
fread (&n, sizeof(INT), 1, Dateizeiger);
return n;
} void DateiPos::operator = (INT i) {
fseek (Dateizeiger, Position, SEEK_SET);
fwrite (&i, sizeof(INT), 1, Dateizeiger);
} ostream& operator << (ostream& s, DateiPos p) {
return s << INT (p);
}
Dem Konstruktor wird der Indexbereich und der Name der temporären Datei angegeben: fVektor::fVektor (long i0, long i1, char *Datei) {
Groesse = i1 - i0 + 1;
Basis = i0;
strcpy (Dateiname, Datei);
Dateizeiger = fopen (Datei, "w+");
}
Der Destruktor löscht die Datei automatisch: fVektor::~fVektor () {
fclose (Dateizeiger);
remove (Dateiname);
}
DateiPos fVektor::operator[] (long i) {
DateiPos p;
p.Dateizeiger = Dateizeiger;
p.Position = (i - Basis) * sizeof(INT);
return p;
}
//////////////////////////////// Test: ///////////////////////////// main () {
int i;
fVektor A (10000, 20000, "C:\\$VEKTOR$.TMP");
cout << "\n";
for (i=10000; i<20000; i+=1000) cout << A[i] << " ";
cout << "\n";
for (i=10000; i<20000; i++) A[i] = i;
for (i=10000; i<20000; i+=1000) cout << A[i] << " ";
cout << "\n";
}
Objekte eines Klassentyps greifen auf gemeinsame Klassenmethoden zu. Sie können sich
nur in der aktuellen Belegung ihrer Datenelemente unterscheiden. Manchmal werden auch
Datenelemente benötigt, die nur einmal für die Klasse existieren und von allen Objekten genutzt werden können, zum Beispiel, um die zum Erzeugen von Objekten zur Verfügung stehenden Ressourcen zu überwachen. Hierfür wird das Schlüsselwort static
in einer neuen Bedeutung verwendet:
•
•
Ein mit deklariertes
Datenelement existiert nur einmal pro Klasse. Es kann von allen
Objekten angesprochen werden.
Eine mit deklarierte
Elementfunktion f()
der Klasse
X
wird mit
X::f()
aufgerufen.
Sie kann alternativ auch von einem Datenelement x
der Klasse mit x.f()
aufgerufen werden, hat aber auch dann keinen this
-Zeiger und darf demnach nicht auf nichtstatische Datenelemente zugreifen.
Ein Skelett-Programm zeigt die Möglichkeiten und Grenzen auf: class A { public:
int i;
static int j; // statisches Datenelement
void f ();
static void g (); // statische Elementfunktion
}; void A::f () { // Eine nicht-statische Elementfunktion
i++; // darf auf nicht-statische
j++; // und statische Datenelemente zugreifen
} void A::g () {
i++; // Fehler: statische Funktion kann nicht
// auf nicht-statisches Element zugreifen
j++;
} int A::j = 0; // so werden statische Detenelemente initialisiert int main () {
A a;
a.f ();
A::f (); // Fehler: nicht-statische Elementfunktion kann nur von
// einem Objekt, nicht von der Klasse aufgerufen werden
a.g (); // vorher muß A::g () korrigiert werden!
A::g();
}
[wird noch nachgetragen]
Mit Hilfe der Vererbung ist es möglich, Klassen zu definieren, die sich von einer schon vorhandenen Klasse nur in einigen Details unterscheiden. Damit wird die
Wiederverwendung von Klassen enorm erleichtert.
Zur Motivation der Vererbung betrachten wir einen primitiven Prototyp eines CAD-
Programms. Mit diesem einfachen Programm können Sie lediglich zwei vorgegebene
Objekte, einen Kreis und ein Rechteck, auf dem Bildschirm bewegen.
Dazu werden zwei Klassen,
Kreis
und
Rechteck
, definiert, die beide neben ihrem
Konstruktor die folgenden Methoden kennen: void zeige(); // zeichnet das Objekt in Vordergrundfarbe void loesche(); // zeichnet das Objekt in Hintergrundfarbe void bewege (int dx, int dy); // relative Verschiebung
Die Methode bewege
läuft in drei Schritten ab:
•
•
•
1. Das Objekt wird gelöscht
2. Die Koordinaten werden verschoben
3. Das Objekt wird mit den neuen Koordinaten gezeigt
Diese Art der Bewegung auf dem Bildschirm hat ihre Grenzen. Sie können das leicht feststellen, wenn Sie ein Objekt über den Hilfetext oben auf dem Bildschirm bewegen.
Beim Löschen wird einfach schwarz gezeichnet, ohne Rücksicht auf den Hintergrund.
Besser wäre es natürlich, vor jeder Bewegung den Bildhintergrund zu speichern und später wieder zu rekonstruieren. Sie sollen hier aber nicht durch technische Details vom prinzipiellen Problem abgelenkt werden.
Im Hauptprogramm werden je ein Kreis und ein Rechteck erzeugt. Der Kreis wird als aktuelles Objekt angesehen. Das aktuelle Objekt (der Kreis) wird an seiner
Anfangsposition auf dem Bildschirm gezeigt. Dann wird die Tastatur abgefragt, bis die
Esc
-Taste gedrückt wird. Dabei werden die Tastendrücke so verarbeitet:
Taste
'K'
: Der Kreis wird gezeigt
Taste
'R'
: Das Rechteck wird gezeigt
Cursortasten: Das aktuelle Objekt wird in Pfeilrichtung verschoben
Zur grafischen Darstellung werden die in GRAPHICS.H deklarierten Routinen (BGI-Grafik) verwendet. Die Implementierung enthält keine besonderen Tricks oder Schwierigkeiten:
////////////////////////////////////////////////////////////////////
// FIGUREN0.CPP //
// Geometrische Objekte ohne Vererbung //
////////////////////////////////////////////////////////////////////
#include <graphics.h>
#include <ctype.h>
#include <conio.h> class Kreis { public:
Kreis(int x0, int y0, int r0);
void zeige();
void loesche();
void bewege (int dx, int dy); private:
int x;
int y;
int Radius;
}; class Rechteck { public:
Rechteck (int x0, int y0, int x1, int y1);
void zeige();
void loesche();
void bewege (int dx, int dy); private:
int x;
int y;
int a, b; // die beiden Seiten
};
///////////////// Implementierung der Klasse Kreis: ////////////////
Kreis::Kreis(int x0, int y0, int r0) {
x = x0;
y = y0;
Radius = r0;
} void Kreis::zeige() {
circle(x, y, Radius);
} void Kreis::loesche() {
setcolor (BLACK);
circle(x, y, Radius);
setcolor (WHITE);
} void Kreis::bewege (int dx, int dy) {
loesche ();
x += dx;
y += dy;
zeige ();
}
//////////////// Implementierung der Klasse Rechteck: //////////////
Rechteck::Rechteck (int x0, int y0, int x1, int y1) {
x = x0;
y = y0;
a = x1 - x0;
b = y1 - y0;
} void Rechteck::zeige() {
rectangle (x, y, x+a, y+b);
} void Rechteck::loesche() {
setcolor (BLACK);
rectangle (x, y, x+a, y+b);
setcolor (WHITE);
} void Rechteck::bewege (int dx, int dy) {
loesche ();
x += dx;
y += dy;
zeige ();
}
///////////////////////// Test der Klassen: //////////////////////// int main() {
char c;
int graphdriver = DETECT, graphmode;
int dx, dy;
initgraph (&graphdriver, &graphmode, "\\borlandc\\bgi");
outtextxy (10, 8, "Figur mit Cursortasten bewegen. ");
outtextxy (10,24, "Figur wechseln: [R]echteck [K]reis");
outtextxy (10,40, "Ende: [Esc]");
Kreis K (getmaxx()/2, getmaxy()/2, 40);
Rechteck R (0, 0, 80, 60);
char aktuelleFigur = 'K';
K.zeige();
do {
c = getch(); // auf Tastendruck warten
if (c != 0) { // normale Zeichentaste: andere Figur?
switch (aktuelleFigur) {
case 'K': K.loesche (); break;
case 'R': R.loesche (); break;
}
switch (toupper (c)) {
case 'K': aktuelleFigur = 'K'; break;
case 'R': aktuelleFigur = 'R'; break;
}
switch (aktuelleFigur) {
case 'K': K.zeige (); break;
case 'R': R.zeige (); break;
}
}
else { // erweiterter Tastencode: Cursorbewegung?
c = getch();
switch (c) {
case 72: dx = 0; dy = -10; break; // aufwärts
case 80: dx = 0; dy = 10; break; // abwärts
case 75: dx = -10; dy = 0; break; // links
case 77: dx = 10; dy = 0; break; // rechts
}
switch (aktuelleFigur) {
case 'K': K.bewege (dx, dy); break;
case 'R': R.bewege (dx, dy); break;
}
}
} while (c != '\33'); // Esc-Taste
closegraph();
return 0;
}
Wenn Sie die beiden Klassen
Kreis
und
Rechteck
betrachten, sehen Sie, daß sie folgende gemeinsamen Elemente besitzen:
•
•
- Die Methoden zeige
, loesche
und bewege
,
- die Datenelemente x
und y
.
Die Methoden unterscheiden sich allerdings in ihrer Implementierung. In der
Wissenschaft, wie im täglichen Leben, versucht man der Fülle von Objekten und
Phänomenen Herr zu werden, indem man das Gemeinsame herausarbeitet und so Klassen bildet, deren Unterabteilungen sich in möglichst wenig Details abheben. Ein bekanntes
Beispiel für solche Klassenbildungen ist die von Carl von Linné begründete Systematik der
Tier- und Pflanzenarten. So gehört zum Beispiel ein Buntspecht der Ordnung der Spechte an, die Spechte der Klasse der Vögel und die Vögel dem Unterstamm der Wirbeltiere. Die
Färbung des Buntspechts ist ein Artmerkmal. Seinen kräftigen Meißelschnabel hat er mit allen Spechten gemeinsam, das Federkleid mit allen Vögeln und das Knochengerüst mit allen
Wirbeltieren.
Auch in der Programmierung hat man es immer wieder mit Datentypen zu tun, die sich zwar nicht gleichen, aber doch gemeinsame Elemente besitzen. Um hier hierarchische Modelle zu ermöglichen, besitzen objektorientierte Sprachen die Möglichkeit der
Vererbung.
Die Gemeinsamkeiten zwischen Buntspechten und Grünspechten führen zur Bildung der
Oberklasse Specht. Welche Gemeinsamkeiten besitzen unsere Klassen
Kreis
und
Rechteck
? Beide besitzen einen Bezugspunkt (x, y), der ihre aktuelle Lage beschreibt.
Wir definieren also eine Klasse
Punkt
: class Punkt { public:
Punkt (int x0, int y0); private:
int x;
int y;
}; mit dem Konstruktor
Punkt::Punkt(int x0, int y0) {
x = x0; y = y0;
}
Jetzt wollen wir erklären, daß ein Kreis eine von Punkt abgeleitete Klasse ist. Das heißt,
Kreis
besitzt zunächst einmal alle Eigenschaften von
Punkt
. In der Definition von
Kreis werden nur die Eigenschaften angegeben, in denen sich ein
Kreis
von einem
Punkt abhebt: class Kreis: public Punkt { // Kreis ist von Punkt abgeleitet public:
Kreis(int x0, int y0, int r0);
void zeige();
void loesche();
void bewege (int dx, int dy); private:
int Radius;
};
Die Implementierung können wir von der ersten Version übernehmen. Es folgt das vollständige Programm.
////////////////////////////////////////////////////////////////////
// FIGUREN1.CPP //
// Geometrische Objekte mit Vererbung //
////////////////////////////////////////////////////////////////////
#include <graphics.h>
#include <ctype.h>
#include <conio.h> class Punkt { public:
Punkt (int x0, int y0);
int x; // Die Datenkomponenten sind hier vorläufig public
int y; // Der Grund wird im nächsten Abschnitt verständlich!
}; class Kreis: public Punkt { public:
Kreis(int x0, int y0, int r0);
void zeige();
void loesche();
void bewege (int dx, int dy); private:
int Radius;
}; class Rechteck: public Punkt { public:
Rechteck (int x0, int y0, int x1, int y1);
void zeige();
void loesche();
void bewege (int dx, int dy); private:
int a, b; // die beiden Seiten
};
///////////////// Implementierung der Klasse Punkt: ////////////////
Punkt::Punkt(int x0, int y0) {
x = x0; y = y0;
}
///////////////// Implementierung der Klasse Kreis: ////////////////
Kreis::Kreis(int x0, int y0, int r0): Punkt (x0, y0) {
Radius = r0;
} void Kreis::zeige() {
circle(x, y, Radius);
} void Kreis::loesche() {
setcolor (BLACK);
circle(x, y, Radius);
setcolor (WHITE);
} void Kreis::bewege (int dx, int dy) {
loesche ();
x += dx;
y += dy;
zeige ();
}
//////////////// Implementierung der Klasse Rechteck: //////////////
Rechteck::Rechteck (int x0, int y0, int x1, int y1): Punkt (x0,y0) {
x = x0;
y = y0;
a = x1 - x0;
b = y1 - y0;
} void Rechteck::zeige() {
rectangle (x, y, x+a, y+b);
} void Rechteck::loesche() {
setcolor (BLACK);
rectangle (x, y, x+a, y+b);
setcolor (WHITE);
} void Rechteck::bewege (int dx, int dy) {
loesche ();
x += dx;
y += dy;
zeige ();
}
///////////////////////// Test der Klassen: //////////////////////// int main() {
char c;
int graphdriver = DETECT, graphmode;
initgraph (&graphdriver, &graphmode, "\\borlandc\\bgi");
outtextxy (10, 8, "Figur mit Cursortasten bewegen. ");
outtextxy (10,24, "Figur wechseln: [R]echteck [K]reis");
outtextxy (10,40, "Ende: [Esc]");
Kreis K (getmaxx()/2, getmaxy()/2, 40);
Rechteck R (0, 0, 80, 60);
char aktuelleFigur = 'K';
K.zeige();
do {
c = getch(); // auf Tastendruck warten
if (c != 0) { // normale Zeichentaste: andere Figur?
switch (aktuelleFigur) {
case 'K': K.loesche (); break;
case 'R': R.loesche (); break;
}
switch (toupper (c)) {
case 'K': aktuelleFigur = 'K'; break;
case 'R': aktuelleFigur = 'R'; break;
}
switch (aktuelleFigur) {
case 'K': K.zeige (); break;
case 'R': R.zeige (); break;
}
}
else { // erweiterter Tastencode: Cursorbewegung?
c = getch();
switch (aktuelleFigur) {
case 'K':
switch (c) {
case 72: K.bewege (0, -10); break; // aufwärts
case 80: K.bewege (0, 10); break; // abwärts
case 75: K.bewege (-10, 0); break; // links
case 77: K.bewege (10, 0); break; // rechts
}
break;
case 'R':
switch (c) {
case 72: R.bewege (0, -10); break; // aufwärts
case 80: R.bewege (0, 10); break; // abwärts
case 75: R.bewege (-10, 0); break; // links
case 77: R.bewege (10, 0); break; // rechts
}
break;
}
}
} while (c != '\33'); // Esc-Taste
closegraph();
return 0;
}
Es bleibt nur noch ein kleines Problem: Die Elemente x
und y
der Klasse
Punkt
sind als private
deklariert. Deshalb kann die Klasse
Kreis
darauf nicht zugreifen. Um private
Elemente auch abgeleiteten Klassen zugänglich zu machen, verwendet man das
Schlüsselwort protected
. Während privat
-Elemente nur Elementfunktionen der Klasse selbst zugänglich sind, können protected
-Elemente auch von abgeleiteten Klassen angesprochen werden.
Der Vollständigkeit halber sei hier auch die Bedeutung des Schlüsselworts public
im Kopf abgeleiteter Klassen erwähnt. In der Definition class B: public A {...}; erklärt der Zugriffsmodifizierer public
, daß die Zugriffsattribute bei abgeleiteten
Klassen erhalten bleiben. Wenn Sie den Zugriffsmodifizierer weglassen (oder private verwenden), sind in der abgeleiteten Klasse alle aus der Basisklasse geerbten Elemente privat.
Einen genauen Überblick soll die folgende Tabelle geben:
Zugriff in abgeleiteter Klasse bei:
Zugriffsmodifizierer
Zugriff in Basisklasse public private public public private protected protected private private
kein Zugriff möglich kein Zugriff möglich
Wenn ein Objekt einer abgeleiteten Klasse erzeugt wird, wird nicht nur der Konstruktor der abgeleiteten Klasse aufgerufen, sondern auch der Konstruktor der Basisklasse.
Zusätzlich müssen noch die Konstruktoren aller Datenelemente der abgeleiteten Klasse aufgerufen werden. Manchmal hängt die Korrektheit eines Programms von der Reihenfolge dieser Aufrufe ab. Die Konstruktoren werden immer in der folgenden Reihenfolge aufgerufen:
•
•
•
(1) Der Konstruktor der Basisklasse;
(2) die Konstruktoren der Datenelemente der abgeleiteten Klasse;
(3) der Konstruktor der abgeleiteten Klasse selbst.
Falls die Basisklasse wieder eine abgeleitete Klasse ist, setzt sich das Spiel rekursiv fort.
Die Destruktoren werden in umgekehrter Reihenfolge aufgerufen. Das folgende Beispiel
verdeutlicht den Mechanismus und zeigt außerdem, daß eine gewöhnliche Elementfunktion, die in der abgeleiteten Klasse neu definiert wird, nur in der aktuellen Version aufgerufen wird.
#include <iostream.h> class Hilfsklasse { public:
Hilfsklasse ();
~Hilfsklasse ();
}; class Basis { public:
Basis ();
~Basis ();
void f ();
}; class Abgeleitet: public Basis { public:
Abgeleitet ();
~Abgeleitet ();
void f ();
Hilfsklasse h;
};
Hilfsklasse::Hilfsklasse () {cout << "Konstr. Hilfsklasse\n";}
Hilfsklasse::~Hilfsklasse (){cout << "Destr. Hilfsklasse\n";}
Basis::Basis () {cout << "Konstr. Basis\n";}
Basis::~Basis () {cout << "Destr. Basis\n";} void Basis::f () {cout << "Fkt. Basis\n";}
Abgeleitet::Abgeleitet () {cout << "Konstr. Abgeleitet\n";}
Abgeleitet::~Abgeleitet () {cout << "Destr. Abgeleitet\n";} void Abgeleitet::f () {cout << "Fkt. Abgeleitet\n";
} main () {
Abgeleitet b;
b.f ();
}
Das Programm erzeugt folgende Ausgabe:
Konstr. Basis
Konstr. Hilfsklasse
Konstr. Abgeleitet
Fkt. Abgeleitet
Destr. Abgeleitet
Destr. Hilfsklasse
Destr. Basis
Bei der Erklärung der Aufrufreihenfolge der Konstruktoren sind wir stillschweigend davon ausgegangen, daß die Basisklasse einen Konstruktor ohne Parameter besitzt. Es gibt auch einen speziellen Mechanismus, um Konstruktoren von Basisklassen aufzurufen, die
Argumente verlangen, nämlich die
Initialisierer-Liste. Dazu wird zwischen Funktionskopf und Funktionsblock nach einem Doppelpunkt der explizite Konstruktor-Aufruf angegeben.
Im folgenden Prinzip-Beispiel wird vor dem Konstruktor der Klasse
Abgeleitet
mit Hilfe der Initialisierer-Liste
Basis (i)
der Konstruktor der Basisklasse aufgerufen:
class Basis { public:
Basis (int i);
}; class Abgeleitet: public Basis { public:
Abgeleitet ();
};
Basis::Basis (int i) {...}
Abgeleitet::Abgeleitet () : Basis (i) {...}
Das alljährliche Brutgeschäft läßt sich für die Klasse aller Vögel in stark vereinfachter
Form so beschreiben:
•
•
•
•
(1) Sorge für einen geeigneten Nistplatz;
(2) lege Eier;
(3) brüte die Eier aus;
(4) versorge die Jungvögel, bis sie selbständig sind.
Eine schrittweise Zerlegung eines Problems in Teilprobleme ist in der Programmierung nichts neues. Was die Sache hier allerdings interessant macht, ist die Tatsache, daß wir eine Methode für eine Klasse formuliert haben, die auf Hilfsmethoden zugreift, die erst in den Unterklassen definiert werden können. So bedeutet Methode (1) je nach Unterklasse:
Höhle zimmern (Spechte), Höhle suchen (Eulen), Nest bauen (Amsel), Wirtsnest aufspüren
(Kuckuck). Auch die übrigen Methoden unterscheiden sich in den Unterklassen. So kann bei
Methode (3) das Brüten Aufgabe des Weibchens, des Männchens, oder beider sein, oder es kann sogar delegiert werden (Kuckuck).
Man spricht hier von polymorphen (vielgestaltigen) Methoden, weil hinter einer
Bezeichnung je nach Klasse verschiedene Methoden stecken. Im Alltagsleben wäre eine sinnvolle Kommunikation ohne Polymorphie nicht möglich. Sie brauchen nur einmal ein
Kochrezept durchzulesen und sich zu überlegen, was die darin enthaltenen Anweisungen für Sie bedeuten, je nachdem, ob Sie der Klasse der Elektroherdbesitzer oder der
Gasherdbesitzer angehören, ob Sie eine Küchenmaschine haben und so weiter.
Ähnliches haben wir auch in den Klassen
Kreis
und
Rechteck
: Die Methoden zeige
und loesche
sind für beide Klassen unterschiedlich. Dagegen stimmt der Text der Methode bewege
in beiden Implementierungen überein. Jedes geometrische Objekt kann bewegt werden, indem es zunächst gelöscht und dann mit neuen Koordinaten wieder gezeigt wird.
Es bietet sich an, zu den Klassen
Kreis
und
Rechteck
eine Oberklasse
Figur einzuführen, in der wir unabhängig von der Art der Figur (Kreis oder Rechteck) beschreiben, wie eine Figur bewegt wird: class Figur: public Punkt { // Basisklasse
public: // für Grafik-Objekte
Figur (int x0, int y0);
void bewege (int dx, int dy);
}; void Figur::bewege (int dx, int dy) {
loesche ();
x += dx;
y += dy;
zeige ();
}
Der Haken daran ist nur, daß der Compiler hier streikt, weil er in
Figur::bewege
die
Methoden loesche
und zeige
gar nicht kennt. Objektorientierte Sprachen bieten einen
Ausweg aus dieser Situation. Sie haben C++ bisher als eine typstrenge Sprache kennengelernt. Der Compiler muß bei jedem Objekt wissen, von welchem Typ es ist, um die passenden Methoden einzusetzen. Bei einer Anweisung wie
Objekt.zeige
prüft der Compiler den Typ der Variablen
Objekt
und setzt dann die Methode zeige
aus der entsprechenden Klasse ein. Das nennt man frühe Bindung (oder auch statische
Bindung). Von später Bindung (oder dynamischer Bindung) spricht man dann, wenn der Typ eines Objekts noch nicht vom Compiler erkannt werden kann, sondern erst zur Laufzeit des Programms.
Späte Bindung eröffnet einerseits ungeahnte neue Möglichkeiten, andererseits muß sie mit einem kleinen Verlust an Effizienz erkauft werden. Deshalb kann man in C++ zwischen früher und später Bindung wählen. Zunächst ist die Typstrenge wie folgt aufgelokkert:
Einer Variablen vom Typ Zeiger auf eine Klasse darf man einen Zeiger auf eine abgeleitete
Klasse zuweisen. Ruft man über diesen Zeiger eine Methode auf, so muß im Normalfall der frühen Bindung der Compiler entscheiden, aus welcher Klasse er die Methode nehmen soll.
Dabei muß er sich nach dem Typ der Zeigervariablen richten. Bei später Bindung wird zur
Laufzeit der aktuelle Typ des Objekts, auf das der Zeiger zeigt, zugrundegelegt.
Späte Bindung wird in C++ durch virtuelle Funktionen realisiert. Durch das Schlüsselwort virtual
vor der Deklaration einer Elementfunktion wird dem Compiler mitgeteilt, daß er späte Bindung verwenden soll. Technisch wird dies dadurch realisiert, daß jedes Objekt der Klasse zusätzlich einen Zeiger auf eine Tabelle enthält, die zu jeder virtuellen
Funktion die zum Objekt gehörende Version angibt.
Ist eine Funktion einmal als virtuell erklärt, ist sie automatisch in jeder abgeleiteten
Klasse wieder virtuell. Zum besseren Verständnis sollte man das Schlüsselwort virtual bei jeder Neudefinition in einer abgeleiteten Klasse wiederholen, obwohl C++ das freistellt.
In unserem Figuren-Programm werden in der Basisklasse
Figur
die gemeinsamen
Eigenschaften der konkreten Klassen
Kreis
und
Rechteck
definiert. Es hat keinen Sinn,
Objekte der Basisklasse selbst zu definieren, denn eine allgemeine Figur kann weder gezeigt noch gelöscht werden. Man spricht in solchen Fällen von einer abstrakten
Basisklasse.
In C++ kann man bei virtuellen Funktionen in einer Basisklasse auf eine Implementierung verzichten, indem man
= 0
hinter die Funktionsdeklaration schreibt. Eine solche Funktion heißt dann rein virtuell (pure virtual). Eine Klasse mit mindestens einer rein virtuellen
Funktion ist eine abstrakte Basisklasse. Die Definition von Objekten einer solchen Klasse betrachtet der Compiler als Fehler.
In der folgenden Version des Figuren-Programms werden im Hauptprogramm je ein Kreis und ein Rechteck definiert. Einem Zeiger
Figur *aktuelleFigur; kann wahlweise die Adresse des Kreises oder des Rechtecks zugewiesen werden. Die
Wirkung der Anweisung aktuelleFigur->zeige(); kann deshalb erst zur Laufzeit ermittelt werden.
////////////////////////////////////////////////////////////////////
// FIGUREN.CPP //
// Einfache Demonstration virtueller Funktionen //
// am Beispiel der Bewegung von geometrischen Objekten //
////////////////////////////////////////////////////////////////////
#include <graphics.h>
#include <ctype.h>
#include <conio.h> class Punkt { public:
Punkt (int x0, int y0); protected:
int x;
int y;
}; class Figur: public Punkt { // abstrakte Basisklasse public: // für Grafik-Objekte
Figur (int x0, int y0);
virtual void zeige() = 0; // rein virtuelle Funktion
virtual void loesche() = 0; // dto.
void bewege (int dx, int dy);
}; class Kreis: public Figur { public:
Kreis(int x0, int y0, int r0);
virtual void zeige();
virtual void loesche(); protected:
int Radius;
}; class Rechteck: public Figur { public:
Rechteck (int x0, int y0, int x1, int y1);
virtual void zeige();
virtual void loesche(); protected:
int a, b; // die beiden Seiten
};
///////////////// Implementierung der Klasse Punkt: ////////////////
Punkt::Punkt(int x0, int y0) {
x = x0; y = y0;
}
///////////////// Implementierung der Klasse Figur: ////////////////
Figur::Figur(int x0, int y0) : Punkt (x0, y0) {} void Figur::bewege (int dx, int dy) {
loesche ();
x += dx;
y += dy;
zeige ();
}
///////////////// Implementierung der Klasse Kreis: ////////////////
Kreis::Kreis(int x0, int y0, int r0): Figur (x0, y0) {
Radius = r0;
} void Kreis::zeige() {
circle(x, y, Radius);
} void Kreis::loesche() {
setcolor (BLACK);
circle(x, y, Radius);
setcolor (WHITE);
}
//////////////// Implementierung der Klasse Rechteck: //////////////
Rechteck::Rechteck (int x0, int y0, int x1, int y1): Figur (x0,y0) {
a = x1 - x0;
b = y1 - y0;
} void Rechteck::zeige() {
rectangle (x, y, x+a, y+b);
} void Rechteck::loesche() {
setcolor (BLACK);
rectangle (x, y, x+a, y+b);
setcolor (WHITE);
}
///////////////////////// Test der Klassen: //////////////////////// int main() {
char c;
int graphdriver = DETECT, graphmode;
initgraph (&graphdriver, &graphmode, "\\borlandc\\bgi");
outtextxy (10, 8, "Figur mit Cursortasten bewegen. ");
outtextxy (10,24, "Figur wechseln: [R]echteck [K]reis");
outtextxy (10,40, "Ende: [Esc]");
Kreis K (getmaxx()/2, getmaxy()/2, 40);
Rechteck R (0, 0, 80, 60);
Figur *aktuelleFigur = &K;
aktuelleFigur->zeige();
do {
c = getch(); // auf Tastendruck warten
if (c != 0) { // normale Zeichentaste: andere Figur?
aktuelleFigur->loesche ();
switch (toupper (c)) {
case 'K': aktuelleFigur = &K; break;
case 'R': aktuelleFigur = &R; break;
}
aktuelleFigur->zeige ();
}
else { // erweiterter Tastencode: Cursorbewegung?
c = getch();
switch (c) {
case 72: aktuelleFigur->bewege (0, -10); break; // aufwärts
case 80: aktuelleFigur->bewege (0, 10); break; // abwärts
case 75: aktuelleFigur->bewege (-10, 0); break; // links
case 77: aktuelleFigur->bewege (10, 0); break; // rechts
}
}
} while (c != '\33'); // Esc-Taste
closegraph();
return 0;
}
Übungsaufgabe 13:
Was gibt das folgende Programm aus?
#include <iostream.h> class Klasse1 { public:
void frueheBindung () {cout << "früheBindung-Klasse1\n";}
virtual void spaeteBindung () {cout << "späteBindung-Klasse1\n";}
}; class Klasse2: public Klasse1 { public:
void frueheBindung () {cout << "früheBindung-Klasse2\n";}
virtual void spaeteBindung () {cout << "späteBindung-Klasse2\n";}
}; void main () {
Klasse1 Objekt1;
Klasse2 Objekt2;
Klasse1 *ptr1, *ptr2;
cout << "\n";
ptr1 = &Objekt1;
ptr2 = &Objekt2;
ptr1->frueheBindung();
ptr2->frueheBindung();
ptr1->spaeteBindung();
ptr2->spaeteBindung();
}
Eine typische Anwendung der späten Bindung sind polymorphe Listen. Ein CAD-Programm muß zum Beispiel eine Vielzahl verschiedener Objekte speichern. Hierzu definiert man eine abstrakte Basisklasse mit virtuellen Funktionen, von der alle benötigen Objekttypen abgeleitet werden können. Alle Objekte können dann in einer Liste von Zeigern auf die
Basisklasse verwaltet werden. Eine Bildschirmauffrischung besteht dann einfach darin, daß für alle Elemente der Liste die virtuelle Methode zum Zeichnen aufgerufen wird.
Sie können zum Beispiel das Figuren-Programm mit wenig Aufwand so erweitern, daß es eine Liste (Im einfachsten Fall als Vektor) von Zeigern auf
Figur
verwalten kann.
Die Art der Vererbung, die Sie bisher kennengelernt haben, würde man in der Biologie als ungeschlechtliche Fortpflanzung bezeichnen: Jede Klasse hat höchstens ein Elternteil, nämlich die Klasse, von der sie abgeleitet ist.
Ebenso wie ein Kind Eigenschaften der Mutter und des Vaters erben kann, ist es in C++ erlaubt, daß eine Klasse von mehreren - sogar beliebig vielen - Klassen abgeleitet wird.
Bei mehrfacher Vererbung müssen bei der Deklaration einer Klasse mehrere Basisklassen angegeben werden. Dabei werden die einzelnen Basisklassen durch Kommata getrennt. Vor der Initialisierung eines Objekts werden die Konstruktoren aller Basisklassen in der
Reihenfolge aufgerufen, in der sie bei der Klassendefinition angegeben wurden. Dabei können auch Initialisierer-Listen verwendet werden, in denen die einzelnen Konstruktor-
Aufrufe durch Kommata getrennt werden.
Soll zum Beispiel eine Klasse
Stringfeld
von den Klassen String und Position abgeleitet werden, sieht das so aus: class Stringfeld: public String, public Position {
...
Es folgt ein einfaches (und damit praxisfernes) Beispiel. Ein Stringfeld hat einen Inhalt und eine Position auf dem Bildschirm, die in zwei getrennten Basisklassen beschrieben werden:
#include <string.h>
#include <conio.h> class String { public:
String (char *s); protected:
char str[80];
};
String::String (char *s) {
if (strlen (s) < 80) strcpy (str, s);
} class Position { public:
Position (int x, int y); protected:
int x0, y0;
};
Position::Position (int x, int y) {
x0 = x;
y0 = y;
} class Stringfeld: public String, public Position { public:
Stringfeld (char *s, int x, int y);
void zeige ();
};
Stringfeld::Stringfeld (char *s, int x, int y) :
String (s), Position (x, y) {
zeige ();
} void Stringfeld::zeige () {gotoxy (x0,y0); cputs (str);} int main () {
Stringfeld s ("Stringfeld", 35, 13);
return 0;
}
Dieses Programm gibt die Zeichenkette "
Stringfeld
" etwa in der Mitte des Bildschirm aus. Ein sinnvolles Beispiel für mehrfache Vererbung finden Sie zum Beispiel im Modul
EDT des Projekts
DIAGRAMM
auf der Diskette zum Buch.
Ein Problem bei mehrfacher Vererbung ist die "Inzucht". Wenn eine Klasse von mehreren
Basisklassen abgeleitet wird, die wiederum eine gemeinsame Basisklasse haben, gibt es die
Elemente dieser gemeinsamen Basisklasse mehrfach. Das führt zu Mehrdeutigkeiten und erhöhtem Speicherbedarf: class Mutter: public Ahn {...}; class Vater: public Ahn {...}; class Kind: public Vater, public Mutter {...}
In diesem Beispiel hat jedes Objekt der Klasse
Kind
die aus der Klasse
Ahn
geerbten
Elemente doppelt. Um das zu vermeiden, läßt sich die Basisklasse als virtuelle Basisklasse deklarieren: class Mutter: virtual public Ahn {...}; class Vater: virtual public Ahn {...}; class Kind: public Vater, public Mutter {...}
Hier wurde
Ahn
zu einer virtuellen Basiklasse und gibt seine Elemente nur einmal an
Kind weiter.
Ein Ziel von C++ ist es, vom Anwender definierten Datentypen die gleichen Möglichkeiten wie Standard-Datentypen geben zu können. In der Praxis tauchen dabei allerdings einige technische Probleme auf, die nicht ganz einfach zu umgehen sind.
Wir wollen nochmals einen Datentyp string
entwerfen, diesmal aber nicht nach dem
Vorbild von Turbo Pascal, sondern so flexibel, daß ein String im Prinzip jede beliebige
Größe annehmen kann. Für diese Klasse wollen wir die Verkettung mit dem Operator + definieren. Sind s1
, s2
und s3
vom Typ string
, so soll die Zuweisung s1 = s2 + s3; die Strings s2
und s3
verketten und das Ergebnis der Stringvariablen s1
zuweisen. Das soll auch dann klappen, wenn s1
derzeit zu kurz ist.
Dazu könnten wir so vorgehen: Die Operatorfunktion
+
gibt den gewünschten verketteten
String zurück. Der Zuweisungsoperator wird so überladen, daß er zunächst das aktuelle
Objekt s1
löscht (es hat ja meist eine nicht passende Größe) und es dann mit dem Kopier-
Konstruktor neu erzeugt. Diese Vorgehensweise ist allerdings extrem ineffizient. Um das zu verstehen, wollen wir etwas genauer betrachten, wie der Compiler die Zuweisung eines
Funktionswertes handhabt.
Betrachten Sie folgendes Programmfragment: int f (int a, int b) {
int t = a + b;
return t;
} main () {
int a, b, c;
c = f (a, b);
}
Was geschieht in der letzten Programmzeile? Zunächst wird die Funktion f
aufgerufen.
Diese erzeugt das lokale Objekt t
und gibt es mit der return
-Anweisung zurück.
Anschließend wird der Zuweisungsoperator aufgerufen. Aber jetzt existiert die lokale
Variable t
gar nicht mehr. Aus diesem Grund wird in der return
-Anweisung ein verborgenes temporäres Objekt erzeugt, das beim Aufruf des Zuweisungsoperators noch existiert und irgendwann später gelöscht wird (Wann das genau geschieht, ist vom
Compiler abhängig).
Bei unserem String-Beispiel wird die Operator-Funktion
+
aufgerufen und erzeugt ein temporäres Objekt. Dieses wird beim Verlassen der Funktion in eine verborgene temporäre Variable kopiert, und diese wird schließlich in die Zielvariable kopiert. Stellen
Sie sich vor, Sie möchten zwei 100-mal-100-Matrizen addieren. Dann bestünde die
Hauptarbeit des Programms im zweimaligen Kopieren des errechneten Ergebnisses. In der
Praxis würden Sie dann vermutlich auf die Eleganz einer Zuweisung der Form
C = A + B;
verzichten und sich eine Funktion schreiben, die die Multiplikation ohne unnötige Kopien erreicht. Das würde dann vielleicht so aussehen: addiere (A, B, C);
Es gibt doch eine Möglichkeit, die Eleganz mit der Effizienz zu paaren, allerdings mit einigen formalen Verrenkungen. Der Schlüssel zur Lösung liegt darin, daß wir die eigentlichen (und speicheraufwendigen) Daten vom Objekt trennen: Wir zerlegen den
String in ein Objekt vom Typ string
und ein Objekt vom Typ stringDaten
. Ein string enthält jetzt nur noch einen Zeiger auf seine stringDaten
. So ist es möglich, daß mehrere gleiche string
-Objekte auf die gleichen stringDaten
zeigen. Bei dieser Lösung müssen wir nur folgendes beachten:
•
•
Bei einer
Zuweisung an einen string
muß geprüft werden, ob er seine stringDaten mit anderen string
-Objekten teilt. Hierfür bekommt die Klasse stringDaten
ein zusätzliches Element
Referenzen
, das zählt, wieviele string
-Objekte derzeit auf ein stringDaten
-Objekt zeigen. Ist er der einzige (
Referenzen == 1
), ruft er den Destruktor seiner stringDaten
auf und hängt sich an die stringDaten
des zugewiesenen Strings. Ist
Referenzen
> 1, wird der Zähler nur um eins vermindert.
Der
Destruktor eines Strings vermindert den Referenzenzähler seiner stringDaten
um eins und ruft den Destruktor der stringDaten
nur dann auf, wenn der Referenzenzähler Null erreicht hat.
In der Implementierung des Operators
+
wird zunächst eine lokale Variable t
der passenden Länge erzeugt. Der Referenzenzähler steht jetzt auf 1. Bei der Übergabe des
Funktionswertes wird er in die verborgene temporäre Variable kopiert. Jetzt steht der
Referenzenzähler auf 2. Bevor die Zuweisung ausgeführt wird, wird der Destruktor der lokalen Variable t
aufgerufen. Dabei werden die stringDaten
nicht vernichtet, sondern nur der Referenzenzähler von 2 auf 1 herabgesetzt.
//////////////////////////// STRING.CPP ////////////////////////////
// //
// Verwendung von Referenzenzählern //
// zur Vermeidung unnötiger Kopien //
// //
////////////////////////////////////////////////////////////////////
#include <iostream.h>
#include <string.h>
#include <stdlib.h> struct stringDaten {
char *s;
int Referenzen;
}; class string { public:
string (int); // string s (256);
string (const char *); // string s = "abc";
string (const string&); // string t = s;
~string ();
int length ();
string& operator = (const string&); // t = s;
char& operator [] (int i);
friend ostream& operator << (ostream&, string&);
friend string operator + (const string& s, const string& t);
friend int operator == (const string& s, const string& t);
friend int operator != (const string& s, const string& t); private:
stringDaten *p;
}; void error (char *p) {
cerr << p << "\n";
exit (1);
} string::string (int n) {
p = new stringDaten;
p->s = new char [n+1];
p->Referenzen = 1;
} string::string (const char *s) {
p = new stringDaten;
p->s = new char [strlen(s)+1];
strcpy (p->s, s);
p->Referenzen = 1;
} string::string (const string& s) {
s.p->Referenzen++;
p = s.p;
} string::~string () {
if (--p->Referenzen == 0) {
delete p->s;
delete p;
}
} inline int string::length () {return strlen (p->s);} string& string::operator = (const string& s) {
if (--p->Referenzen == 0) {
delete p->s;
delete p;
}
s.p->Referenzen++;
p = s.p;
return *this;
} char& string::operator [] (int i) {
if (i<0 || i >= strlen(p->s)) error ("unerlaubter Index");
return p->s[i];
} int operator == (const string& s, const string& t) {
return strcmp (s.p->s, t.p->s) == 0;
} int operator != (const string& s, const string& t) {
return strcmp (s.p->s, t.p->s) != 0;
} ostream& operator << (ostream& s, string& t) {
return s << t.p->s << t.p->Referenzen << " ";
} string operator + (const string& s1, const string& s2) {
int l1 = strlen (s1.p->s);
string t (l1 + strlen(s2.p->s));
strcpy (t.p->s, s1.p->s);
strcpy (t.p->s+l1, s2.p->s);
return t;
} main () {
string s1 = "erster";
string s2 = "zweiter";
string s3 = "dritter";
s3 = s1 + s2;
cout << s1 << s2 << s3 << "\n";
}
Nun haben Sie die nötigen Kenntnisse, um ähnliche Aufgaben, wie etwa Matrizenrechnung elegant zu lösen. Hierzu soll ein Hinweis genügen: Sie können eine Matrix als Vektor von
Zeilenvektoren definieren. Ist
M
eine Matrix, dann sollte der Operator
[]
für
M[i]
eine
Referenz auf den i
-ten Zeilenvektor von
M
liefern. Dann ist automatisch
M[i][k]
eine
Referenz auf das entsprechende Matrix-Element.
Aufgabe 1:
if (a > 0 && a <= 4)
b = a;
Aufgabe 2:
Die for
-Schleife wird bei i=21
mit der break
-Anweisung abgebrochen. Für i=0
..
20
(also
21 mal) wird n
um 100 erhöht. Die Zeile n++
wird erst ab i=10
(also 11 mal) ausgeführt.
Damit bekommt n
den Wert 21*100+11 = 2111.
Aufgabe 3:
Für die nicht angegebenen Parameter wird die kleinstmögliche int
-Zahl eingesetzt. Sie ist in der Header-Datei
LIMITS.H
definiert:
#include <limits.h> int max0 (int a, int b = INT_MIN, int c = INT_MIN) {
if (a > b)
if (a > c) return a;
else return c;
else
if (b > c) return b;
else return c;
}
Es folgt eine alternative Lösung mit einem bedingten Ausdruck: int max (int a, int b = INT_MIN, int c = INT_MIN) {
return (a > b) ? ((a > c) ? a : c) : ((b > c) ? b : c);
}
Aufgabe 4:
f (i,l); // 'A'
f (l,i); // 'B'
f (i,i); // Fehler: mehrdeutig!
f (l,l); // Fehler: mehrdeutig!
f (i, long(i)); // 'A'
f (l, int(i)); // 'B'
Aufgabe 5:
In der einfachen Zuweisung wird der mit allen Zeigertypen verträgliche
NULL
-Zeiger zugewiesen. Die doppelte Zuweisung ist so zu lesen:
p1 = (p2 = NULL);
Dabei ist der Klammerausdruck vom Typ long *
und nicht typverträglich mit int *
.
Aufgabe 6:
Ab der Adresse s
werden alle Bytes mit
'V'
beschrieben. (Systemzusammenbruch ?!)
Aufgabe 7:
Der Compiler reserviert den Speicherplatz für die drei Strings unmittelbar hintereinander. Deshalb können die vorderen Strings in die hinteren überlaufen. Das
Diagramm zeigt den Speicherbereich Schritt für Schritt. Dabei steht ein Backslash für das Null-Byte. s1 s2 s3
³
.....
³
...
³
...
³.....³
...BAR\
³
.....WIRKLICH\
WUNDER\IRKLICH\
WUNDERLICH
Die Lösung ist also
"WUNDERLICH"
.
Aufgabe 8:
A:e, B:g, C:h, D:f, E:c, F:b, G:d, H:a.
Aufgabe 9:
int (*(* A[n])(int))[n];
6 4 2 0 1 3 5
(0) A ist (1) Vektor[n] von (2) Zeigern auf (3) Funktion (int) mit Wert (4) Zeiger auf (5)
Vektor[n] von (6) int.
typedef int Typ5[n]; // Typ5 ist Vektor[n] von int typedef Typ5 *Typ4; // Typ4 ist Zeiger auf Typ5 typedef Typ4 Typ3 (int); // Typ3 ist Funktion (int) mit Wert Typ4 typedef Typ3 *Typ2; // Typ2 ist Zeiger auf Typ3 typedef Typ2 Typ1[n]; // Typ1 ist Vektor[n] von Typ2
Typ1 a;
Aufgabe 10:
"
Quadrat (b+c)
" wird zu " b+c*b+c
" erweitert (was gleich b + (c*b) + c
ist).
Doppelt
ist ein Makro ohne Parameter, weil nach dem Namen ein Leerzeichen folgt!
Deshalb wird "
Doppelt (b+c)
" zu "
(x) x*x (b+c)
" erweitert (Syntaxfehler).
Ersatz: inline Quadrat (int x) {return x*x;} inline Doppelt (int x) {return x+x;}
Aufgabe 11:
Die Klasse bitset
wird erweitert: class bitset { public:
...
bitset (int i); // Konversion int --> bitset
operator int (); // Konversion bitset --> int
...
};
Implementierung: inline bitset::bitset (int i) {bits = i;} inline bitset::operator int () {return bits;}
Aufgabe 12:
Wenn der Gleichheitsoperator nicht überladen wird, verwendet der Compiler die implizite
Konvertierung nach double
. Dabei kann das Resultat aber durch Rundungsfehler verfälscht werden. Erst, wenn Sie auch die Typumwandlung ( operator double
) entfernen, gibt der Compiler eine Fehlermeldung aus.
Aufgabe 13:
ptr1
und ptr2
sind vom gleichen Typ Zeiger auf
Klasse1
. Daher setzt der Compiler in den beiden Anweisungen
ptr1->frueheBindung();
ptr2->frueheBindung(); die Funktion der Klasse 1 ein. In den beiden Anweisungen
ptr1->spaeteBindung();
ptr2->spaeteBindung(); wird eine virtuelle Funktion aufgerufen. Deshalb werden (zur Laufzeit) die
Funktionsversionen nach dem Typ des Objekts, auf das der Zeiger zeigt, ermittelt.
Insgesamt werden so die vier Zeilen ausgegeben: früheBindung-Klasse1 früheBindung-Klasse1 späteBindung-Klasse1 späteBindung-Klasse2
C++ hat zwar auf den ersten Blick einen recht kleinen Sprachumfang. Viele Schlüsselwörter und Operatoren haben allerdings in verschiedenen Kontexten unterschiedliche
Bedeutungen. Hier finden Sie eine Übersicht mehrdeutiger Sprachelemente, die Ursache von Programmierfehlern sein können:
(1) const
-Variablen: Können nicht verändert werden.
(2) const
-Zeiger: Nicht der Zeiger ist konstant, sonden das Objekt, das er adressiert.
(3) const
Elementfunktion
(1) befreundete Klasse (darf auf private Elemente zugreifen).
(2) befreundete Member-Funktion
(1) Überladen von Operatoren (member/friend)
(2) implizite Typkonvertierung
(1) Funktionen und globale Variablen: Sichtbarkeit auf die Datei beschränkt.
(2) lokale Variablen: Lebensdauer auf Programmlaufzeit ausgedehnt.
(3) Klassenelemente: Nur einmal für eine Klasse vorhanden.
(1) virtuelle Funktion
(2) virtuelle Basisklasse (bei Mehrfachvererbung)
(1) leerer Ergebnistyp von Funktionen: void gotoxy (int x, int y)
(2) Bezeichnung einer leeren Parameterliste, z. B.: int rand (void)
Diese Verwendung ist in C++ überflüssig. Sie ist in ANSI-C nötig, um klarzumachen, daß keine Parameter vergessen worden sind.
(3) void *
bezeichnet universellen Zeiger.
(4) In Zortech C++: leere Anweisung. Hiermit wird angedeutet, daß wirklich eine leere
Anweisung gewünscht wird. Beispiel while (! keypressed ()) void;
Der Zortech-
Compiler gibt hier eine Warnung aus, wenn void
weggelassen wird.
(1) Vorbedingung der while
-Schleife
(2) Nachbedingung in do
-Schleife
(1) Trenner zwischen Funktionsparametern
(2) Komma-Operator
(1) Funktionsaufruf bzw. Parameterliste
(2) Typ-Umwandlung:
Typname
(
Ausdruck
)
(3) Cast-Operator:
(
Typ
)
Ausdruck
(1) Binär: Multiplikationsoperator
(2) Unär, präfix: Dereferenzierungsoperator (auch zur Zeigerdefinition)
(1) Unär, präfix: Adreß-Operator
(2) Unär, postfix: Referenz-Operator
(3) Binär: Bitweise Und-Verknüpfung
(1) Bitweise Negation
(2) Bezeichnung des Destruktors
(1) Binär: Klassenspezifikation
(2) Unär: Ansprechen globaler Objekte
C++ ist eine Fortentwicklung der Sprache C. Das heißt aber nicht, daß jedes C-Programm automatisch auch mit dem C++-Compiler übersetzt werden kann. Die Kompatibilität besteht vielmehr darin, daß C-Moduln und C++-Moduln beliebig zu lauffähigen Programmen gebunden werden können.
C selbst hat mehrere Entwicklungsstufen durchlaufen. In der ursprünglichen Version vor der ANSI-Normierung gab es keinerlei Überprüfung von Funktionsaufrufen auf korrekte
Parameter- und Rückgabetypen. C war eine Sprache von Freaks für Freaks. Was machte es da schon, daß ein Programm vom Compiler klaglos übersetzt wurde, dann beim Lauf abstürzte und ein langwieriges Suchspiel begann. Der Pascal-Entwickler Niklaus Wirth hat kürzlich wieder in einem Interview (iX 5/1991) die Sprache C in Grund und Boden verdammt: "Natürlich weiß ich auch, daß man mit der schönsten Programmiersprache scheußliche Programme konzipieren kann. Da gibt es leider so viele Beispiele. Mit C ist es
weitaus schlimmer: man kann einfach alles damit machen, wenn man gerissen ist. Da wird dann herumgetrickst, ganz schrecklich ist das."
Während C durch die ANSI-Norm wohl nun eine stabile Sprachdefiniton ist, wird C++ sich durchaus noch weiterentwickeln. Der in diesem Buch behandelte Sprachstandard (wie er in
Borland C++ implementiert ist) entspricht der 1989 erschienenen Version 2.0 des
Sprachentwicklers (AT&T). In früheren Versionen gab es noch keine mehrfache
Vererbung. Auch wurde die Typstrenge etwas lockerer gehandhabt.
C++ hat zwar viele Mängel von C abgelegt. Trotzdem ist es wohl immer noch eine gefährliche Programmiersprache. Das liegt einerseits daran, daß viele Sprachkonzepte etwas undurchsichtig und unsystematisch entworfen sind, andererseits an den vielen
überflüssigen Freiheiten, die einen einheitlichen Programmierstil geradezu torpedieren.
Dieses Buch sollte ein wenig dazu beitragen, die in C++ verborgenen Gefahren auf ein erträgliches Maß zu begrenzen.
Op. Bezeichnung Anwendung
:: Klassenspezifikation Klasse :: Element
:: globales Objekt
::
Name
. Elementauswahl Record .
Element
-> dto. mit Dereferenzierung Zeiger
->
Element
[]
Vektorelement Zeiger
[ int-Ausdruck
]
() Funktionsaufruf Fkt-Ausdr. ( Parameterliste )
()
Typumwandlung Typ
(
Ausdruck
) sizeof Größe sizeof ( Ausdruck ) sizeof
Größe sizeof (
Typ
)
++ Prä-Inkrementierung ++ Lvalue
++
Post-Inkrementierung Lvalue
++
--
Prä-Dekrementierung
--
Lvalue
-- Post-Dekrementierung Lvalue --
~ bitweise Negation
~
Ausdruck
! logische Negation
!
Ausdruck
+ unäres Plus + Ausdruck
- unäres Minus Ausdruck
&
Adreßoperator
&
Lvalue
*
Dereferenzierung
*
Ausdruck new Speicherreservierung new Typ new
Speicherreservierung new
Typ
[ int-Ausdruck
] delete
Speicherfreigabe delete
Zeiger delete Speicherfreigabe delete [ int-Ausdruck ] Zeiger
() Typkonvertierung (Cast) ( Typ ) Ausdruck
->*
Auswahloperationen für Zeiger (in
.* diesem Buch nicht behandelt)
*
Multiplikation Ausdruck
*
Ausdruck
/ Division Ausdruck / Ausdruck
% Rest (Modulo) Ausdruck % Ausdruck
+
Addition Ausdruck
+
Ausdruck
-
Subtraktion Ausdruck
-
Ausdruck
<< bitweises Linksschieben Ausdruck
<<
Ausdruck
>> bitweises Rechtsschieben Ausdruck
>>
Ausdruck
< kleiner als Ausdruck
<
Ausdruck
> größer als Ausdruck
>
Ausdruck
<= kleiner als oder gleich Ausdruck <= Ausdruck
>= größer als oder gleich Ausdruck >= Ausdruck
==
Gleichheit Ausdruck
==
Ausdruck
!= Ungleichheit Ausdruck != Ausdruck
& bitweises Und Ausdruck
&
Ausdruck
^ bitweises exklusives Oder Ausdruck ^ Ausdruck
| bitweises Oder Ausdruck
|
Ausdruck
&& logisches Und Ausdruck && Ausdruck
|| logisches Oder Ausdruck
||
Ausdruck
? : bedingter Ausdruck Ausdruck ?
Ausdruck : Ausdruck
=
Zuweisung Lvalue
=
Ausdruck
*= Zuweisung mit Multiplikation Lvalue *= Ausdruck
/=
Zuweisung mit Division Lvalue
/=
Ausdruck
%=
Zuweisung mit Rest Lvalue
%=
Ausdruck
+=
Zuweisung mit Addition Lvalue
+=
Ausdruck
-=
Zuweisung mit Subtraktion Lvalue
-=
Ausdruck
<<= Zuweisung mit Linksschieben Lvalue <<= Ausdruck
>>=
Zuweisung mit Rechtsschieben Lvalue
>>=
Ausdruck
&=
Zuweisung mit bitweisem Und Lvalue
&=
Ausdruck
|= Zuweisung mit bitweisem Oder Lvalue |= Ausdruck
^= Zuweisung mit bitweisem exkl. Oder Lvalue ^= Ausdruck
,
Sequenzoperator Ausdruck
,
Ausdruck
[Gamm95]
E. Gamma, R. Helm, R. Johnson, J. Vlissides:
Design Patterns, Elements of
Reusable Object-Oriented Software, Addison-Wesley, Reading, 1995.
[Kern88]
B. W. Kernighan, D. M. Ritchie:
The C Programming Language, 2nd Ed.,
Prentice-Hall, Englewood Cliffs, N.J., 1988.
[Lipp89]
S. B. Lippmann:
A C++ Primer, Addison-Wesley, Reading, 1989. Deutsche
Übersetzung:
C++ Einführung und Leitfaden, Addison-Wesley, Bonn, 1990.
[Louis96]
D. Louis:
C und C++. Programmierung und Referenz, Markt und Technik, 1996.
[Meye88]
B. Meyer:
Object-oriented Software Construction, Prentice Hall, New York
1988. Deutsche Übersetzung:
Objektorientierte Software-Entwicklung, Hanser, München,
1990.
[Scha95]
M.Schader, S. Kuhlins:
Programmieren in C++. Einführung in den
Sprachstandard C++, Springer, Berlin, 3. Aufl. 1995.
[Seth89]
R. Sethi:
Programming Languages. Concepts and Constructs, Addison-Wesley,
Reading, 1989.
[Stro91]
B. Stroustrup:
The C++ Programming Language, Bell Telephone Laboratories,
Incorporated, 2. Aufl. 1991 (Reprinted with corrections December, 1994).
Deutsche Übersetzung:
Die C++ Programmiersprache, Addison-Wesley, Bonn, 2. Aufl. (4.
überarbeiteter Nachdruck) 1994.
[Watt90]
D. A. Watt:
Programming Language Concepts and Paradigms, Prentice Hall, New
York, 1990
[Wien88]
R. Wiener, L. Pinson:
An Introduction to Object-Oriented Programming and
C++, Addison-Wesley, Reading, 1988.
* Your assessment is very important for improving the work of artificial intelligence, which forms the content of this project