Programmierung II C++

Werbung
Programmierung II
C++
Thomas Letschert
FH Giessen–Friedberg
(Version vom 10. September 2002)
i
Programmierung II
ii
Zusammenfassung
Dieser Text ist die Unterlage zu Programmierung II.
Die Lernziele der Veranstaltung sind:
1. Konzepte der objektbasierten Programmierung kennen und anwenden können:
Prinzipien der Kapselung: Schnittstelle und Implementierung, Geheimnisprinzip; Klassenkonzept: Abstrakte und konkrete Datentypen; Freundschaften kennen und einsetzen.
2. Basiskonzepte der objektorientierten Programmierung kennen und anwenden können:
Vererbung: öffentliche Ableitungen und Polymorphismus; Definition einfacher Klassenhierarchien einsetzen; Technische Details der Klassendefinition in C++ beherrschen;
3. Datenstrukturen aufbauen und verwalten können:
Stack und Heap; Zeiger und Referenzen; new und delete; Behälterklassen mit Iteratoren.
4. Templates einsetzen können:
Funktionstemplates; Klassentemplates.
5. Einfache Dateiverarbeitung beherrschen:
Dateikonzept in C++; Daten beliebigen Typs schreiben und lesen; Konzept der persistenten Daten; elementare Dateistrukturen.
6. Elementare Bestandteile einer Entwicklungsumgebung kennen, unterscheiden und einsetzen:
Compiler, Binder, Lader; Dateiinklusion; getrennte Übersetzung; Make; Objektbibliotheken.
7. Klassenbiliotheken einsetzen: Einsatz der Standard Template Library (STL) zur Lösung von algorithmischen Standardproblemen. Die STL wird nur unsystematisch an Hand einiger einfacher Beispiele behandelt.
8. Einsatz von UML zur Beschreibung von Programmstrukturen. Einfache Klassenbeziehungen können mit
Hilfe einiger der wichtigsten Elemente der UML–Notation dargestellt werden. UML wird nur sehr unsystematisch an Hand weniger sehr einfacher Beispiele behandelt.
Die beschriebenen Elemente von C++ entsprechen dem aktuellen Sprachstandard.
Die vorausgesetzten Kenntnisse und Fertigkeiten sind im Wesentlichen durch die Lernziele der Veranstaltung
Programmierung I beschrieben. Daneben wird vorausgesetzt, dass der Leser mit den grundlegensten Begriffen
der Informatik vertraut ist. Zahlsysteme sowie das Konzept einer Grammatik sollten beispielsweise bekannt sein.
Die Übungen und Lösungshinweise sind ein wesentlicher Bestandteil dieses Lehrmaterials. Es wird dringend
empfohlen sich mit ihnen zu beschäftigen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
iii
Inhaltsverzeichnis
1 Klassen
1
1.1
Klassen: Verbunde mit Geheimnissen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1
1.2
Kapselung: Klassen als Softwarekomponenten mit Schnittstelle und Implementierung . . . . . . .
4
1.3
Freunde . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
1.4
Überladene Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11
1.5
Inline Funktionen und Methoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13
1.6
Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
14
1.7
Initialisierung von Objekten, Initialisierer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
18
1.8
Statische Komponenten einer Klasse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
23
1.9
Konstante Komponenten, konstante Parameter . . . . . . . . . . . . . . . . . . . . . . . . . . . .
26
1.10 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
31
2 Objektorientierte Programmierung: Entwurf von Klassendefinitionen
37
2.1
Referenzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
37
2.2
Ein– und Ausgabe selbst definierter Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
39
2.3
Zustands– und Wertorientierte Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
40
2.4
Von Abstrakten zu Konkreten Datentypen: Zuweisung und Kopie . . . . . . . . . . . . . . . . . .
46
2.5
Beziehungen zwischen Klassendefinitionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
53
2.6
Beispiel: Geometrische Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
2.7
Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
57
3 Verweise und dynamische Objekte
60
3.1
Zeiger (Pointer) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
60
3.2
Definition von Zeigervariablen und –Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
64
3.3
Verkettete Objekte, der Null–Zeiger . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
68
3.4
Dynamisch erzeugte Objekte: Der new– und delete–Operator . . . . . . . . . . . . . . . . . .
72
3.5
Zeiger und Felder . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
76
3.6
Zeiger, Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
82
3.7
Zeiger und const . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
85
3.8
Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
91
4 Programmorganisation
100
4.1
Konsolenanwendungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100
4.2
Speicherbereiche und Speicherschutz . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
4.3
Übersetzen und Binden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
4.4
Präprozessor und Inklusionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106
4.5
Getrennte Übersetzung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
4.6
Konventionen zur Gestaltung von Übersetzungseinheiten . . . . . . . . . . . . . . . . . . . . . . 112
4.7
Make . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
4.8
Bibliotheken . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 124
Programmierung II
iv
4.9
Beispiel: Geometrische Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127
4.10 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130
5 Datenstrukturen
132
5.1
Verkettete Liste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132
5.2
Menge als konkreter Datentyp . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137
5.3
Wiederverwendung: Die Liste als allgemeine Behälterklasse . . . . . . . . . . . . . . . . . . . . 139
5.4
Cursorkonzept: Liste mit aktueller Position . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
5.5
Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
5.6
Konstante Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150
5.7
Beispiel Syntaxbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152
5.8
Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
6 Schablonen (Templates)
163
6.1
Funktionsschablonen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163
6.2
Klassenschablonen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 170
6.3
Schablone mit Freunden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174
6.4
Datenstrukturen und Algorithmen mit Schablonen . . . . . . . . . . . . . . . . . . . . . . . . . . 179
6.5
Standard Template Library STL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
6.6
Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 189
7 Vererbung
192
7.1
Basisklasse und Abgeleitete Klasse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 192
7.2
Vererbung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 194
7.3
Sichtbarkeit und Vererbung: public, protected und private . . . . . . . . . . . . . . . . . . . . . . 199
7.4
Vererbungstypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203
7.5
Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 204
7.6
Beispiel Sortierte Liste: Abgeleitete Klasse als konkreter Datentyp . . . . . . . . . . . . . . . . . 212
7.7
Beispiel Syntaxbaum: Ableitung um Typvarianten zu ermöglichen . . . . . . . . . . . . . . . . . 214
7.8
Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 218
8 Polymorphismus
222
8.1
Virtuelle Methoden: Das Gleiche auf unterschiedliche Arten tun . . . . . . . . . . . . . . . . . . 222
8.2
Polymorphismus: Typanalyse zur Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 224
8.3
Konstruktoren, Destruktoren, Zuweisungen und Polymorphismus . . . . . . . . . . . . . . . . . . 227
8.4
Abstrakte Basisklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 229
8.5
Umschlagklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231
8.6
Geklonte Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
8.7
Beispiel: Tupel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 237
8.8
Beispiel: Syntaxbaum, Implementierung mit Polymorphismus . . . . . . . . . . . . . . . . . . . 239
8.9
Typinformationen und dynamische Typkonversionen . . . . . . . . . . . . . . . . . . . . . . . . 243
8.10 Beispiel: Multimethoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 246
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
v
8.11 Vererbung und Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 252
8.12 Übersicht: Mechanismen der Vererbung und des Polymorphismus . . . . . . . . . . . . . . . . . 254
8.13 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 257
9 Dateien
263
9.1
Sequentielle Dateien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263
9.2
Ein– und Ausgabe selbst definierter Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 269
9.3
Darstellungskonversionen und String–Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . 273
9.4
Dateien in Iteratoren umwandeln . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 277
9.5
Wahlfreier Zugriff . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 281
9.6
Dateistrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 285
9.7
Binärdateien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 288
9.8
Dateiabstraktion: Datei als Speicher von Sätzen fester Länge . . . . . . . . . . . . . . . . . . . . 293
9.9
Iteratoren auf abstrakten Satz–Dateien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 299
9.10 Verwaltung von Sätzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 300
9.11 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 303
A STL: wichtige Behälterklassen
305
A.1 Vektoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 305
A.2 Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 305
A.3 Mengen und Multimengen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 306
A.4 Abbildungen (Sortierte Schlüssel–Wert–Paare) . . . . . . . . . . . . . . . . . . . . . . . . . . . 307
B Lösungshinweise
309
B.1 Lösungshinweise zu Kapitel 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 309
B.2 Lösungshinweise zu Kapitel 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 319
B.3 Lösungshinweise zu Kapitel 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 325
B.4 Lösungshinweise zu Kapitel 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 337
B.5 Lösungshinweise zu Kapitel 5 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 341
B.6 Lösungshinweise zu Kapitel 6 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 347
B.7 Lösungshinweise zu Kapitel 7 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 350
B.8 Lösungshinweise zu Kapitel 8 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 353
B.9 Lösungshinweise zu Kapitel 9 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 359
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
1
1
Klassen
1.1
Klassen: Verbunde mit Geheimnissen
1.1.1
Klassen: Verbunde mit privaten und öffentlichen Komponenten
Klassen sind eine Variante der Verbunde (Structs). Umgekehrt sind Verbunde eine Sonderform der Klassen. Einem
Verbund S, der als
struct S
T1 s1;
T2 s2;
...
;
class S
public:
T1 s1;
T2 s2;
...
;
definiert wurde, entspricht die Klasse
Das Schlüsselwort struct wurde hier durch class ersetzt und public leitet die Definition der Komponenten
ein. Die Kennzeichnung public (= öffentlich) sagt, dass s1, s2 und so weiter public = öffentlich sind.
Ein Verbund ist eine Klasse deren Komponenten zunächst einmal öffentlich (public) sind. Umgekehrt sind Klassen Verbunde, bei denen alle Komponeten zunächst einmal privat (private) sind. Einer Klasse K, die als
class K
T1 s1;
T2 s2;
...
;
definiert wurde, entspricht der Verbund
struct K
private:
T1 s1;
T2 s2;
...
;
In Klassen ist also alles privat und in einem Verbund ist alles public. Durch Angabe der Schlüsselworte public
und private kann diese Voreinstellung verändert werden.
In Klassen und Verbunden kann zwischen öffentlichen und privaten Komponenten
unterschieden werden.
In Verbunden sind Komponenten öffentlich, wenn sie nicht explizit als privat gekennzeichnet werden. In Klassen sind Komponenten privat, wenn sie nicht explizit als
öffentlich gekennzeichnet werden.
Der Einfachheit halber, und entsprechend der üblichen Praxis, belassen wir es im Folgenden dabei, dass in Verbunden alles öffentlich ist und benutzen Klassen, wenn zwischen öffentlich und privat unterschieden werden soll.
1.1.2
Verwendung von public und private
Die Klasse Vektor mit öffentlichem x und y
class Vektor
public:
float x, y;
;
ist das Gleiche wie der Verbund
struct Vektor
float x, y;
;
Lässt man in der Klassendefinition aber public weg, dann werden x und y zu privaten Komponenten von
Vektor:
class Vektor
float x, y;
; // x und y sind privat
Programmierung II
2
Mit dem Schlüsselwort private kann man dies auch explizit ausdrücken:
class Vektor {
private:
float x, y;
};
// x und y sind privat
Öffentliche und private Komponenten können in einer Klasse auch gemischt auftreten:
class Vektor {
public:
float x;
private:
float y;
};
x ist jetzt öffentlich und y ist privat. Genau wie Datenkomponenten können auch Methoden – inklusive Operatoren
– öffentlich oder privat sein. Beispiel:
class Vektor {
public:
Vektor operator+ (Vektor);
private:
float x, y;
};
// oeffentliche Methode
// private Daten
Nur die Komponenten einer Klasse, die in der Definition der Klasse hinter dem Schlüsselwort public erscheinen,
sind öffentlich, die anderen sind privat. Beispiel:
class S1 {
int x;
int f (int);
public:
int y;
int g (int);
};
// privat
// privat
// oeffentlich
// oeffentlich
Mit dem Schlüsselwort public wird der private Teil beendet und ein öffentlicher begonnen. Mit private wird
ein öffentlicher Teil beendet und ein privater begonnen. Die beiden Schlüsselworte können beliebig oft und in
beliebiger Reihenfolge angewendet werden. Beispiel:
class S2 {
int x;
public:
int y;
int g (int);
private:
int f (int);
};
1.1.3
// privat
// oeffentlich
// oeffentlich
// privat
Eingeschränkte Sichtbarkeit der privaten Komponenten einer Klasse
Nach so viel Formalismus wird es Zeit sich mit der Bedeutung von public und private zu beschäftigen. Die
öffentlichen Komponenten einer Klasse verhalten sich in jeder Beziehung genau so wie die “normalen” Komponenten von Verbunden (struct–Typen). Die privaten Komponenten dagegen haben eine eingeschränkte Sichtbarkeit:
sie können nur innerhalb der Klasse verwendet werden, zu der sie gehören. Beispiel:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
class S3 {
int x;
public:
int f(int);
int y;
};
3
// privat
// oeffentlich
// oeffentlich
Die Komponente x kann nur von der Klasse S3 selbst verwendet werden. Beispielsweise in der Methode f, die zu
S3 gehört:
int S3::f (int p) {// Wir sind in S3::f, also innerhalb von S3
++x;
// OK: privat aber intern genutzt
return y;
// OK: y ist oeffentlich
}
Außerhalb des Sichtbarkeitsbereichs von S3 ist x im Gegensatz zu y und f nicht zugreifbar:
int main () {
S3 s;
s.x = 10;
++s.y;
}
// Wir sind in main, also ausserhalb von S3
// FEHLER: x ist eine private Komponente der Klasse S3
// OK
: y ist "offentlich
Hier wird von main aus (der Punktoperator “.x” taucht in main auf) versucht auf s.x zuzugreifen, das ist nicht
erlaubt. x ist eine private Komponente von S3 und der “Zugreifer” main gehört natürlich nicht zu S3. Der Zugriff
auf s.y ist dagegen von überall her uneingeschränkt möglich.
Private Komponenten dürfen nur in Methoden der Klasse benutzt werden, zu der sie
gehören.
Der Zugriff auf private Komponenten einer Klasse ist darum nur von dieser Klasse aus erlaubt. Es kommt dabei
darauf an, wo der Zugriff (der Punktoperator) auftaucht; in einer Methode der Klasse: OK!, Irgendwo anders: Nicht
OK!
1.1.4
Klassen, nicht Objekte, haben eine eigene Privatsphäre
public und private operieren auf der Ebene von Klassen, nicht auf der Ebene von Objekten. Mit private
werden die Komponenten eines Objekts vor dem Zugriff von Funktionen und Methoden anderer Klassen geschützt.
Die Methoden der Klasse, zu der ein Objekt gehört, haben vollen Zugriff auf die eigenen Komponenten und auf
die Komponenten aller Objekte der gleichen Klasse. Solange sie zu gleichen Klasse gehören können also Objekte
auf die privaten Komponenten anderer Objekte zugreifen. Beispiel:
class C {
public:
int
a;
void g();
private:
int b;
};
C x, y;
void C::g() {// wir sind in C: alles erlaubt
++b;
// OK: eigene private Komponente
++x.b;
// OK: private Komponente von x
}
int main () { // wir sind in main, nicht in C: nur Zugriff auf public erlaubt
y.g(); // OK: y greift in Methode g auf Privates von sich selbst und von x zu
++x.b; // FEHLER
}
Programmierung II
4
Innerhalb der Klasse C, also in einer Methode von C, ist jeder Zugriff erlaubt, außerhalb nur der auf öffentliche
Komponenten. Von welchem Objekt dabei auf welches andere Objekt zugegriffen wird, ist völlig irrelevant.
private heißt private Komponente der Klasse insgesamt: Für den internen Gebrauch von allen Methoden aller Objekte der Klasse.
Klassen definieren die Familie ihrer Objekte. Innerhalb einer Familie gibt es keine Privatsphäre. Mitglieder der
eigenen Familie dürfen alles anfassen. Fremden klopft der Compiler allerdings auf die Finger, wenn sie die Privatsphäre der Familie (= Klasse) nicht achten.
private ist also gar nicht so privat. Innerhalb einer Methode einer Klasse S kann auf die privaten Komponenten
aller dort sichtbaren Objekte der Klasse S zugegriffen werden. Sichtbar sind nach den üblichen Sichtbarkeitsregeln
die lokalen Variablen, die Parameter und die globalen Variablen – soweit jeweils vorhanden.
1.2
1.2.1
Kapselung: Klassen als Softwarekomponenten mit Schnittstelle und Implementierung
Öffentlich und privat als Schnittstelle und Implementierung
Private Klassenkomponenten mit ihrer beschränkten Sichtbarkeit unterscheiden sich von den Komponenten eines
Verbunds nur dadurch, dass man weniger mit ihnen machen kann. Das neue Konzept der Klassen bringt also eine
Einschränkung gegenüber den Verbunden! Diese Beschränkung soll die Entwicklung komplexer Softwaresysteme
unterstützen. Mit ihr kann eine Klasse als Softwarekomponente mit klar definierter Funktionalität und Implementierung realisiert werden.
Mit private und public können die Komponenten einer Klasse in zwei Gruppen aufgeteilt werden. Private
Komponenten sind nur für interne Zwecke da. Sie sind Hilfskonstrukte der Implementierung und gehen die Benutzer der Klasse nichts an. Die öffentlichen sind dagegen dazu gedacht, von außen benutzt zu werden. Mit der
Unterscheidung in privat und öffentlich können die Komponenten einer Klasse sortiert werden. Man nennt dies das
Prinzip der Kapselung:
Öffentliche Schnittstelle: Von außen sicht– und benutzbare Bestandteile.
Private Implementierung: Rein interne Bestandteile, die den Benutzer nicht zu interessieren haben.
Mit “Benutzer” ist dabei der Quellcode – und dessen Entwickler – gemeint, der nicht zur Klasse gehört, sondern
sie nur benutzt.
1.2.2
Zwei Arten von Benutzern
Bei dem Wort “Benutzer” denkt man meist an einen Menschen, der ein fertiges (Software–) Produkt benutzt. Der
Benutzer steht im Gegensatz zum Entwickler. Der eine stellt etwas her, der andere benutzt es.
Diese Unterscheidung ist für die Wirklichkeit der Softwarebranche zu grob. Ein Entwickler hat es nur recht selten
mit dem sogenannten End–Benutzer zu tun. Da Software normalerweise im Team entwickelt wird, kommuniziert
er meist mit anderen Entwicklern. Zwischen den Entwicklern, die an einem Projekt arbeiten, bestehen ebenfalls
Benutzt–Relationen. Softwarekomponenten benutzen andere Komponenten um ihre eigene Funktion zu erfüllen.
In folgenden Beispiel benutzt die kgv–Funktion die ggt–Funktion:
unsigned int ggt(unsigned int a, unsigned int b) {
while (a != b)
if ( a > b ) a = a-b; else b = b-a;
return a;
}
unsigned int kgv(unsigned int a, unsigned int b) {
return a*b/ggt(a,b);
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
5
Die Benutzt–Relation zwischen Softwarekomponenten ist von ganz entscheidender Bedeutung für die Strukturierung von Software. Ein Benutzer ist darum im Folgenden in der Regel ein Stück Software bzw. dessen Autor. Der
Mensch, der ein fertiges Produkt benutzt, wird explizit End–Benutzer genannt.
1.2.3
Schnittstelle und Implementierung
Teilt man Softwareprodukte in Komponenten auf, die sich gegenseitig benutzen, dann kommt man naturgemäß
dazu, die einzelnen Komponenten weiter in Schnittstelle und Implementierung aufzuteilen. Für die kgv–Funktion
oben (und deren Autorin) ist nur die Schnittstelle von ggt interessant: Was gebe ich wie hinein, damit was wie
herauskommt. Die Implementierung – Schleife oder Rekursion – ist vom Standpunkt des Benutzers völlig unerheblich. Hauptsache es funktioniert, wie ist egal.
Da alle halbwegs komplexen Systeme in Komponenten aufgeteilt werden, ist die Trennung von Schnittstelle und
Implementierung ein weitverbreitetes Konstruktionsprinzip technischer Systeme, das das Leben enorm erleichtert.
Ein Lichtschalter beispielsweise hat eine Schnittstelle und eine Implementierung. Man kann mit ihm das Licht
an– und ausmachen, indem die Schalterfläche umgelegt wird. Das ist seine Schnittstelle. Intern werden Kontakte
geschlossen. Das ist die Implementierung. Selbstverständlich ist es auch möglich den Schalter aufzuschrauben
und die Kontakte direkt zu verbinden. Das ist aber eine ebenso unübliche wie unerwünschte Art den Schalter zu
bedienen.
Dieses bewährte Prinzip wird auf die Software übertragen indem man streng unterscheidet zwischen den öffentlichen, für den Benutzer gedachten, Komponenten einer Klasse und den internen, den privaten. (Siehe Abbildung
1):
Benutzer
public
private
Schnittstelle
Implementierung
benutzte Komponente
Abbildung 1: Das Prinzip der Kapselung
1.2.4
Mengen–Typ als Verbund mit informaler Schnittstelle
Ein Verbund–Typ Menge zur Darstellung von Mengen positiver ganzer Zahlen könnte wie folgt aussehen:
//Die Klasse:
struct Menge {
// Zugriff (Schnittstelle):
void einfuege (int);
void entnehme (int);
bool istEnthalten (int);
// interne Speicherung:
int
m[10]; // Mengenelemente
int
a;
// Zahl der belegten Pl"atze in m
};
//Ihr Benutzer:
int main () {
Menge m;
Programmierung II
6
m.einfuege (1);
m.a = 2;
m.m[2] = 3;
// erwuenschter Zugriff
// nicht erwuenschter Zugriff
// nicht erwuenschter Zugriff
}
Die Schnittstelle besteht hier aus den Methoden einfuege, entnehme und istEnthalten. Die Komponenten m und a dienen der internen Speicherung und Verwaltung der Mengenelemente. Sie sollten nicht direkt benutzt
werden. Ihre genaue Verwendung ist für die Benutzung des Typs darum irrelevant: sie gehören zur Implementierung. Der Benutzer sollte seine Finger von ihnen lassen.
Durch Aufruf der Methode einfuege sollten Elemente eingefügt werden. Man kann statt dessen auch direkt auf
m und a zugreifen. Das ist aber so unerwünscht wie das Einschalten des Lichts durch Abschrauben des Schalterdeckels und Kurzschluss der internen Kontakte. Man kann darüber den Kopf schütteln, verhindern kann man es
aber nicht.
1.2.5
Mengen–Typ als Klasse mit expliziter Trennung von Schnittstelle und Implementierung
Mit dem Klassen–Konstrukt (mit der Aufteilung in öffentlich und privat) kann die Zweiteilung klar zum Ausdruck
gebracht und (vom Compiler) überwacht werden. private entspricht dabei dem Deckel auf dem Lichtschalter,
der den direkten Zugriff auf die Kontakte verhindert:
// Die Klasse
class Menge {
public:
void einfuege
void entnehme
bool istEnthalten
private:
int m[10];
int a;
};
//Ihr Benutzer:
int main () {
Menge m;
m.einfuege (1);
m.a = 2;
m.m[2] = 3;
}
// Aha, Schnittstelle:
(int); // zur allgemeinen Benutzung
(int); // freigegeben!
(int);
// Aha, Implementierung:
// geht nur Mengenimplementierer
// was an!
// OK, Zugriff moeglich
// FEHLER: Zugriff nicht moeglich
// FEHLER: Zugriff nicht moeglich
Die Aufteilung in Schnittstelle und Implementierung in diesem Beispiel ist:
Schnittstelle:
– void einfuege (int);
– void entnehme (int);
– bool istEnthalten (int);
Implementierung:
– int m[10];
– int a;
– Die Körper der Methoden – privat oder öffentlich – gehören zur Implementierung:
void Menge::einfuege (int i)
...
void Menge::entnehme (int i)
...
bool Menge::istEnthalten (int i)
...
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
7
Bei dieser Mengen–Klasse können Elemente nur auf dem offiziellen Weg über die entsprechende Methode eingefügt oder entnommen werden. Ein direkter Zugriff auf die Innereien ist nicht möglich. Der Deckel kann nicht
abgeschraubt werden.
1.2.6
Klassendiagramm zur Darstellung einer Klasse
Softwarekonponenten werden der besseren Übersichtlichkeit halber oft grafisch dargestellt. Nach vielen Jahren des
völligen Chaos’ durch konkurrierende Darstellungskonventionen hat sich jetzt UML 1 allgemein durchgesetzt. Die
Klasse Menge wird in UML folgendermaßen dargestellt (siehe Abbildung 2):
Klassenname
Menge
Datenkomponenten
−m
−a
+ einfuege
+ entnehme
+ istEnthalten
Methoden
+: public (Bestandteil der Schnittstelle)
−: private
Abbildung 2: Klassendiagramm der Menge
Klassen werden durch Rechtecke dargestellt. Oben enthalten sie den Klassennamen, es folgen die Datenkomponenten und dann die Methoden. Öffentliche Komponenten werden mit einem + gekennzeichnet, private mit einem
-. Alles mit einem + gehört also zur Schnittstelle. So wie in diesem Beispiel sind sehr oft alle Datenkomponenten
privat. Die Kennzeichnungen + und - kı̈nnen auch weggelassen werden.
Die Aufteilung eines Stücks Software in Schnittstelle und Implementierung kann in UML leider nur mit optionalem
+ und -, ausgedrückt werden. 2
1.2.7
Geheimnisse nutzen den Anwendern: Was ich nicht weiß, kann ich nicht missverstehen oder vergessen.
Die Kapselung, also die Trennung von Schnittstelle und Implementierung, wird oft auch als Geheimnisprinzip
bezeichnet. Die Implementierung ist ein Geheimnis der Klasse. Sie geht keinen ihrer Benutzer etwas an.
Die klare Trennung von Interna und benutzbarer Schnittstelle nutzt zunächst einmal den Benutzern/Anwendern der
Klasse. Sie können sich auf das konzentrieren, was als öffentlich deklariert und somit zur Benutzung freigegeben
wurde.
Jeder, der ein komplexes System anwenden muss, wird dankbar alles zur Kenntnis nehmen, was er nicht kennen
oder wissen muss. Ein Lichtschalter wird einfach gedrückt. Ich muss mir dabei keine Gedanken darüber machen,
ob es sich um einen einfachen Schalter oder einen Wechselschalter handelt, ob er ein Relais bedient oder direkt
schaltet, welche Kontakte verbunden werden, und so weiter. Ich sehe die Schnittstelle und kann das Ding bedienen.
1.2.8
Geheimnisse nutzen den Implementierern: Was nur ich weis, brauche ich nicht abzusprechen oder
zu erklären.
Auf die privaten Komponenten kann nur innerhalb der Methoden der Klasse selbst zugegriffen werden. Damit ist
garantiert, dass sie jederzeit und ohne Absprache mit dem Benutzer der Klasse geändert, gestrichen oder durch
1 UML ist nicht als direkte Illustration des Quelltextes von Programmen gedacht – es kann aber zu genau diesem Zweck genutzt werden. Es
ist eine Notation für Spezifikationen. UML–Diagramme enthalten darum oft nur das “Wichtige” oder das “was man jetzt schon weiß”. UML,
die Unified Modeling Language, wird in der Softwaretechnik näher behandelt.
2 Importschnittstellen, also das was eine Komonenten von anderen benötigt, können in UML gar nicht dargestellt werden. Es spricht nicht
gerade für den allgemeinen Zustand der Softwaretechnik (und der Informatik allgemein), dass UML – die wichtigste und am weitetesten
verbreitete Notation – das wichtigste Konzept im Softwareentwurf – Komponenten mit Schnittstelle und Implementierung – nur jämmerlich
ausdrücken kann.
Programmierung II
8
gänzlich andere ersetzen werden können.
Die Elektrik eines Hauses kann geändert werden, ohne dass jeder, der das Licht anschalten will, einen Lehrgang
besuchen muss. Alle Lichtschalter haben die gleiche Schnittstelle, egal, ob es sich um Wechsel– oder Relaisschalter handelt. Die einen können darum jederzeit ohne Absprache gegen die anderen ausgetauscht werden. 3
Genauso kann die Implementierung der Menge geändert werden, ohne die Benutzer auch nur darüber informieren
zu müssen.
Kopplung von A und B
Wissen über B, das notwendig ist,
um B zu benutzen.
Komponente
A
Komponente
benutzt
B
Implementierer von A
Abbildung 3: Der Begriff der Kopplung
1.2.9
Kopplung von Softwarekomponenten
Unter Kopplung versteht man die Verzahnung von Softwarekomponenten die dadurch entsteht, dass das Wissen
über das Wesen der einen Bestandteil der anderen ist. So ist der Benutzer der Klasse Menge an die Klasse Menge
gekoppelt, weil er beispielsweise wissen muss, wie die Methode zum Einfügen eines Elementes heißt. Dieses
Wissen ist im Quellcode des Benutzers “fest verdrahtet” und beide sind damit gekoppelt (Siehe Abbildung 3).
struct
void
void
bool
Menge {
einfuege (int);
entnehme (int);
istEnthalten (int);
Jetzt benutzt er schon überall
a als Obergrenze, ich kann meinen Code
darum nicht mehr ändern.
int m[10];
int a;
};
Implementierung
Wie meint sie denn das mit a und m?
Anwendung
War a die Obergrenze in m oder die Anzahl
oder was?
Menge x;
x.m[a++] = 15; ??
oder x.m[++a] = 15; ??
oder x.m[a−−] = 15;
Abbildung 4: Zu enge Kopplung von Anwendung und Implementierung
Die Kopplung von Softwarekomponenten sollte einerseits klar erkennbar und andererseits so klein wie möglich
sein. Der Grad der Kopplung wird durch das Geschick oder Ungeschick der Software–Entwickler bestimmt (siehe
Abbildung 4). Die Unterscheidung von Schnittstelle und Implementierung kann die Kopplung nur dokumentieren:
3 Benutzer, die
direkt auf die Implementierung zugreifen sterben wegen Stromschlag aus.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9
alles was zur Schnittstelle gehört, aber nur das, kann in einer anderen Komponente benutzt werden. Die Kopplung zwischen dem Bneutzer und der Realisation einer Klasse wird auf die Schnittstelle – also die öffentlichen
Datenfelder und Methoden – begrenzt.
Eine Trennung von Privatem und Öffentlichem gibt es auch bei Verbunden – man denke nur an struct Menge
... ;. Bei einer Klasse kann sie aber im Code mit den Schlüsselworten private und public dargelegt und
so vom Compiler überwacht werden.
Es bleibt natürlich weiterhin den Entwicklern überlassen zu entscheiden, was zu welcher Kategorie gehört. Diese
Entscheidung ist oft genug weder eindeutig noch einfach.
1.2.10 private und public dienen dazu den Programmcode zu organisieren
Mit dem Schlüsselwort private wird das Aussehen von Programmtexten beeinflusst. Mit ihm sollen bestimmte
Komponenten einer Klasse – nicht vor Menschen (!) –, sondern vor anderen Textstücken im gleichen Programm
“verborgen” werden. Wobei “verborgen” nichts anderes heißt, als dass der Compiler eine Fehlermeldung ausgibt,
wenn er einen “verborgenen” (privaten) Bezeichner an der falschen Stelle antrifft.
Dies alles bezieht sich nur auf den Quelltext von Programmen. Was wann welcher Mensch sehen oder nicht sehen
darf, ist eine völlig andere Frage. Ob die Programmiererin, die die Definition der Klasse Menge im Beispiel oben
benutzt, die Definition der privaten Komponenten m und a mit eigenen Augen sehen darf, hat vielleicht etwas mit
dem Betriebsklima oder mit Lizenzverträgen zu tun. Es wird ihr aber vom Compiler weder verboten noch erlaubt.
Er kann entsprechende Regeln nicht einmal überwachen.
Der Compiler interessiert sich nicht für die Beziehungen zwischen Menschen sondern nur für Programmtexte. Er
prüft und übersetzt einen Text ohne zu wissen von wem er stammt. Mit private kann der Programmierer dem
Compiler nur eine Absicht für die Organisation des Quelltextes bekannt gegeben werden. Dieser prüft dann, ob
diese Absicht im gesamten Programm auch eingehalten wird.
private hat also ganz und gar nichts mit der Privatsphäre, den Geheimnissen oder den Besitzverhältnissen
zwischen Menschen zu tun. Es trennt lediglich die Schnittstelle (das “ Öffentliche”) und die Implementierung (das
“Private”) in einem Stück Software.
1.3
1.3.1
Freunde
Freundschaft in C++
Freie Funktionen und Methoden anderer Klassen dürfen nicht auf die privaten Komponenten einer Klasse zugreifen. Ein Objekt ist damit vor den indiskreten Zugriffen “fremder” Objekte und Funktionen geschützt. Mit dem
Konzept der Freundschaft kann dieses Prinzip aufgehoben werden.
Ein Freund (in C++) ist jemand, der die Privatsphäre nicht achten muss. Klassen können Funktionen und andere
Klassen zu ihren Freunden erklären. Der Freund hat dann vollen Zugriff auf alle privaten Komponenten.
Freundschaft beruht (zumindest in C++) nicht unbedingt auf Gegenseitigkeit. Wenn Klasse A eine Klasse B zu
ihrem Freund erklärt, erlaubt sie B den Zugriff auf ihre Privatsphäre. Damit hat sie selbst aber noch lange keinen
Zugriff auf die privaten Komponenten von B.
Freunde einer Klasse sind Programmstücke (Klassen oder freie Funktionen) für welche die private–Beschränkungen dieser Klasse nicht gelten.
1.3.2
Funktionen als Freunde
Im folgenden Beispiel erklärt die Klasse Vektor die freie Funktion void drucke(Vektor) zu ihrem Freund:
class Vektor {
friend void drucke (Vektor);
// drucke ist mein Freund
Programmierung II
10
public:
...
private:
float x, y;
};
// und darf auf x und y zugreifen
// drucke darf hier zugreifen
drucke darf damit auf die privaten Komponenten x und y jedes Objekts vom Typ Vektor zugreifen:
void drucke (Vektor v) {
cout << "(" << v.x << ", " << v.y << ")\n";
}
Befreundete freie Funktionen sind damit eine Alternative zu Methoden. Genau wie Methoden dürfen sie auf die
privaten Komponenten einer Klasse zugreifen.
Auch freie Operatoren können Freunde einer Klasse sein:
class Vektor {
friend void
friend Vektor
public:
...
private:
float x, y;
};
drucke
(Vektor);
operator+ (Vektor, Vektor);
// befreundeter freier Operator:
Vektor operator+ (Vektor v1, Vektor v2) {
Vektor res;
res.x=v1.x+v2.x; res.y=v1.y+v2.y;
return res;
}
// Operator als Methode:
Vektor Vektor::operator- (Vektor v2) {
Vektor res;
res.x=x+v.x; res.y=y+v.y;
return res;
}
Mit dem Konzept der Freundschaft sollte sparsam und mit Überlegung umgegangen werden. Es setzt private
ausser Kraft und schaltet damit etwas ab, das allgemein als wichtiges und sinnvollen Prinzip der Software–
Entwicklung angesehen wird.
1.3.3
Klassen als Freunde
Wird eine Klasse zum Freund einer anderen erklärt, dann bedeutet dies, dass alle Methoden des Freundes Zugriff
erhalten. Selbstverständlich erklärt man nur Klassen zu Freunden, die eng zusammenarbeiten. Beispiel:
class Vektor {
friend class Punkt;
public:
...
private:
float x, y;
};
class Punkt {
public:
// Klasse Punkt ist mein Freund
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
11
...
float entfernt_von (Punkt);
private:
Vektor pos;
};
...
// Alle Methoden von Punkt duerfen auf alle
// Komponenten von Vektor zugreifen.
...
float Punkt::entfernt_von (Punkt p) { // Zugriff auf die privaten Komponenten von pos
return sqrt ( (pos.x - p.pos.x)*(pos.x - p.pos.x)
+
(pos.y - p.pos.y)*(pos.y - p.pos.y));
}
Hier darf jede Methode von Punkt auf alles von Vektor zugreifen, aber nicht umgekehrt. 4
Auch mit der Freundschaft zwischen Klassen sollte sparsam umgegangen werden. Nur Klassen die inhaltlich sehr
eng zusammengehören sollten Freunde sein.
1.4
1.4.1
Überladene Operatoren
Überladene Operatoren als freie Funktionen
Operatoren können bekanntlich mit neuen Definitionen belegt werden. Beispielsweise kann die Addition von Vektoren als Operator definiert werden:
struct Vektor {
...
float x;
float y;
};
...
Vektor operator+ (Vektor a, Vektor b) {
Vektor res;
res.x = a.x + b.x;
res.y = a.y + b.y;
return res;
}
...
Vektor a, b, c;
...
a = b + c;
Die Definition eines solchen Operators unterscheidet sich kaum von der einer “normalen” freien Funktion. Die
Anweisung
a = b + c;
ist als
a = operator+ (b, c);
zu interpretieren.
4 Man beachte, dass, ohne die erklärte Freundschft, Punkt keinesfalls auf pos.x und pos.y hätte zugreifen dürfen, obwohl pos eine
Komponente von Punkt ist.
Programmierung II
12
1.4.2
Überladene Operatoren in Klassen
Statt als freie Funktionen können Operatoren auch als Methoden definiert werden. Das ist natürlich besonders
interessant, wenn sie auf private Komponenten zugreifen müssen, was für eine freie Funktion (ohne Freundschaft)
ja nicht möglich ist.
Ein binärer Operator, z.B. der operator+, wird als Methode stets mit einem Argument definiert. Ist operator+
eine Methode (mit einem Argument) von a dann wird
a+b
als
a.operator+(b)
interpretiert. Unäre Operatoren werden entsprechend als Methoden ohne Argument definiert. Ist beispielsweise
operator++ eine Methode (ohne Argument) von a dann wird
++a
als
a.operator++ ()
interpretiert.
Die Vektoraddition mit “+” als Methode ist:
class Vektor {
public:
...
Vektor operator+ (Vektor b); // binaeres +
...
private:
float x;
float y;
};
...
Vektor Vektor::operator+ (Vektor b) { // addiere b zu mir!
Vektor res;
res.x = x + b.x;
res.y = y + b.y;
return res;
}
...
Vektor x, y, z;
x = y + z;
Operatoren als Methoden können, im Gegensatz zu Operatoren als freie Funktionen, auf private Komponenten
zugreifen.
1.4.3
Unäre Operatoren
Unäre Operatoren können als Methode definiert werden:
class Vektor {
public:
...
Vektor operator- () {
Vektor res;
res.x = -x; res.y = -y;
return res;
}
// unaeres Minus
// als Methode
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
13
...
private:
float x;
float y;
};
// Benutzung:
int main () {
Vektor a, b;
b = -a;
}
// unaeres Minus auf einem Vektor
Sie können auch als freie Funktionen definiert werden:
Vektor operator- (Vektor v) { // unaeres - als freie Funktion
return Vektor (-v.x, -v.y);
}
Die freie Funktion wird genauso wie die Methode benutzt.
1.5
1.5.1
Inline Funktionen und Methoden
Inline Funktionen
Eine einfache Funktion wie etwa
int max (int a, int b) {
if (a > b) return a; else return b;
}
kann die Lesbarkeit eines Programms beträchtlich erhöhen. Man darf aber den erhöhten Aufwand der Funktion
nicht vergessen. Wird eine solche kleine Funktion sehr oft aufgerufen – speziell in einer Schleife –, dann wird die
verbesserte Lesbarkeit mit einem schlechteren Laufzeitverhalten erkauft.
Mit inline Funktionen kann die Lesbarkeit verbessert werden, ohne dass die Effizienz des Programms leidet.
inline int max (int a, int b) {
if (a > b) return a; else return b;
}
Das Schlüsselwort inline weist den Compiler an, nicht wirklich eine Funktion zu erzeugen und aufzurufen,
sondern jeden Funktionsaufruf im Quellprogramm durch den Code des Funktionskörpers zu ersetzen.
Nur wirklich kleine und einfache Funktionen sollten inline sein. Zum einen erzeugt ein Funktionsaufruf nur einen
geringen Zusatzaufwand und zum anderen kann mit der Expansion umfangreicher Funktionen der Maschinencode
wesentlich größer werden, was eventuell auch negative Auswirkungen auf das Laufzeitverhalten hat. Komplexe
Funktionen können u.U. auch vom Compiler gar nicht korrekt expandiert werden. Die Inline–Direktive ist darum
nur ein Hinweis, den der Compiler befolgen kann, aber nicht befolgen muss.
1.5.2
Inline Methoden
Methoden können natürlich ebenfalls als inline deklariert werden. Beispiel:
class C {
public:
inline int f ();
int x;
};
inline int C::f () { return x; }
Programmierung II
14
Das Schlüsselwort inline ist hier sowohl der Deklaration der Methode als auch der Definition vorgestellt. Eines
der beiden kann weggelassen werden.
1.5.3
Inline Methoden innerhalb der Klassendefinition
Inline Methoden können direkt in die Klassendefinition platziert werden. Beispiel:
class C {
public:
inline int f () { return x; }
int x;
};
Da dies nur für inline Methoden möglich ist, ist das Schlüsselwort inline redundant und kann weggelassen
werden:
class C {
public:
int f () { return x; }
int x;
};
// Inline Methode
Es ist üblich kleine Methoden in dieser Art inline zu definieren.
1.6
1.6.1
Konstruktoren und Destruktoren
Methoden zur Initialisierung
Der Klasse Menge von oben fehlt noch eine Initialisierungsmethode mit der ein Mengenobjekt in einen definierten
Anfangszustand versetzt werden kann. Typischerweise wird man eine Menge als leere Menge initialisieren und die
Initialisierungs–Methode init nennen:
class Menge {
public:
void init
void einfuege
void entnehme
bool istEnthalten
private:
int m[10];
int a;
};
() { a = 0; }
(int);
(int);
(int);
Jede Variable vom Typ Menge muss dann vor der ersten Benutzung mit init initialisiert werden.
...
Menge m1, m2;
m1.init(); m2.init();
...
1.6.2
Konstruktoren sind Initialisierungs–Methoden mit Compiler–Unterstützung
Die Initialisierung ist eine Routineaktion. Als solche kann und sollte sie vom Compiler übernommen werden. Gibt
man der Initialisierungs–Methode einer Klasse den Namen der Klasse und lässt den Ergebnistyp weg, dann erkennt
der Compiler sie als Konstruktor, d.h. als Initialisierungsroutine für deren Aktivierung er verantwortlich ist.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
15
class Menge {
public:
Menge ();
// Konstruktor (Deklaration)
...
};
Menge::Menge () { a = 0; } // Konstruktor (Definition)
bzw.
class Menge {
public:
Menge () { a = 0; }
...
};
// Konstruktor (inline)
Durch diese einfache Konvention der Benennung kann also ein Konstruktor vom Compiler erkannt werden. Er
nutzt sein Wissen, um an allen Stellen, an denen ein neues Exemplar des Typs Menge erzeugt wird, selbständig
einen Aufruf des Konstruktors einzufügen:
...
Menge m1, m2;
// Hier fuegt der Compiler (in dem von ihm erzeugten Code)
// automatisch die Aufrufe
// m1.Menge() und
// m2.Menge() ein
...
Explizite Initialisierungen sind nicht mehr notwendig. Sie können somit auch nicht mehr versehentlich vergessen
werden.
Konstruktoren sind Initialisierungsroutinen für Objekte. Sie werden mit der Klasse
definiert und automatisch an der Stelle der Variablendefinition aufgerufen.
1.6.3
Der Compiler erzeugt keine Konstruktordefinitionen
Manche Klassen enthalten keinen Konstruktor. Das ist erlaubt. Klassen müssen keinen Konstruktor enthalten.
Der Compiler erzeugt auch keine Konstruktoren automatisch. Objekte einer Klasse ohne Konstruktor werden wie
Variablen jedes beliebigen Typs behandelt: globale Objekte werden mit 0 initialisiert und alle anderen enthalten
Zufallswerte. Beispiel:
class C {
public:
int a;
};
C c1;
int main () {
C c2;
}
c1.a wird hier mit 0 initialisiert, c2.a enthält einen Zufallswert.
Aufrufe von Konstruktoren werden also vom Compiler automatisch in das Quellprogramm eingefügt. Definitionen
von Konstruktoren werden dagegen (im Allgemeinen) nicht automatisch erzeugt.
1.6.4
Konstruktoren mit Argumenten
Konstruktoren können Argumente haben. Für eine Klasse können auch beliebig viele Konstruktoren definiert werden, wenn sie nur an Hand ihrer Parameterliste unterschieden werden können. Die Konstruktoren sind dann überladene Funktionen. Eine Menge kann beispielsweise mit keinem, einem oder zwei Elementen initialisiert werden:
Programmierung II
16
class Menge {
public:
Menge ();
// Konstruktor 1
Menge (int);
// Konstruktor 2
Menge (int, int); // Konstruktor 3
...
};
Menge::Menge ()
{ a = 0; }
Menge::Menge (int x)
{ a = 1; m[0] = x; }
Menge::Menge (int x, int y) { a = 2; m[0] = x; m[1] = y; }
...
Menge m1;
//Variablendef. plus Aufruf von Konstruktor 1
Menge m2 (1);
//Variablendef. plus Aufruf von Konstruktor 2
Menge m3 (1, 2); //Variablendef. plus Aufruf von Konstruktor 3
Konstruktoren mit Argumenten werden aktiviert, indem man die Argumente in Klammern hinter die definierte
Variable schreibt:
Menge m2(1);
oder den Konstruktor explizit aufruft:
Menge m2 = Menge::Menge(1);
1.6.5
Konstruktor als Konversionsoperation
Konstruktoren mit einem Argument werden bei Bedarf als Konversionsoperation behandelt. Dazu werden sie in
dieser Funktion explizit über den Typnamen aufgerufen:
Menge m2 = Menge(1); // explizite Konversion mit Typname als Konstruktor
Eine implizite Konversion ist auch möglich:
Menge m2 = 1; // OK: implizite Konversion mit Konstruktor
Hier wird der int–Wert 1 implizit in ein Objekt vom Typ Menge konvertiert. Der Konstruktor
Menge::Menge(int) dient dabei als implizite Konversionsfunktion.
1.6.6
explicit: der Konstruktor wird nur explizit aktiviert
Mit Schlüsselwort explicit vor der Konstruktordefinition kann die implizite Verwendung dieses Konstruktors
als Konversionsfunktion verhindert werden. Beispiel:
class Menge {
public:
...
explicit Menge (int);
...
};
...
Menge m1 (1); // OK: expliziter Aufruf des Konstruktors
Menge m2 = 1; // FEHLER: nicht erlaubte Verwendung des Konstruktors zur Konversion
Mit explicit sollen Fehler durch unbeabsichtigte Konversionen vermieden werden.
1.6.7
Default–Konstruktor: Konstruktor der ohne Argumente aktiviert wird
Ein Konstruktor ohne Argumente wird Default–Konstruktor genannt. Achtung: er heißt nicht etwa so, weil der
Compiler ihn automatisch erzeugt, sondern weil er vom Compiler automatisch (“per default”) aufgerufen wird,
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
17
wenn keine Argumente den Einsatz eines anderen Konstruktors vorschreiben!
Man beachte dass der Default–Konstruktor ohne die Verwendung von Klammern aktiviert wird:
Menge m;
// OK
Menge m(); // FALSCH
statt
Die Konstruktion Menge m() würde vom Compiler nicht als Definition einer Variablen m, sondern als Deklaration einer Funktion m mit Ergebnis vom Typ Menge missverstanden werden.
1.6.8
Klassen ohne Default–Konstruktor
Eine Klasse muss nicht unbedingt einen Default–Konstruktor definieren. Wenn er benötigt wird, dann muss er aber
vorhanden sein:
class Menge {
public:
// KEIN DEFAULT-Konstruktor
Menge (int);
Menge (int, int);
...
};
...
Menge m1;
// FEHLER: Compiler sucht vergeblich Menge::Menge ()
Menge m2(1); // OK
Enthält eine Klasse überhaupt keinen Konstruktor, dann muss auch kein Default–Konstruktor vorhanden sein:
class Menge {
public:
// KEIN Konstruktor
...
};
...
Menge m1;
// OK: gar kein Konstruktoraufruf:
//
m1 hat Zufallswert, oder -- falls es global ist -//
ist mit 0-en belegt,
Menge m2(1); // FEHLER: Compiler sucht vergeblich Menge::Menge (int)
Ist kein Konstruktor definiert, dann wird auch kein Default-Konstruktor erwartet. Gibt es irgendeinen Konstruktor,
dann muss bei Bedarf auch ein Defaultkonstruktor zur Verfügung stehen.
Man sollte von der Möglichkeit alle Konstruktoren wegzulassen jedoch nur überlegt Gebrauch machen. Jede Klasse
sollte mit mindestens einem Konstruktor ausgestattet werden um fehlerhafte oder zufällige Initialisierungen schon
bei der Übersetzung aufspüren zu können.
Im Regelfall sollte auch ein Defaultkonstruktor definiert werden. Nur wenn keine sinnvollen allgemeinen Initialwerte für die Datenkomponenten angegeben werden können, kann er fehlen. In dem Fall sollte es dann aber
mindestens einen anderen Konstruktor geben.
1.6.9
Initialisierungslisten
Verbunde können durch Initialisierungslisten mit einem Wert belegt werden. Verbunde sind Klassen deren Komponenten alle öffentlich sind. Klassen, die nur öffentliche Datenkomponenten und keinen Konstruktor enthalten,
können darum wie Verbunde mit einer Initialisierungsliste intialisiert werden:
class Menge {
public:
// KEIN Konstruktor
Programmierung II
18
// und ALLE Datenkomponenten public
int m[10];
int a;
};
...
Menge m = {{1,2,3,4,5,6,7,8,9,0}, 10}
Initialisierungslisten eignen sich zur Belegung großer Strukturen. Generell sollten Objekte aber nicht mit Initialisierungslisten sondern mit Konstruktoren initialisiert werden.
1.6.10 Destruktoren
Destruktoren sind komplementär zu Konstruktoren. Sie werden aufgerufen, bevor ein Objekt vernichtet wird. Beispiel:
class Menge {
public:
Menge ();
// Konstruktor
˜Menge (); // Destruktor
...
};
.. f (...) {
Menge m;
// Beginn der Existenz von m:
// Compiler-erzeugter Aufruf des Konstruktors
// m.Menge();
...
...
// Ende der Funktion und damit
// Ende der Existenz aller funktionslokalen Variablen:
// Compiler-erzeugter Aufruf des Destruktors
// m.˜Menge();
}
Destruktoren werden für Aufräumarbeiten eingesetzt, die beim Ableben eines Objektes notwendig werden. Typischerweise werden im Destruktor die vom dahingehenden Objekt belegten Ressourcen freigegeben. Dies kann
bei der Verwendung von Verweisen sinnvoll sein. Im Gegensatz zu Konstruktoren sind Destruktoren oftmals nicht
notwendig. Sie werden wie Konstruktoren vom Compiler nicht automatisch erzeugt. Destruktoren werden später
ausführlicher behandelt.
1.7
1.7.1
Initialisierung von Objekten, Initialisierer
Initialisierung von einfachen Variablen
Variablen können bei ihrer Definition mit einen ersten Wert belegt werden. Bei skalaren Typen und Feldern sieht
das wie eine Zuweisung aus, z.B:
int i = 5;
int a[3] =
1, 2, 3
;
Klassen – inklusive ihr Sonderfall Struct – werden durch Konstruktoren initialisiert. Nur dann, wenn kein Konstruktor existiert und alle Komponenten öffentlich sind, ist die Zuweisungsinitialisierung auch bei ihnen erlaubt.
struct S
int x, y, z;
1, 2 , 3 ;
S s =
;
Ohne explizite Initialisierung werden statische Variablen mit Null belegt und alle anderen behalten ihre Zufallswerte.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
1.7.2
19
Initialisierungsreihenfolge
Klassen enthalten üblicherweise Datenkomponten. Diese können wiederum Objekte einer Klasse sein, die selbst
wieder Objekte als Komponenten enthält, etc. Ein Objekt kann also aus beliebig tief verschachtelten Unterobjekten
bestehen. Bei der Initialisierung des Gesamtobjekts werden auch die Unterobjekte initialisiert. Die Reihenfolge der
Initialisierung ist dabei:
Zuerst werden alle Datenkomponenten eines Objekts in der Reihenfolge ihrer Definition initialisiert.
Anschließend wird das Objekt als ganzes durch seinen Konstruktor initialisiert.
Diese Regel gilt rekursiv für alle Objekte, Unterobjekte, Unter–Unterobjekte, etc. Die Regeln der Initialisierung
ergeben sich immer aus dem Typ der Objekte und Unterobjekte. Das Vorgehen ist intuitiv einsichtig: zuerst werden
die Einzelteile fertig gemacht, dann wird aus ihnen ein Ganzes konstruiert. Der Konstruktor ist für das Gesamte
zuständig.
1.7.3
Beispiel Initialisierungsreihenfolge
Als Beispiel betrachten wir wieder einmal Vektoren:
class Vektor {
public:
Vektor ();
Vektor (float, float);
private:
float x, y;
};
Vektor::Vektor () { x = 0.0; y = 0.0; }
Vektor::Vektor (float a, float b) { x=a; y=b; }
Wird ein Objekt vom Typ Vektor angelegt, dann werden zuerst dessen x– und y–Komponenten initialisiert, und
zwar nach den Regeln, die für float–Variablen gelten (also keinerlei Initialisierung außer für statische Objekte).
Danach wird der Konstruktor aufgerufen.
Etwas interessanter wird die Angelegenheit, wenn ein Objekt nicht aus skalaren Typen wie float zusammengesetzt ist, sondern aus “richtigen Objekten” besteht. Nehmen wir eine Gerade, die mit Hilfe von zwei Vektoren
dargestellt wird:
class Gerade {
public:
Gerade ();
Gerade (Vektor, Vektor);
private:
Vektor o, r;
};
Gerade::Gerade () { o = Vektor (0.0, 0.0); r = Vektor (1.0, 1.0); }
Gerade::Gerade (Vektor po, Vektor pr) { o = po; r = pr; }
Wird ein Objekt vom Typ Gerade angelegt, z.B. mit
Gerade g;
dann laufen folgende Initialisierungsaktionen ab:
1. g.o.x
wird initialisiert (Unter-Unterobjekt)
2. g.o.y
wird initialisiert (Unter-Unterobjekt)
Programmierung II
20
3. g.o.Vektor()
wird ausgeführt (Konstruktor Unterobjekt)
4. g.r.x
wird initialisiert (Unter-Unterobjekt)
5. g.r.y
wird initialisiert (Unter-Unterobjekt)
6. g.r.Vektor()
wird ausgeführt (Konstruktor Unterobjekt)
7. g.Gerade()
wird ausgeführt (Konstruktor Objekt).
Das Prinizip, das die Initialisierungsreihenfolge bestimmt, ist insgesamt sehr einfach:
Objekte werden von innen nach aussen initalisiert. Auf die Initialisierung der Komponenten folgt dabei der Aufruf des Konstruktors für das Objekt das diese Komponenten
enthält.
1.7.4
Redundante Initialisierung
Man beachte dass im letzten Beispiel die Komponenten prinzipiell zweimal mit Werten belegt werden. Zuerst durch
ihren eigenen Konstruktor und dann durch eine Zuweisung im Konstruktor der Klasse deren Teilkomponente sie
sind. Beispielsweise wird g.r zuerst durch den Defaultkonstruktor von Vektor
g.r.Vektor()
mit g.r.x=0.0, g.r.y=0.0 belegt, dann wird g.Gerade() aufgerufen, die Zuweisung
r = Vektor (1.0, 1.0);
in g.Gerade() ausgeführt und g.r erhält seinen endgültigen Wert g.r.x=1.0, g.r.y=1.0.
Die letzte Zuweisung könnten wir uns sparen, wenn der Konstruktor für g.r gleich mit den richtigen Argumenten
aufgerufen würde. Statt durch den Defaultkonstruktor müßte g.r also gleich mit g.r.Vektor(1.0, 1.0)
belegt werden. Zu dem notwendigen gezielten Aufruf eines Konstruktors für Unterobjekte gibt es Initialisierer.
1.7.5
Initialisierer
Initialisierer können in Konstruktoren verwendet werden, um die Initialisierung der Unterkomponenten zu steuern.
Mit ihnen kann die oben angesprochene doppelte Initialisierung einer Komponente vermieden werden. Beispiel:
class Gerade {
public:
Gerade ();
Gerade (Vektor, Vektor);
private:
Vektor o, r;
};
Gerade::Gerade ()// Defaultkonstruktor mit Initialisierer
: o(0.0, 0.0), // Initialisierer: expliziter Aufruf des Konstruktors
//
Vektor::Vektor(float, float) fuer Komponente o
//
statt des Defaultkonstruktors Vektor::Vektor()
r(1.0, 1.0) // Initialisierer: expliziter Konstruktoraufruf
//
fuer Komponente r
{}
// Keine Zuweisung da bereits korrekt initialisiert
Gerade::Gerade (Vektor po, Vektor pr)
: o(po), r(pr)
{}
Im Konstruktor Gerade::Gerade werden o und r jezt gleich durch den richtigen Konstruktoraufruf mit den
endgültigen Werten belegt, statt sie erst durch ihren Default–Konstruktor zu initialisieren und dann durch eine
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
21
Zuweisung mit den richtigen Werten zu belegen. In dieser Variante ist die Initialisierungsreihenfolge immer noch
die gleiche wie oben:
1. g.o.x
wird initialisiert
2. g.o.y
wird initialisiert
3. g.o.Vektor(0.0, 0.0) wird ausgeführt
4. g.r.x
wird initialisiert
5. g.r.y
wird initialisiert
6. g.r.Vektor(1.0, 1.0) wird ausgeführt
7. g.Gerade()
wird ausgeführt
Die Zuweisungen in g.Gerade() können allerdings wegfallen. Üblicherweise initialisiert man alle Komponenten durch Initialisierer, auch wenn es sich nicht um Klassen handelt:
class Vektor {
public:
Vektor ();
Vektor (float, float);
private:
float x, y;
};
Vektor::Vektor () : x(0.0), y(0.0) {}
Vektor::Vektor (float a, float b) : x(a), y(b) {}
class Gerade {
public:
Gerade ();
Gerade (Vektor, Vektor);
private:
Vektor o, r;
};
Gerade::Gerade () : r(1.0, 1.0) {}
Gerade::Gerade (Vektor po, Vektor pr) : o(po), r(pr) {}
Ob man sich bei der Initialisierung gegebenenfalls auf den Defaultkonstruktor verlässt wie in:
Gerade::Gerade (): r(1.0, 1.0)
oder lieber alle Konstruktoren explizit aufruft:
Gerade::Gerade (): o(0.0, 0.0), r(1.0, 1.0)
das bleibt dem persönlichen Geschmack überlassen. Selbstvertändlich ist auch
Gerade::Gerade (): o(Vektor(0.0, 0.0)), r(Vektor(1.0, 1.0))
möglich. Da Konstruktoren sehr oft aufgerufen werden, kann ihre Effizienz die des Gesamtprogramms stark beeinflussen. Initialisierer können die Laufzeit eines Programms beträchtlich verbessern. In der ersten Version von
Gerade wird g.r.x zwei bzw. dreimal mit einem Wert belegt. In der zweiten Variante dagegen nur genau einmal.
Werden Tausende von Geraden erzeugt, dann kann das die Laufzeit des des Programm schon beeinflussen.
1.7.6
Klassen mit Klassenkomponenten und automatisch generierte Default–Konstruktoren
Bisher haben wir immer betont, dass Konstruktoren im Allgemeinen und der Default–Konstruktor im Besonderen
nicht vom Compiler automatisch erzeugt werden, sondern explizit definiert werden müssen. Von dieser Regel
gibt es eine Ausnahme: Wenn der Typ einer Komponente eine Klasse mit Default–Konstruktor ist, dann wird der
Konstruktor der “Unterklasse” immer aktiviert; auch dann, wenn die Klasse selbst keinen Konstruktor hat:
Programmierung II
22
class Float { // Klasse mit Konstruktor
public:
Float () { f = 0.0; }
float f;
};
class C {
// Klasse ohne Konstruktor
public:
Float
a; // Klassen-Komponente: wird mit Konstruktor initialisiert
float
b; // keine Klasse
};
C c1;
int main () {
C c2;
}
Die Komponenten mit Klassentyp, c1.a und c2.a, werden mit ihrem Default–Konstruktor Float::Float()
initialisiert, obwohl die Klasse C, zu der sie gehören, keinen Konstruktor hat. Der Compiler erzeugt dazu einen
Defaultkonstruktor von C der a.Float() aufruft. Die Komponenten mit skalarem Typ werden wie üblich behandelt: c1.b wird mit 0 initialisiert, da c1 eine globale Variable ist; c2.b wird nicht initialisiert.
Die Aktivierung der “Subkonstruktoren” übernimmt ein vom Compiler generierter Default–Konstruktor:
class Float {
public:
Float () : f(0.0) {}
float f;
};
...
class C {
// C () { a.Float(); } generierter Default-Konstruktor,
public:
// Pseudocode, nicht vollwertig
Float
a;
float
b;
};
...
int main () {
C c;
// OK, Aufruf des automatisch erzeugten C::C initialisiert c.a
}
Der erzeugte Defaultkonstruktor ist nicht “vollwertig” und kann einen explizit definierten Defaultkonstruktor nicht
ersetzen:
class Float {
public:
Float () : f(0.0) {}
float f;
};
...
class C {
// C () { a.Float(); }
public:
C (float x) : b(x) {}
Float
a;
float
b;
};
...
int main () {
C c;
// FEHLER:
generierter Default-Konstruktor,
// Pseudocode, nicht vollwertig
// <- explizit definierter Konstruktor,
// erzwingt Existenz eines passenden Konstruktors
// bei jeder Erzeugung eines C-Objekts
Defaultkonstruktor von C fehlt
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
23
}
Die Klasse C enthält hier einen explizit definierten Konstruktor, wenn ein C–Objekt erzeugt wird, muss darum
der entsprechende Konstruktor definiert sein. Der vom Compiler erzeugt Default–Konstruktor ist kein vollwertiger
Konstruktor und “gilt nicht”.
Insgesamt gilt: Der Default–Konstruktor ist der Konstruktor, der “default–mäßig” aufgerufen wird. Er muss vorhanden sein, wenn er
gebraucht wird – wenn also ein Objekt ohne expliziten Aufruf eines anderen Konstruktors angelegt wird,
und wenn außerdem
irgendein anderer Konstruktor definiert ist.
Bei Klassen mit Klassen als Komponenten werden die Default–Konstruktoren der Subklassen auch dann aktiviert,
wenn die Klasse selbst keinen Konstruktor hat. Dies setzt aber die Regeln über die notwendige Existenz eines
Default–Konstruktors nicht außer Kraft.
1.8
1.8.1
Statische Komponenten einer Klasse
Statische Datenkomponenten (Klassenvariablen)
Komponenten – Daten und Methoden – einer Klasse können mit dem Schlüsselwort static als statisch deklariert
werden. Die entsprechende Komponente ist dann klassen–spezifisch statt objekt–spezifisch. Bei statischen Daten–
Komponenten bedeutet dies, dass die Komponente nur einmal für alle Instanzen (Objekte) der entsprechenden
Klasse angelegt wird.
Beispiel:
class Vektor {
public:
Vektor ();
Vektor (float, float);
˜Vektor ();
static int vz;
// <<--- Deklaration statische Datenkomponente
private:
float x, y;
};
int Vektor::vz = 0;
// <<--- Definition statische Datenkomponente
Vektor::Vektor () { x=0; y=0; ++vz; }
// Konstr. erhoeht vz
Vektor::˜Vektor () { --vz; }
// Destr. erniedrigt vz
Vektor::Vektor (float a, float b) { x=a; y=b; ++vz; } // Konstr. erhoeht vz
In diesem Beispiel wird eine statische Datenkomponente benutzt, um die Anzahl der Objekte der Klasse Vektor
zu zählen. Die Anzahl wird für alle Objekte der Klasse Vektor gemeinsam in
int Vektor::vz
geführt. Diese Variable gibt es genau einmal, egal wieviele Vektor–Objekte gerade existieren. Jedesmal, wenn ein
Objekt vom Typ Vektor angelegt wird, wird auch vz erhöht. Der Destruktor zählt vz wieder zurück. Wir erinnern
uns, dass der Destruktor automatisch aufgerufen wird, wenn ein Objekt verschwindet.
Statische Datenfelder müssen außerhalb der Klassendefinition angelegt (definiert) und bei Bedarf auch dort initialisiert werden:
int Vektor::vz = 0;
Programmierung II
24
Statische Datenkomponenten einer Klasse sind nicht Bestandteil der Objekte, sie sind wie globlale Variablen einmal im Programm vorhanden. Sie existieren also nicht wie “normale” Datenkomponenten einmal pro Objekt,
sondern einmal pro Klasse. Man nennt sie darum auch Klassenvariablen. Klassenvariablen benutzt man typischerweise um Informationen zu verwalten, die sich nicht auf einzelne Objekte sondern auf die Klasse als Ganzes
beziehen.
Zusammengefasst, der Unterschied von “normalen” und statischen Datenkomponenten ist:
Nicht–statische Datenkomponenten gehören zu den Ojekten. Jedes Objekt hat seine eigene Komponente.
Statische Datenkomponenten (Klassenvaribalen) gehören zur Klasse. Es gibt sie genau einmal, unabhängig
davon wieviele Objekte der Klasse gerade existieren.
1.8.2
Verwendung statischer Komponenten
Normale Datenkomponenten werden über die Punktnotation angesprochen, z.B.
Vektor v; v.x=0; // x von Vektor v auf 0 setzen
Bei Klassenvariablen gibt es kein Objekt, dessen Komponente sie sein können. Man spricht sie darum über die
Klasse an, z.B.:
Vektor::vz=0;
// vz der Vektoren-Klasse auf 0 setzen
Ein etwas ausführlicheres Beispiel für die Verwendung dieser Vektor–Klasse ist:
class Vektor { ... wie oben ... };
...
void f () {
Vektor v;
cout << Vektor::vz << endl; // Ausgabe:
}
int main () {
cout << Vektor::vz << endl; // Ausgabe:
Vektor v1, v2 (1.0, 2.5);
cout << Vektor::vz << endl; // Ausgabe:
f();
cout << Vektor::vz << endl; // Ausgabe:
}
3
0
2
2
In diesem Beispiel wird während des Programmlaufs viermal die Zahl der gerade existierenden Vektoren ausgegeben.
1.8.3
Private Klassenvariablen
Klassenvariablen dürfen natürlich auch privat sein. In dem Fall müssen sie über Methoden angesprochen werden:
class Vektor {
public:
Vektor ();
Vektor (float, float);
˜Vektor ();
int wieViele ();
private:
float x, y;
static int vz;
};
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
25
int Vektor::vz = 0;
Vektor::Vektor () { x=0; y=0; ++vz; }
Vektor::˜Vektor () { --vz; }
Vektor::Vektor (float a, float b) { x=a; y=b; ++vz; }
int Vektor::wieViele () { return vz; }
void f () {
Vektor v;
cout << v.wieViele() << endl;
}
int main () {
// NICHT MOEGLICH --> Ausgabe:
Vektor v1, v2 (1.0, 2.5);
cout << v1.wieViele() << endl;
f();
cout << v1.wieViele() << endl;
}
// Ausgabe: 3
0
// Ausgabe: 2
// Ausgabe: 2
Der Aufruf der Methode wieViele ist an ein Objekt gekoppelt. Das ist in gewisser Weise unnatürlich. Die
aktuelle Zahl der Vektoren ist nichts, was etwas mit einem bestimmten Vektor zu tun hat. Es ist sogar unmöglich
festzustellen, dass es aktuell gar keinen Vektor gibt. Es sollte also auch Methoden geben, die zur gesamten Klasse,
statt zu einem einzelnen Objekt gehören.
1.8.4
Statische Methoden
Neben statischen Datenkomponenten gibt es auch statische Methoden. Wie die statischen Datenkomponenten
gehören statische Methoden nicht zu einzelnen Objekten sondern zu der Klasse insgesamt. Sie werden darum
unabhängig von einem bestimmten Objekt ausgeführt. Beispielsweise kann die Angabe der Zahl der aktuell existierenden Vektoren als statische Methode definiert werden:
class Vektor {
public:
Vektor ();
Vektor (float, float);
˜Vektor ();
static int wieViele ();
// Deklaration statische Methode
private:
float x, y;
static int vz;
};
int Vektor::vz = 0;
... etc. wie oben ...
int Vektor::wieViele () { return vz; } // Definition statische Methode
// entspr. der "normaler" Methoden
void f () {
Vektor v;
cout << v.wieViele() << endl; // Ausgabe: 3
}
int main () {
Programmierung II
26
cout << Vektor::wieViele() << endl; // Ausgabe: 0
Vektor v1, v2(1.0, 2.5);
cout << Vektor::wieViele() << endl; // Ausgabe: 2
f();
cout << Vektor::wieViele() << endl; // Ausgabe: 2
Aufruf statische Methode
}
Man beachte den Aufruf der Methode wieViele ohne den Selektionspunkt aber mit der Bereichsangabe
Vektor::. Damit kommt zum Ausdruck, dass wieViele zur Klasse Vektor gehört, aber nicht in Abhängigkeit von einem bestimmten Objekt ausgewertet wird.
Statische Methoden haben keinen Zugriff auf ein Objekt. Sie verhalten sich damit wie freie Funktionen. Im Gegensatz zu diesen haben sie – als Bestandteile ihrer Klassen – aber Zugriff auf deren private Komponenten.
1.8.5
Methoden, statische Methoden und freie Funktionen
Methoden, statische Methoden und freie Funktionen bieten ähnliche Möglichkeiten. Betrachten wir dazu noch
einmal das Mengenbeispiel mit drei Varianten der Vereinigung:
class Menge {
...
Menge operator+ (Menge);
// Methode (und Operator)
static Menge vereinige (Menge, Menge); // statische Methode
...
};
Menge operator+ (Menge, Menge);
// freie Funktion (und Operator)
Die wesentlichen Eigenschaften und Unterschiede von Methoden, statischen Methoden und freien Funktionen
sind:
Methoden operieren auf “ihrem” Objekt. Sie können als Operatoren definiert werden und haben Zugriff auf
alle Komponenten.
Statische Methoden verhalten sich wie freie Funktionen. Sie gehören zwar zu einer Klasse, haben aber im
Gegensatz zu normalen Methoden keinen Bezug zu einem bestimmten Objekt. Sie können nicht als Operatoren definiert werden.
Freie Funktionen haben nur auf öffentliche Komponenten Zugriff. Sie können als Operatoren definiert werden.
1.9
1.9.1
Konstante Komponenten, konstante Parameter
Konstante Datenkomponenten
Wir erinnern uns, dass mit const Konstanten im Programm definiert werden können. Beispiel:
const float pi = 3.1415;
Komponenten einer Klasse können mit dem Schlüsselwort const ebenfalls als “konstant” erklärt werden. Konstante Datenkomponenten verhalten sich genau wie Konstanten auf Programmebene: sie sind unveränderlich. Wollen wir beispielsweise, dass die Registriernummer eines Buchs nicht verändert werden kann, dann erklären wir sie
als konstant:
class Buch {
public:
Buch (string, string);
...
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
private:
const int regNr;
...
};
27
// konstante Datenkomponente
Jedes Buch hat damit seine eigene unveränderliche Registriernummer regNr.
Man beachte den Unterschied von const und static. Das Schlüsselwort const erklärt Komponenten als
unveränderlich. Die Komponente existiert einmal pro Objekt, kann aber nicht modifiziert werden. Mit static
deklariert man Komponenten als klassenspezifisch. Die Komponente existiert einmal für die ganze Klasse, ist aber
veränderlich.
static und const können natürlich auch kombiniert werden. Die Komponente ist dann sowohl klassenspezifisch als auch unveränderlich.
1.9.2
Const und Initialisierer
Da Zuweisungen an Konstanten nicht möglich sind, können sie ihren Wert nur durch einen Initialisierer erhalten.
Bei Konstanten auf Programm– oder Funktionsebene wird der Initialisierer direkt zur Konstanten geschrieben:
const float pi
= 3.1415;
1.9.3
\\ Konstante
\\ Initialisierer
Initialisierer von Klassenkomponenten
Konstante Klassenkomponenten müssen ebenfalls durch einen Initialisierer mit einem Wert belegt werden. Sie
werden beim Konstruktor in der speziellen Notation der Initialisierer angegeben:
class Buch {
public:
Buch (string, string);
...
private:
string
titel, autor;
const int regNr;
static int naechsteNr;
};
Buch::Buch(string p_autor, string p_titel)
: regNr (naechsteNr)
// Initialisierer
{
autor = p_autor;
titel = p_titel;
++naechsteNr;
}
Konstante Klassenkomponenten können nur mit Initialisierern belegt werden. Umgekehrt kann jede Datenkomponente – nicht nur die konstanten – mit einem Initialisierer belegt werden:
Buch::Buch(string p_autor, string p_titel)
: regNr (naechsteNr++), // Initialisierer fuer alle Komponenten
titel (p_titel),
autor (p_autor)
{}
// Rumpf des Konstruktors jetzt leer
Nicht–konstante Komponenten können (und sollten), konstante Komponenten müssen mit einem Inititialisierer
initialisiert werden!
Programmierung II
28
1.9.4
Konstante Methoden
Eine Methode kann wie eine Datenkomponente als konstant erklärt werden. Hier ist die Bedeutung allerdings nicht
“diese Komponente ist unveränderlich”, sondern – da Methoden selbst etwas Aktives sind – : “diese Methode
verändert nichts!”. Beispiel:
class Vektor {
public:
Vektor ();
Vektor (float, float);
float xWert () const; // Konstante Methoden
float yWert () const; // veraendern ihr Objekt nicht
private:
float x, y;
};
...
float Vektor::xWert () const { return x; }
float Vektor::yWert () const { return y; }
Die Methoden xWert und yWert verändern das Objekt nicht, es sind konstante Methoden.
Wird eine Methode als konstant erklärt, dann kann sie auch aktiviert werden, wenn das Objekt zu dem sie gehört
selbst konstant ist. Beispiel:
const Vektor null(0.0, 0.0);
cout << null.xWert() << endl;
// konstantes Objekt
// Aufruf konstanter Methoden erlaubt
Es ist guter Stil alle Datenkomponenten als privat zu erklären und, soweit notwendig, mit speziellen Lese– und/oder
Schreib–Methoden zu versehen. Die Leseoperationen sollten dann wie im Beispiel oben konstant sein.
1.9.5
Konstante Parameter
Auch die formalen Parameter einer Funktion (freie Funktion oder Methode) können als konstant erklärt werden.
Die Funktion sagt damit: Ich werde diesen Parameter nicht verändern!.
Das Versprechen den Parameter nicht zu verändern ist natürlich nur interessant, wenn eine Veränderung, die innerhalb der Funktion erfolgt, auch eine Wirkung nach außen hätte. Das ist nur bei Referenzparametern möglich.
Von Wertparametern werden ja lokale Kopien erzeugt, deren Modifikation außerhalb der Funktion irrelevant ist.
Konstante Wertparameter sind darum unsinnig.
Konstante Referenzparameter sind dagegen sinnvoll. Sie stellen eine wichtige Alternative zu Wertparametern dar.
Wie bei Wertparametern ist der Aufrufer sicher, dass seine aktuellen Parameter nicht verändert werden. Es wird
allerdings bei der Übergabe keine vollständige Kopie erzeugt, sondern lediglich eine Referenz übergeben. Die
Parameterübergabe hat damit die Effizienz der Übergabe per Referenz und die Sicherheit der Übergabe per Wert.
Beispiel:
Vektor operator+ (const Vektor &v1, const Vektor &v2) {
return Vektor (v1.xWert() + v2.xWert(),
v1.yWert() + v2.yWert());
}
Beim Aufruf von operator+ werden jetzt zwei Referenzen übergeben, statt zwei Objekte vom Typ Vektor
vollständig neu zu erzeugen und mit Kopien zu belegen. Der Aufrufer kann aber trotz der Referenzübergabe sicher
sein, dass die aktuellen Parameter nicht modifiziert werden.
Konstante Objekte dürfen nur als Wert– oder konstante Referenzparameter übergeben werden:
Vektor f (Vektor v)
Vektor g (Vektor &v)
{ ... }
{ ... }
// Wertparameter
// Referenzparameter
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
Vektor h (const Vektor &v) { ... }
...
const Vektor null (0.0, 0.0);
f(null);
g(null);
h(null);
// OK
//
// FEHLER
//
// OK
//
29
// konstanter Referenzparameter
f erzeugt eine Kopie von null,
diese darf veraendert werden
g erhaelt Zugriff auf null,
verspricht aber nicht es unveraendert zu lassen
h erhaelt Zugriff auf null und
verspricht es unveraendert zu lassen
Man beachte, dass der Inhalt der Funktionen hier völlig unerheblich ist. Auch wenn g seinen Parameter nicht
verändert, die Übergabe eines konstanten Parameters an g ist verboten.
1.9.6
Konstante Methoden mit konstantem Parameter
Die Kombination konstante Methode mit konstantem Parameter wird häufig eingesetzt:
class Vektor {
public:
Vektor ();
Vektor (float, float);
Vektor operator+ (const Vektor &v) const;
private:
float x, y;
};
...
Vektor Vektor::operator+ (const Vektor &v) const {
return Vektor (x+v.x, y+v.y);
}
Das erste const sagt, dass der rechte Operand der Addition nicht verändert wird, das zweite const garantiert,
dass der linke Operand der Addition nicht verändert wird.
1.9.7
const oder nicht const
Das Schlüsselwort const kann zu einer Flut von Fehlermeldungen und Warnungen führen. Es wird trotzdem
empfohlen mit Konstanten, konstanten Parametern und konstanten Methoden zu arbeiten und gegebenenfalls allen
Warnungen und Fehlermeldungen nachzugehen.
Konstante Objekte und Klassen–Komponenten erhöhen die Sicherheit durch Schutz vor versehentlichem Überschreiben. Sie machen zudem die Intentionen des Programm–Autors klar und erhöhen so die Lesbarkeit des Programms. Warnungen und Fehlermeldungen zu const sind stets ein Anzeichen, dass in der Konzeption des Programms etwas nicht in Ordnung ist: Etwas ist entweder nicht wirklich konstant, oder aber etwas, das konstant sein
sollte, wird irrtümlicherweise verändert.
1.9.8
Philosophie einer Const–Definition
Die unterschiedlichen Bedeutungen, die mit const ausgedrückt werden, sind:
Variablendefinition mit Const
const T x;
Die Variable x darf nicht verändert werden!
Damit wird die Verwendung eingeschränkt: Sie darf nicht zugewiesen werden, nur
an konstante Parameter übergeben werden und nur ihre statischen Methoden dürfen
aktiviert werden.
Programmierung II
30
Parameter mit Const
T1 f (const T & x)
Der Parameter x wird in der Funktion / Methode nicht verändert!
Damit wird der Einsatz der Funktion / Methode erweitert: Sie kann auch auf konstante
Objekte angewendet werden.
Methodendefinition mit Const
T1 T::m(...) const
Die Methode m wird ihr Objekt nicht verändern!
Damit wird der Einsatz der Methode erweitert: Sie kann auch bei konstanten Objekten
aufgerufen werden.
Der Einsatz von const ist wie der von static letztlich eine “philosophische Frage”. Alle Elemente der Klassendefinition müssen zusammenpassen und insgesamt ein möglichst einfach benutzbares, konsistentes, logisches
– kurz “schönes” – Konstrukt ergeben.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
1.10
31
Übungen
Aufgabe 1
1. Definieren Sie als äquivalente Klasse: struct Vektor
float x, y;
;
2. Ist struct S
... private: ...
; erlaubt. Wenn ja: was bedeutet es?
3. Welche Zugriffe auf Klassen–Komponenten sind in folgendem Programm erlaubt, welche sind nicht erlaubt:
class S {
int y;
public:
int x;
int f (S);
private:
int g (S);
};
int S::f (S ps) {
x
= 1;
y
= 2;
ps.x = 1;
ps.y = 2;
return ps.g(ps)
+ ps.y;
}
int S::g (S ps) {
x
= 1;
y
= 2;
ps.x = 1;
ps.y = 2;
return ps.f(ps)
+ ps.y;
}
int h (S ps) {
ps.x = 1;
ps.y = 2;
return ps.f(ps)
+ ps.g(ps);
}
4. Kann jedes Programm, in dem Klassen vorkommen, in ein äquivalentes Programm ohne Klassen umgesetzt
werden? (Äquivalent: Bei gleicher Eingabe wird die gleiche Ausgabe erzeugt.) Wenn nein, geben Sie ein
Beispiel an, bei dem die Umsetzung nicht möglich ist. Wenn ja, wozu gibt es denn überhaupt Klassen?
5.
(a) Kann eine Funktion ein Freund einer Klasse sein?
(b) Kann eine Klasse ein Freund einer Funktion sein?
(c) Kann eine Klasse ein Freund einer Klasse sein?
6. Welche Fehler enthält folgende Klassendefinition. Wie wird das Gewollte korrekt formuliert?
class C {
public:
C (int x)
{ c=x; }
void
incA () { ++a; }
static void incB () { ++b; }
private:
int
a = 0;
static int b = 1;
Programmierung II
32
const int c;
};
int main () {
C c;
c.incA();
c.incB();
}
7. Welchen Fehler enthält:
class Vektor {
public:
Vektor ();
...
private:
float x, y;
};
class Punkt {
public:
Punkt ();
...
float entfernt_von (Punkt);
private:
Vektor pos;
};
...
float Punkt::entfernt_von (Punkt p) {
return sqrt ( (pos.x - p.pos.x)*(pos.x - p.pos.x)
+
(pos.y - p.pos.y)*(pos.y - p.pos.y));
}
Wie ist er zu korrigieren?
8. Welche Fehler enthält:
class S {
public:
S() : y(y+1) {} // y um 1
˜S() : y(y-1) {} // y um 1
static void f () {
cout << x << ", " << y<<
}
private:
int
x = 1; // x mit
static int y = 0; // y mit
};
erhoehen
reduzieren
endl; // x und y ausgeben
1 initialisieren
0 initialisieren
Wie wird das jeweils Gemeinte – soweit es nicht schon korrekt ist – korrekt ausgedrückt?
9. Klassenvariablen müssen außerhalb der Klasse definiert werden:
class C {
public:
static int i;
};
int C::i = 1;
Müssen sie darum public sein, oder ist auch folgendes erlaubt:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
33
class C {
private:
static int i;
};
int C::i = 1;
Was ist mit
class C {
private:
static int i;
};
int main () {
int C::i = 1;
...
}
10. Komplexe technische Systeme sind aus Komponenten aufgebaut, die jeweils eine Schnittstelle und eine
Implementierung haben. Nennen Sie Beispiele und erläutern Sie wie dieses Prinzip auf Softwareprodukte
übertragen wird.
11. Erläutern Sie den Begriff der Kopplung am Beispiel einer Mengen–Klasse und ihrer Benutzer.
Aufgabe 2
1. Erläutern und begründen Sie jeden Einsatz von const in folgender Definition:
class Menge {
public:
Menge ();
// leere Menge
Menge (int);
// ein-elementige Menge
Menge operator+ (const Menge &) const; // Vereinigung
Menge operator* (const Menge &) const; // Schnitt
bool
istEnthalten (int) const;
// ist Element
private:
void fuegeEin (int i); // interne Hilfsfunktion
void entferne (int i); // interne Hilfsfunktion
int m[10];
int a;
};
//Test:
int main () {
Menge m1, m2(2);
Menge m3, m4;
m1 = m1 + Menge(2);
m1 = m1 + Menge(3);
m2
m2
m3
m4
=
=
=
=
m2
m2
m1
m1
+
+
+
*
Menge(3);
Menge(4);
m2;
m2;
}
2. Begründen Sie die Klassifikation der Methoden und Datenkomponenten in öffentlich und privat.
3. Warum ist das erste const in folgender Deklaration unsinnig, das zweite jedoch nicht?
Menge operator+ (const Menge) const;
Programmierung II
34
4. Wieso ist das erste const in folgender Deklaration nicht unsinnig?
Menge operator+ (const Menge &) const;
5. Wodurch unterscheiden sich die beiden Methoden:
Menge operator+ (Menge) const;
Menge operator+ (const Menge &) const;
Was sind die Vor– und Nachteile?
6. Ergänzen Sie die Klasse Menge um die Definition der Methoden. Benutzen Sie dabei soweit wie möglich
Initialisierer.
7. Formulieren Sie das Beispiel so um, dass Vereinigung und Schnitt freie Operatoren sind. Die privaten Komponenten dürfen dabei nicht öffentlich gemacht werden!
8. Formulieren Sie das Beispiel so um, dass Vereinigung und Schnitt statische Methoden sind.
9. Definieren Sie die Ausgabe von Mengen in drei Varianten:
(a) Eine Ausgabe–Methode.
(b) Eine freie Ausgabe–Funktion.
(c) Eine statische Ausgabe–Methode.
10. Definieren Sie die Operatoren “+” und “-” in:
class Menge {
public:
Menge ();
// leere Menge
Menge (int);
// ein-elementige Menge
Menge operator+ (const Menge &) const; // Vereinigung
Menge operator* (const Menge &) const; // Schnitt
bool
istEnthalten (int i) const;
// ist Element
private:
void fuegeEin (int i);
void entferne (int i);
int m[10];
int a;
};
als befeundete freie Operatoren.
Aufgabe 3
Konten haben einen Besitzer, einen Wert und einen Zinssatz. Der Zinssatz ist für alle Konten gleich: 3 Prozent für
Guthaben, 5 Prozent für Kredite (= negative Guthaben). Von einem Konto zum anderen können beliebige Beträge
überwiesen werden. Das Guthaben bzw. der Kredit eines Kontos kann für einen bestimmten Zeitraum verzinst
werden; das Guthaben wird dabei entsprechend verändert. Definieren Sie die Klasse der Konten mit geeigneten
Methoden und/oder Funktionen.
Aufgabe 4
1. Was versteht man unter einem Konstruktor und was ist der Default–Konstruktor?
2. Stimmt es, dass ...
(a) eine Klasse immer mindestens / genau / höchstens einen Konstruktor haben muss?
(b) eine Klasse immer einen Default–Konstruktor haben muss?
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
35
(c) der Compiler einen Default–Konstruktor erzeugt, wenn für eine Klasse keiner definiert wurde.
(d) Objekte immer mit einem Konstruktor initialisiert werden?
(e) Objekte, deren Klasse mindestens einen Konstruktor hat, immer mit einem Konstruktor initialisiert
werden.
(f) Klasssen mit Konstruktor auch einen Destruktor haben müssen?
(g) Klassen mit Destruktor auch einen Konstruktor haben müssen?
3. Welche Methoden dürfen Initialisierer enthalten?
4. Unter welchen Umständen muss ein Initialisierer vorhanden sein?
5. Was ist an folgender Definition falsch:
class A {
public:
A(int p) : x(p) {}
private:
int x;
};
class B {
public:
B(A a) { y = a; }
private:
A y;
};
dagegen aber
class A {
public:
A(int p) : x(p) {}
private:
int x;
};
class B {
public:
B(A a) : y(a) {}
private:
A y;
};
korrekt? Was unterscheidet die beiden Programmstücke?
Geben Sie eine Variablendefinition für eine Variable b vom Typ B an und erläutern Sie die Reihenfolge und
das Ergebnis der Initialisierung, je nach dem ob b global oder lokal in einer Funktion definiert wird.
6. Ist folgendes Programmstück korrekt? Wenn nein: warum nicht? Wenn ja: Wird der Konstruktor von X
aktiviert?
class X {
public:
X () : f(0.0) {}
float f;
};
class C {
public:
X
a;
float
b;
Programmierung II
36
};
int main () {
C c;
}
7. Was ist mit
class X {
public:
X () : f(0.0) {}
float f;
};
class C {
public:
C (X x) : a(x) {}
X
a;
float
b;
};
int main () {
C c;
}
Ist es korrekt? Wenn nein: Warum nicht? Wenn ja: Welche Konstruktoren werden in welcher Reihenfolge
aufgerufen?
8. Geben Sie ein Beispiel an, für den (seltenen) Fall eines Compiler–erzeugten Default–Konstruktors.
9. Eine Klasse erklärt eine andere zum Freund. Welche Auswirkung hat dies auf die Ausführung der Konstruktoren?
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
2
37
Objektorientierte Programmierung: Entwurf von Klassendefinitionen
2.1
2.1.1
Referenzen
Referenzparameter
Praktisch einsetzbare vollständige Klassendefinitionen benötigen meist Referenztypen. Eine Referenz ist ein Hinweis auf ein Objekt. Wir kennen das Konzept bereits von den Referenzparametern. Bei der Referenzübergabe wird
nicht das Objekt selbst an eine Funktion übergeben, sondern nur ein Hinweis – eine Referenz – auf dieses Objekt.
Rufen wir uns zunächst noch einmal die Funktionsweise von Referenzparametern an einem Beispiel in Erinnerung:
void f (int wp,
//
int &rp) { //
wp = wp + 1;
//
rp = rp + 1;
//
}
...
int main () {
int a = 1, b = 2;
f (a, b);
// a == 1, b == 3
..
}
Wertparameter
Referenzparameter
erhoehe wp
erhoehe das worauf rp zeigt
f hat einen Wertparameter wp und einen Referenzparameter rp. Innerhalb von f wird ein lokales Objekt wp
angelegt und mit dem Wert des aktuellen Parameters a initialisiert (a wird also nach wp kopiert). Die Anweisung
wp = wp + 1; bezieht sich auf dieses lokale Objekt und hat somit keine Wirkung nach außen.
Bei der Referenzübergabe von b wird dagegen nicht dessen Wert nach rp kopiert. rp ist innerhalb von f nur eine
andere Bezeichnung für den aktuellen Parameter b. Alle Aktionen wie etwa rp = rp + 1; beziehen sich auf
das “Original” b und haben somit eine Wirkung nach außen.
Eine Implementierung dieses Sprachkonzeptes kann (und wird in der Regel) so aussehen, dass innerhalb von f ein
lokales Objekt rp angelegt und mit der Speicheradresse von b initialisiert wird. Man beachte, dass der Compiler
dann für die beiden Zuweisungen innerhalb von f unterschiedlichen Code erzeugen muss. Im Gegensatz zu wp
enthält rp nicht den neuen Wert, sondern die Variable, deren Adresse (= Referenz) in rp zu finden ist. Der Zugriff
geht darum indirekt über rp auf b.
2.1.2
Referenzergebnisse
Eine Funktion kann statt eines Wertes (genauer eines r–Werts) auch eine Referenz als Ergebnis liefern. Referenzen
sind stets l–Werte. Damit ist es möglich, Funktionsaufrufe als l–Wert zu benutzen, etwa als linke Seite in einer
Zuweisung. Beispiel:
int & f (int &a) {
return a;
}
int main () {
int i, j;
f(i) = 1;
f(j) = 2;
}
// Funktion mit Referenzergebnis
// Funktionsaufruf liefert l-Wert:
// Funktionsaufruf darf links stehen
In diesem Beispiel kommt genau das gleiche aus der Funktion als Ergebnis heraus, wie als Argument in sie hinein
gegeben wurde: Eine Referenz auf eine Int–Variable. Die Funktion f gibt den l–Wert a zurück. Man vergleiche
das mit der Definition
int f (int & a)
return a;
Programmierung II
38
bei der der l–Wert a vor der Rückgabe in einen r–Wert konvertiert wird, das System also von der Variablen zu
deren Inhalt übergeht.
Ein Referenzergebnis muss nicht unbedingt als l–Wert genutzt werden. Da l–Werte in der Regel in r–Werte konvertiert werden können, kann die Funktion f sowohl links, als auch rechts vom Zuweisungszeichen auftauchen.
int & f () {...}
...
f() = f() + 1;
Natürlich sollte man vermeiden, dass eine Referenz auf eine Variable geliefert wird, die an der Aufrufstelle der
Funktion nicht mehr existiert, wie z.B. hier:
int & f () {
int x = 1;
return x; // FEHLER: liefert Referenz auf lokale Variable x
}
int g () {
int x = 1;
return x; // OK: liefert Wert 1
}
2.1.3
Referenzvariablen
Referenzvariablen sind Variablen, deren Inhalt eine Referenz ist. Referenzparameter sind somit Referenzvariablen.
Referenzvariablen können auch explizit mit einer Variablendefinition angelegt werden:
int
i;
int & j = i;
// Lege eine Variable i vom Typ int an
// j ist eine Referenzvariable, sie enthaelt eine Referenz auf i
j wird hier als Referenz auf – und quasi als ein anderer Name von – i deklariert. Alle Aktionen auf j beziehen sich “in Wirklichkeit” auf i. Im Gegensatz zu einer “normalen” Variablen ist bei Referenzdefinitionen die
Initialisierung unbedingt notwendig.
Ebenfalls im Gegensatz zu “normalen” Variablen sind Initialisierung und Zuweisung bei Referenzen völlig unterschiedliche Aktionen:
int
i = 1;
int & j = i;
..
j = i;
j = k;
// Lege eine Variable vom Typ int an und belege sie mit 1
// j ist eine Referenzvariable, belegt mit einer Referenz auf i
// Sinnlose Zuweisung an sich selbst (entspricht i = i;)
// Zuweisung des Wertes von k an i (!), d.h. an das, auf das die
// Referenz j zeigt
Der Wert der Variable j wird nur bei der Initialisierung verändert. Alle anderen Aktionen auf j verändern i, also
das, auf das j verweist.
2.1.4
Referenzinitialisierung
Nach der Definition (mit Initialisierung) kann eine Referenz nicht mehr verändert werden. D.h. wenn j eine Referenz auf i ist, dann bleibt das so bis zum Ende von j.
Anders als Referenzparameter und –Ergebnisse, haben Variablen mit Referenztyp nur wenige sinnvolle Anwendungen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
2.1.5
39
Referenzen sind l–Werte
Referenzen sind l–Werte: Sie können auf der linken Seite einer Zuweisung stehen. Allerdings können sie (wie alle
anderen l–Werte) auch auf der rechten Seite einer Zuweisung auftreten – oder ganz allgemein in jedem Kontext,
in dem ein r–Wert benötigt wird. In diesem Fall sorgt der Compiler dafür, dass aus dem l–Wert ein r–Wert erzeugt
wird: Statt der Variablen (l–Wert) wird ihr Inhalt (r–Wert) genommen. Aus r–Werten kann dagegen nicht so einfach
ein l-Wert konstruiert werden. Beispiel:
int
i = 5;
int & j = i;
int f (int & pf) { //liefert r-Wert, verlangt l-Wert
pf = pf + 1;
return pf;
}
int & g (int pg) { //liefert l-Wert, verlangt r-Wert
pg = pg + 1;
return i;
}
...
j = 4;
// OK: j ist l-Wert; 4 ist r-Wert
5 = i;
// FEHLER: 5 ist kein l-Wert;
i = j;
// OK: i ist l-Wert;
//
j ist l-Wert -> Konversion in r-Wert moeglich;
i = f (5); // KEIN FEHLER: 5 ist kein l-Wert, der Compiler legt eine
//
Variable (l-Wert) mit Inhalt 5 an.
//
WIRD NICHT EMPFOHLEN !
j = g (i); // OK: j ist l-Wert, i ist l-Wert,
f(i) = 12; // FEHLER: Aufruf von f ist kein l-Wert
g(i) = 5; // OK: i ist l-Wert -> Konversion in r-Wert moeglich;
//
g(i) ist l-Wert;
i = g(5); // OK: i ist l-Wert; 5 ist r-Wert;
//
g(5) ist l--Wert -> Konversion in r-Wert moeglich;
In diesem Beispiel sind alle Stellen, an denen ein l–Wert dort auftaucht, wo ein r-Wert erwartet wird, ohne Fehler.
Jedes Vorkommen eines r-Werts dort, wo ein l-Wert verlangt wird, ist jedoch ein Fehler.
2.2
Ein– und Ausgabe selbst definierter Typen
2.2.1
Ein–/Ausgabe–Operatoren überladen
Referenzen werden für praktische Zwecke öfters benötigt. Das erste Beispiel ist die Ein– und Ausgabe selbst
definierter Typen. Bisher haben wir Ein–/Ausgabe für eine Klasse als Methoden definiert. Definiert man sie als befreundete überladene Operatoren, dann können Objekte selbstdefinierter Typen genau so wie Objekte vordefinierter
Typen ein– und ausgegeben werden. Beispiel:
class Vektor {
friend ostream & operator<< (ostream &, const Vektor &); // Ausgabe- und
friend istream & operator>> (istream &, Vektor &);
// Eingabe-Operatoren
public:
// sind Freunde
...
private:
float x, y;
};
//-- Ausgabe als befreundete freie Funktion:
ostream & operator<< (ostream &os, const Vektor &v) {
os << x << ", " << y;
Programmierung II
40
return os;
}
//-- Eingabe als befreundete freie Funktion:
istream & operator>> (istream &is, Vektor &v) {
is >> v.x >> v.y;
return is;
}
...
//-- Verwendung
Vektor v;
cin >> v;
// Einlesen und
cout << v;
// Ausgeben wie bei vordefinierten Typen
Der Ausgabeoperator << und der Eingabeoperator >> (die Shift–Operatoren) werden hier zur Aus– und Eingabe eines Objektes mit dem selbst definiertem Typ Vektor überladen. Man muss dabei beachten, dass << eine Referenz
auf ein ostream–Objekt (output stream Objekt) als Argument hat und ein solches liefern muss. Entsprechendes
gilt für >>.
Die Operatoren liefern Ein/Ausgabeströme als Referenzen und haben entsprechende Parameter. Man merkt sich
dieses Beispiel am Besten als Muster für beliebige Klassen mit definierten Ein/Ausgabefunktionen:
// MUSTER ZUR DEFINITION VON EIN-/AUSGABE-OPERATOREN
// fuer beliebige selbst definierte Klassen
class T {
friend ostream & operator<< (ostream &, const T &);
friend istream & operator>> (istream &, T &);
...
};
ostream & operator<< (ostream &os, const T &t) {
... t auf os ausgeben ...
return os;
}
istream & operator>> (istream &is, Vektor &t) {
... t von is lesen ...
return is;
}
...
T tt;
cin >> tt;
cout << tt;
2.3
2.3.1
Zustands– und Wertorientierte Klassen
Was ist die richtige Klassendefinition?
Die Mechanik der Klassendefinition zu verstehen ist eine Sache. Eine andere ist, die richtigen Klassen zu einer
bestimmten Anwendung zu finden. Es gibt in der Regel nicht die richtige Klasse Menge zur Darstellung von
“Mengen”, oder die richtige Klasse TelefonNr zur Darstellung von “Telefonnummern”. Es gibt nur eine für
bestimmte Anwendungen mehr oder weniger geeignete Klasse zur Darstellung von “Mengen”, “Telefonnummern”
oder anderen Dingen.
Bisher haben wir uns bei einem strukturierten Datentyp in erster Linie gefragt: “Was ist ein Objekt; aus was besteht
es?” und die Datentypen wurden entsprechend definiert. Dieses Denken ist nicht falsch, es ist aber zu ergänzen um
die Frage, wie Objekte der zu definierenden Klasse verwendet werden sollen.
Angenommen wir wollten ein Klasse “Punkt” definieren, deren Objekte Punkte in der Ebene darstellen. Die erste
Frage – “Was ist / aus was besteht ein Punkt” – ist schnell beantwortet: Ein Punkt ist eine Position im zweidimensionalen Koordinatensystem und wird durch seine Koordinaten beschrieben:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
41
class Punkt {
... float x, y; ...
};
2.3.2
Die Verwendung definiert die Schnittstelle einer Klasse
Jetzt stellt sich die Frage nach der Verwendung: “Wer benutzt Punkte zu welchem Zweck”. Wir stellen uns dazu
zwei Szenarien vor:
PunktA, mathematische Punkte: In einer mathematisch orientierten Anwendung soll beispielsweise der
Schnitt von Geraden als Punkt berechnet, oder die Entfernung von zwei Punkten bestimmt werden.
PunktB, graphische Punkte: In einer graphisch orientierten Anwendung werden Punkte als Positionen auf
dem Bildschirm verwendet. Punkte dieser Variante sollen beispielsweise in der Ebene (auf dem Bildschirm)
umherwandern können.
Entsprechend der unterschiedlichen Verwendung haben unsere Punkte unterschiedliche Schnittstellen, also unterschiedliche öffentliche Methoden.
2.3.3
PunktA: Punkte als reine Werte
Die “mathematischen Punkte” sind:
class PunktA { // mathematische Punkte
public:
Punkt (float px, float py)
: x(px), y(py) {}
float distanzZu (const Punkt &p) const {
return sqrt((x-p.x)*(x-p.x) + (y-p.y)*(x-p.x));
}
private:
const float x, y;
};
// Punkte erzeugen
// Distanz zweier Punkte
// Koordinaten
Punkte müssen demnach immer mit bestimmten Koordinaten erzeugt werden. Diese können dann nicht mehr
verändert werden. Die “Philosophie” dieser Klassendefinition ist:
Punkte sind reine unveränderliche Werte ohne eigene Individualität.
Die Zahl ist ewig und unveränderlich. Ich kann keine zu einer machen, indem ich zwei dazu addiere.
ergibt als Wert, aber die ist keine veränderte . Genauso gibt es einen einzigen Punkt und der liegt für
immer und ewig an der Position . So wie nicht zu einer werden kann, so wenig kann der Punkt an
eine andere Position wandern. Punkte heben keine Individualität. Zwei Punkte, die an der Position liegen sind
nicht unterscheidbar, sie sind identisch, also letztlich der gleiche Punkt.
2.3.4
PunktB: Punkte als zustandsorientierte Klasse
Die “graphischen Punkte” sind:
class PunktB { // graphische Punkte
public:
Punkt()
: x(0), y(0) {}
void bewege (float dx, float dy) {
x = x+dx; y = y+dy;
}
// Punkte erzeugen
// Punkte in x- und y-Richtung bewegen
Programmierung II
42
private:
float x, y;
};
// Koordinaten
Punkte werden immer an der Position erzeugt. Von dort können sie mit der Methode bewege verschoben
werden. Punkte sind in diesem Verständnis veränderliche Wesen. Sie haben einen Zustand – ihre aktuelle Position
– der jederzeit verändert werden kann. Die “Philosophie” dieser Klassendefinition ist jetzt:
Punkte sind Dinge mit einem veränderlichen Zustand und eigenener Individualität.
Ein Punkt kann erzeugt werden. Er hat gewisse Eigenschaften. Diese können sich ändern. Das nennt man “Zustandsänderung”. Bei allen Zustandsänderungen bleibt der Punkt aber immer der Gleiche. Zwei Punkte, die beide
an der Position liegen, sind keinesfalls die gleichen Punkte: der eine wandert gleich vielleicht nach oben und
der andere nach unten.
2.3.5
Unterschiedliche Anwendungen benötigen unterschiedliche Punkte
Eine wichtige Leitlinie beim Entwurf einer Klasse ist also die Frage, wie die Objekte der Klasse verwendet werden
sollen. Hierbei kann man zwei prinzipielle Arten unterscheiden:
Sind die Objekte der Klasse im Verständnis der Anwendung reine unveränderliche Werte oder
haben sie einen Zustand der sich im Laufe ihres Lebens ändern kann.
Die Antwort auf diese Frage führt dann zu einer wertorientierten oder einer zustandsorientierten Klassendefinition.
Die Unterscheidung ist allerdings nicht dogmatisch zu sehen, es ist ein Hinweis für den Entwurf einer Klassendefinition. Ein erster und wichtiger Hinweis, aber nur ein Hinweis.
2.3.6
Stapel, zustandsorientiert
Das natürliche Verständnis eines Stapels ist zustandsorientiert. Ein Stapel hat eine Identität, die er bei allen
Veränderungen im Laufe seines Lebens beibehält. Zwei Stapel sind nie derselbe auch wenn auf beiden das Gleiche
aufgestapelt ist. Ein Stapel hat einen Zustand: das was gerade auf ihm gestapelt ist. Sein Zustand kann verändert
werden, indem man etwas auf ihm ablegt oder wegnimmt. Es bleibt aber derselbe Stapel. Eine von dieser Sicht
inspirierte Definition eines Stapels (engl. Stack) von Integer–Werten ist:
class Stack {
public:
Stack ();
// Konstruktor, erzeugt den leeren Stapel
// Leseoperationen, mit Ergebnis, konstant
char top
() const; // liefert das oberste Element
bool empty () const; // ist der Stapel leer
bool full () const; // ist der Stapel voll
// Schreiboperationen, ohne Ergebnis, nicht konstant
void pop
();
// veraendert Stapel-Zustand: entfernt Element
void push (int);
// veraendert Stapel-Zustand: legt ein Element ab
private:
int count;
// aktuelle Zahl der Elemente
int a[10];
// Ablageplatz fuer die Elemente
};
Ein Stapel–Objekt
hat einen Zustand, der sich aus den in ihm abgelegten Elementen ergibt.
Mit lesenden Zugriffsoperationen kann man etwas über den Zustand erfahren. (Meist konstante Methoden
mit Ergebnis.)
Mit schreibenden Zugriffsoperationen kann der Zustand verändert werden. (Meist nicht–konstante Methoden
ohne Ergebnis.)
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
2.3.7
43
Verwendung zustandsorientierter Klassen
Eine von der zustandsorientierten Sicht inspirierte Klasse wird in der Regel dazu verwendet eine begrenzte Zahl
von Objekten zu erzeugen, die dann wechselnde Zustände durchlaufen. Beispielsweise könnte die Klasse Stack
wie folgt verwendet werden:
Stack s;
int
i;
...
s.push(i);
...
if (!s.empty()) {
i = s.top ();
s.pop ();
...
}
// Ablegen
// Ist er leer ?
// Entnehmen
Derartige Klassen entsprechen dem klassischen Verständnis von Objektorientierung, nach dem eine Klasse ein
Modell einer endlichen Anzahl von Dingen mit jeweils eigener Identität ist. Ein Objekt wird dabei als “Automat”
mit wechselnden Zuständen betrachtet.
2.3.8
Komplex: Eine Klasse mit wertartigen Instanzen
Komplexe Zahlen sind etwas anderes als Stapel. Legt man auf einem Stapel etwas ab, dann verändert er sich. Addiert man dagegen zu einer komplexen Zahl eine andere, dann verändert sich nicht etwa eine der beiden, sondern als
Ergebnis entsteht eine dritte. Werte haben zwar auch eine interne Struktur, diese stellt aber keinen veränderlichen
Zustand dar.
Definition und Verwendung einer wertartigen Klasse zur Modellierung komplexer Zahlen können etwa wie folgt
aussehen:
class Komplex {
public:
Komplex ();
Komplex (float, float);
Komplex operator+ (const Komplex &) const;
private:
const float re;
const float im;
};
...
Komplex k1, k2, k3;
...
k3 = k1 + Komplex(0.0, 2.3);
Typische Eigenschaften einer wertartigen Klassendefinition sind:
In einem Programmlauf werden viele Instanzen erzeugt.
Instanzen werden auch per Wertübergabe an Funktionen übergeben.
Funktionen geben Objekte dieser Klasse als Wert zurück.
Variablen vom entsprechenden Typ werden zugewiesen.
Programmierung II
44
1/2
1/3
2/3
5/6
Wert
Variable
Modell
Modell
class Bruch {
Bruch b1, b2;
Modell für
Werte und ihre Operationen
public:
Bruch ();
Bruch operator+ (const Bruch &) const;
. . .
};
b1 = b2;
Abbildung 5: Eine wertorientierte Klasse
2.3.9
Brüche als wertorientierte Klasse
Nehmen wir an, dass Brüche zu verarbeiten seinen. (Siehe Abbildung 5.) “Bruch” ist ein Wertekonzept. Die Objekte der Klasse Bruch modellieren reine Werte. Ein Wert kann Inhalt einer Variablen sein, Eine Variable kann
ihren Wert wechseln, ein Bruch – ein Wert – dagegen kann sich niemals ändern. Eine Variable vom Typ Bruch
kann beliebige Objekte der Klasse Bruch als Wert enthalten. Nicht die Variable repräsentiert den Bruch sondern
der Inhalt der Variablen – der Wert.
2.3.10 Reiner Wert oder Zustandsorientiert?
Man kann den Stapel auch als reine Werte ansehen und komplexe Zahlen oder Brüche als etwas zustandsbehaftetes
ansehen. In diesem Fall wäre etwa push eine Funktion, die nicht einen Stapel verändert, sondern einen neuen
Stapel erzeugt:
class Stack { // wertorientierter Stapel: SELTSAM
public:
Stack ();
Stack push (Stack, int) const; // erzeugt neuen Stapel
...
};
...
Stack s1, s2;
...
s2 = push (s1, 5); // neuen Stapel als Wert von s2 erzeugen,
// s1 bleibt unveraendert
Die Addition als Methode komplexer Zahlen mit Zustand wäre:
class Komplex {
public:
Komplex ();
// zustandsorientierte komplexe Zahlen: SELTSAM
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
45
void add (const Komplex &k) { // addiere zu mir
re = re+k.re; im = im+k.im;
}
private:
float re;
float im;
};
...
Komplex k1, k2;
...
k1.add(k2); // die komplexe Zahl k1 wird durch Addition von k2 modifiziert
Wertorientierte Klassen sind “mathematisch” indem sie ewige unvergängliche Konzepte modellieren. Zustandsorientierte Klassen modellieren die “Wirklichkeit” mit ihren veränderlichen, greifbaren vergänglichen Dingen. Ob
ein Sachverhalt eher dem einen oder dem anderen Muster folgt, legt oft schon das “Gefühl” nahe. Stapel, die man
nicht verändern kann, und komplexe Zahlen, die einen veränderlichen Zustand haben, sind nicht völlig falsch, aber
doch zumindest sehr “eigenwillig”.
Die Entscheidung, ob eine zu definierende Klasse als reiner Wert oder mit veränderlichem Zustand verwendet
werden soll, ist zwar nicht immer so offensichtlich, wie in diesen beiden Beispielen, aber glücklicherweise doch
recht oft.
2.3.11 Konzeptionelles Chaos vermeiden
Ein klares Konzept für eine Klasse ist immer wichtiger als das “richtige” Konzept. Eine Klasse für komplexe
Zahlen, deren Schnittstelle beide Konzepte mischt, ist verwirrend kompliziert und sollte vermieden werden:
class Komplex { // PFUI, TOTAL VERMURKSTE komplexe Zahlen
public:
Komplex ();
void
add (const Komplex &);
// zustandsorientierte Addition
Komplex sub (const Komplex &) const; // wertorientierte Subtraktion
private:
float re;
float im;
};
...
Komplex k1, k2;
...
k1.add(k2);
k2 = k1-k2;
Hinter der Schnittstelle, im privaten Bereich der Klasse, ist natürlich alles erlaubt. Die Schnittstelle definiert die
mögliche Verwendung und die sollte auf einem möglichst einfachen und klaren Konzept beruhen.
class Komplex { // Wert-orientierte komplexe Zahlen
public:
// Schnittstelle, hier achten wie auf Stil
// (Was sollen sonst die Leute denken ..)
Komplex ();
Komplex (float r, float i) : re(r), im(i) {}
Komplex add (const Komplex &k) const { // wertorientiert
Kompex res = k;
k.add( Komplex(re, im) );
return res;
}
private:
Programmierung II
46
// Implementierung: erlaubt ist, was funktioniert
void add (const Komplex &k) {
re = re+k.re; im = im+k.im;
}
float re;
float im;
};
...
Komplex k1, k2;
...
k1.add(k2); // die komplexe Zahl k1 wird durch Addition von k2 modifiziert
2.4
2.4.1
Von Abstrakten zu Konkreten Datentypen: Zuweisung und Kopie
Abstrakte und konkrete Datentypen
Datentypen wie int, char, float, etc., sind konkret. Sie werden von der Programmiersprache direkt unterstützt. Man kann Variablen von diesem Typ definieren, ihnen Werte zuweisen und mit speziellen Operatoren
verknüpfen. Abstrakte Datentypen existieren dagegen nicht wirklich. Sie sind eine Fiktion des Programms bzw.
seiner Autoren.
In einem Programm einer Finanzbehörde etwa könnten Mengen von Hunden verwaltet werden: Die Hunde für
welche die Hundesteuer schon bezahlt wurde, diejenigen bei denen sie noch aussteht und die Menge der Hunde,
die steuerbefreit sind. Es gibt zwar keine Programmiersprache die den Datentyp “Hund”, oder gar “Hunde–Menge”
anbietet5, aber mit Feldern, Verbunden, Klassen oder anderen Datentypen sowie einigen geeigneten Routinen kann
man einen Datentyp “Hund” oder “Hunde–Menge” simulieren: “Hunde” und “Hunde–Menge” sind abstrakte Datentypen (ADT, Abstract Data Type).
2.4.2
Klassen können zur Definition abstrakter Datentypen verwendet werden
C++ wurde unter anderem mit dem Ziel definiert, die Arbeit mit derartigen “Datenabstraktionen” zu erleichtern.
Das Klassenkonzept unterstützt die Definition abstrakter Datentypen direkt: Man kann eine Klasse Hund mit allen
benötigten Methoden definieren und hat dann den Datentyp “Hund”.
2.4.3
Konkrete Datentypen: mustergültige Klassen
Eine Klasse kann verwendet werden, um Variablen anzulegen. Der abstrakte Datentyp entspricht in dieser Hinsicht einem konkreten vordefinierten Typ. Das gilt aber nicht immer für alle Aspekte eines Typs. Ein Klasse ist
nicht unbedingt automatisch einem vordefinierten Typ gleichwertig. Es ist eine besondere Eigenschaft von C++,
dass es möglich ist eigene Datentypen – als Klassen – so zu definieren, dass sie sich genau so verhalten wie die
“eingebauten” konkreten Datentypen. Was dazu zu tun ist, ist Thema dieses Abschnitts.
Verhält sich eine Klasse wie ein vordefinierter Typ, dann besteht kein Grund mehr sie mit einem so abschreckenden
Begriff wie “abstrakt” zu versehen.6
Konkrete Typen sind also nicht nur die vordefinierten, sondern, außer diesen, auch alle selbstdefinierten, die sich
wie vordefinierte verwenden lassen. Es sind damit mustergültige Klassendefinitionen. Um solche Musterknaben
von konkreten Datentypen selbst zu definieren, müssen wir zunächst einmal herausfinden, was ein vordefinierter –
also ein von “Natur aus” konkreter – Datentyp kann.
2.4.4
Fähigkeiten konkreter Datentypen
Ein vordefinierter Typ unterstützt in jedem Fall folgende Aktionen:
5 Zumindest keine, die
6 Abstraktion ist
dem Autor bekannt ist
ein Kernkonzept der Informatik. Der Begriff “abstrakt” sollte für Informatiker nicht abschreckend klingen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
47
Variablendeklarationen
Zuweisung
Parameterübergabe per Referenz
Parameterübergabe per Wert
Dazu kommen dann in der Regel noch eine Reihe von Operatoren.
Ein selbst definierter Datentyp ist konkret, wenn auch mit ihm all dies möglich ist. Mit Klassen können selbst definierte konkrete Datentypen realisiert werden. Nur bei ihnen hat man die Möglichkeit die Wirkung einer Zuweisung
etc. selbst zu bestimmen.
2.4.5
Komponenten konkreter Datentypen
Damit Objekte einer selbstdefinierten Klasse so flexibel verwendet werden können wie Werte vordefinierter Typen,
muss die Klassendefinition einige Standard–Komponenten enthalten:
Defaultkonstruktor (nicht vordefiniert):
Ein parameterloser Konstruktor, der sogenannte Default–Konstruktor. Er wird bei einer einfachen Variablendefinition benötigt, sowie dann, wenn ein Objekt als Feldelement oder als Datenelement eines anderen
Objektes angelegt wird.
Kopierkonstruktor (vordefiniert):
Der Kopier–Konstruktor erzeugt eine identische Kopie eines Objektes. Er wird vor allem bei der Wertübergabe eines Objekts benötigt.
Zuweisungsoperator (vordefiniert):
Diese Komponente wird bei Zuweisungen benötigt.
Vergleichsoperator (nicht vordefiniert):
In vielen Fällen wird man die Objekte mit == vergleichen wollen, dazu wird eine Definition des Vergleichsoperators benötigt.
Sollten diese Komponenten nicht explizit im Programmcode definiert sein, dann werden sie nur zum Teil (nur
Zuweisung und Kopie !) vom Compiler automatisch erzeugt. Der automatisch erzeugte Zuweisungsoperator und
der Kopierkonstruktor kopieren einfach alle Datenkomponenten. Auf diese Automatik sollte man sich aber nur mit
Bedacht verlassen.
2.4.6
Vektoren als konkreter Datentyp
Als Beispiel wollen wir einen konkreten Datentyp Vektor definieren.
Vektoren als konkreter Datentyp:
class Vektor { // Vektoren als konkreter Datentyp, Minimalversion
public:
Vektor () : x(0.0), y(0.0)
{} // Defaultkonstruktor
Vektor (float a, float b) : x(a), y(b) {} // Konstruktor
bool operator== (const Vektor &) const {
// Vergleich
return (x == v.x) && (y == v.y);
}
private:
float x, y;
};
Der Vergleichsoperator muss definiert werden, weil sonst keine Vergleiche möglich wären. Ein Destruktor ist nicht
notwendig: beim Tod eines Vektors muss nichts weiter geschehen.
Programmierung II
48
2.4.7
Kopierkonstruktor
Der Kopier–Konstruktor wird bei der Wertübergabe benötigt. Bei der Wertübergabe wird vom aktuellen Parameter
eine Kopie erzeugt und an die Funktion übergeben. Der Compiler ruft den Kopierkonstruktor auf, um diese Kopie
zu erzeugen.
Wurde – so wie hier – der Kopier–Konstruktor nicht explizit definiert, dann erzeugt der Compiler ihn selbst. Der
automatisch erzeugte Kopier–Konstruktor kopiert einfach alle Datenelemente. Wir definieren einen Kopierkonstruktor, der dem automatisch erzeugten entspricht:
class Vektor {
public:
...
// Kopierkonstruktor:
Vektor(const Vektor &v) : x(v.x), y(v.y) {} // initialisiere mich mit
...
// einer Kopie von v
};
Man beachte dass der Parameter per Referenz übergeben werden muss: der Kopierkonstruktor definiert die
Wertübergabe, kann sie also nicht selbst benutzen.
2.4.8
Zuweisungen und der Zuweisungsoperator
Zuweisungen von einer Variablen an eine andere sind jederzeit auch ohne besondere Definitionen möglich. D.h.
Vektoren können ohne weiteres zugewiesen werden:
Vektor v1, v2;
v1 = v2;
Zu jeder Klasse, zu der nicht explizit ein Zuweisungsoperator definiert wurde, erzeugt ihn der Compiler automatisch. Von einem solchen implizit definierten Zuweisungsoperator werden einfach alle Datenkomponenten kopiert.
Wie beim Kopierkonstruktor ist die automatisch generierte Zuweisung oft – aber nicht immer – völlig korrekt und
ausreichend. Auch im Fall unserer Vektoren ist der automatisch erzeugte Zuweisungsoperator völlig ausreichend.
Der automatisch erzeugte Zuweisungsoperator entspricht folgender expliziten Definition:
class Vektor {
public:
...
Vektor & operator= (const Vektor &v) { // Zuweisungsoperator
x=v.x; y=v.y;
// Belege mich mit einer Kopie von v
return *this;
// und liefere eine Referenz auf mich
}
...
};
Der Zuweisungsoperator sieht dem Kopierkonstruktor recht ähnlich. Das ist sicher nicht erstaunlich, sie haben ja
sehr ähnliche Aufgaben. Die Unterschiede sind:
Keine Initialisierer im Zuweisungsoperator: Da der Zuweisungsoperator kein Konstruktor ist, können keine
Initialisierer verwendet werden.
Rückgabewert im Zuweisungsoperator: Der Kopierkonstruktor darf als Konstruktor keinen Wert liefern. Der
Zuweisungsoperator dagegen muss einen Wert liefern.
2.4.9
this: dieser da, das bin Ich!
Der Zuweisungsoperator verändert das Objekt für das er aufgerufen wird und liefert eine Referenz auf dieses
veränderte Objekt:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
49
Vektor & operator= (const Vektor &v) { ... return *this; }
this ist ein Schlüsselwort, das in jeder Methode verwendet werden kann, und dort mit einem Verweis auf das
aktuelle Objekt belegt ist. Ist beispielsweise x.m() aktiv, dann bezeichnet this in
X::m() ... this ...
einen Verweis auf das Objekt x vom Typ X. *this bezeichnet im Gegensatz zu this nicht den Verweis auf das
Objekt, sondern das Objekt selbst. Der Sternoperator “*” wird im nächsten Kapitel ausführlich besprochen. Wir
merken uns hier einfach, dass jeder Zuweisungsoperator mit
return *this;
endet.
2.4.10 Die Zuweisung liefert eine Referenz auf ihre linke Seite
Wie bei allen anderen dyadischen Operatoren (solchen mit zwei Operanden) gilt auch bei der Zuweisung folgende
Äquivalenz:
a=b;
a.operator=(b);
Der Zuweisungsoperator muss als Resultat einen Wert liefern, weil in C++ (wie in C) Zuweisungen als Ergebnis
stets einen Wert liefern. Es ist notwendig weil a = b = c; erlaubt ist und – wenn man Seiteneffekte bei der
Auswertung von a, b und c ignoriert – das Gleiche wie: b = c; a = b; bedeutet:
a=b=c;
a.operator=(b.operator=(c));
a=(b=c);
Das Ergebnis der Zuweisung wird hier zugewiesen. Das macht es noch nicht notwendig, dass die Zuweisung einen
l–Wert liefert, ein r–Wert wäre schon ausreichend. Allerdings ist die Form
(a=b)=c;
(a.operator=(b)).operator=(c);
ebenfalls erlaubt. Hier wird jetzt ein Wert (der von c) an das Ergebnis einer Zuweisung (das durch die erste
Zuweisung veränderte a) zugewiesen. Das Ergebnis muss darum ein l–Wert sein. Der Zuweisungsoperator muss
also einen Wert liefern, dieser Wert muss ein l–Wert sein und der Typ eines l–Werts ist ein Referenztyp.
Wem das zu kompliziert ist, merke sich einfach das verbindliche Muster eines Zuweisungsoperators, ohne darüber
nachzudenken, warum er so aussieht.
2.4.11 Vergleichsoperator
Ein ordentlicher Datentyp bietet die Möglichkeit seine Mitglieder mit dem Vergleichsoperator == zu vergleichen.
Wir definieren darum auch einen Vergleichsoperator für unsere Vektoren.
class Vektor {
public:
...
bool operator== (const Vektor &) const { // Vergleichsoperator
return (x == v.x) && (y == v.y); // Vergleiche mich mit v
}
...
};
Im Gegensatz zum Zuweisungsoperator und Kopierkonstruktor wird der Vergleichoperator niemals automatisch
erzeugt.
2.4.12 Vektor als konkreter Datentyp
Zum Schluss noch einmal die gesamte Definition des konkreten Datentyps Vektor:
Programmierung II
50
class Vektor { // Vektoren als konkreter Datentyp
public:
Vektor () : x(0.0), y(0.0) {}
// Defaultkonstruktor
Vektor (float a, float b) : x(a), y(b) {}
// Konstruktor
Vektor(const Vektor &v) : x(v.x), y(v.y) {} // Kopierkonstruktor
// (so schon vordefiniert)
bool operator== (const Vektor &v) const {
// Vergleich
return (x== v.x) && (y == v.y);
}
Vektor & operator= (const Vektor &v) {
// Zuweisung
x=v.x; y=v.y;
// (so schon vordefiniert)
return *this;
}
Vektor operator+ (const Vektor &v) const {
// Vektoraddition
return Vektor(x+v.x, y + v.y);
}
private:
float x, y;
// Datenkomponenten
};
Zuweisungsoperator und Kopierkonstruktor entsprechen dem was der Compiler automatisch erzeugt hätte.
Natürlich wird man in einer realistischen Anwendung noch weitere Methoden definieren.
Die besondere Eigenschaft von C++ besteht darin Zuweisung und Kopie selbst definieren zu können. Im Beispiel
oben haben wir das getan. Allerdings war das keine so besonders mitreißende Aktion: Hätten wir es nicht getan,
dann hätte der Compiler exakt die gleichen Definitionen automatisch erzeugt. Wenn die Freiheit, Kopie und Zuweisung selbst definieren zu können, zu mehr nutze ist als nur beliebigen Unsinn treiben zu können, dann muss
es Klassen geben, für die der Compiler Kopie und Zuweisung eben nicht korrekt erzeugen kann – und die gibt es
tatsächlich.
2.4.13 Vektoren die ihre Exemplare zählen
Die Klasse Vektor soll in dieser Variante die Zahl ihrer Objekte zählen. Das gibt uns Grund einen Destruktor zu
definieren, sowie einen Kopierkonstruktor der sich von dem unterscheidet den der Compiler automatisch erzeugen
würde.
Vektoren mit Objektzählung als konkreter Datentyp:
class Vektor { // Vektoren mit Objektzaehlung
friend ostream & operator<< (ostream &, const Vektor &);
friend istream & operator>> (istream &, Vektor &);
public:
Vektor ();
// Defaultkonstruktor
Vektor (float, float);
// Konstruktor
˜Vektor ();
// Destruktor
Vektor(const Vektor &);
// Kopierkonstruktor
bool operator== (const Vektor &) const; // Vergleich
Vektor & operator= (const Vektor &);
// Zuweisung
Vektor operator+ (const Vektor &) const;// dyadischer Operator
static int zV ();
// statische Methode
private:
static int vc;
// statische Datenkomponente
float x, y;
// Datenkomponenten
};
// statische Datenkomponente (Klassenvariable) definieren
int Vektor::vc = 0;
// Konstruktoren (muessen alle definiert werden):
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
51
Vektor::Vektor ()
: x(0.0), y(0.0) {++vc;}
Vektor::Vektor (float a, float b) : x(a),
y(b) {++vc;}
// Kopierkonstruktor (entspricht NICHT dem vordefinierten)
Vektor::Vektor (const Vektor &v) : x(v.x), y(v.y) {++vc;}
// Destruktor:
Vektor::˜Vektor()
{--vc;}
// Zuweisung (entspricht der vordefinierten):
Vektor & Vektor::operator= (const Vektor &v) {
x=v.x; y=v.y;
return *this;
}
// Vergleich
bool Vektor::operator== (const Vektor &v) const {
return (x== v.x) && (y == v.y);
}
// Operator +
Vektor Vektor::operator+ (const Vektor &v) const {
return Vektor (x + v.x, y + v.y);
}
// statische Methode
int Vektor::zV () { return vc; }
// Ein-/Ausgabe
ostream & operator<< (ostream &os, const Vektor &v) {
return os << "(" << v.x << ", " << v.y << ")";
}
istream & operator>> (istream & is, Vektor & v) {
char c;
is >> c;
if (c != ’(’) {cout << "FEHLER: ( erwartet!\n"; return is;}
is >> v.x;
is >> c;
if (c != ’,’) {cout << "FEHLER: , erwartet!\n"; return is;}
is >> v.y;
is >> c;
if (c != ’)’) {cout << "FEHLER: ) erwartet!\n"; return is;}
return is;
}
Hier muss der Kopierkonstrutor selbst definiert werden. Bei der Erzeugung eines Vektors durch Kopie wird die Zahl
der Vektoren erhöht. Dies muss sich im Wert von vc niederschlagen. Der automatisch erzeugte Kopierkonstruktor
würde vc nicht verändern und wäre darum nicht korrekt. Wir haben es hier also mit einem Beispiel dafür zu tun,
dass der Programmierer selbst definieren muss, was es bedeutet, ein Objekt zu kopieren. Diese Erklärung liefert er
im Kopierkonstruktor.
Der Kopierkonstruktor erlaubt es, die Aktion des Kopierens eines Objekts bei Bedarf
selbst zu definieren.
Der Zuweisungsoperator erlaubt es, die Aktion der Zuweisung eines Objekts bei Bedarf selbst zu definieren.
Die explizite Definition des Zuweisungsoperators ist auch in diesem Beispiel eigentlich überflüssig. Die vordefinierte Methode verhält sich genauso wie die hier definierte.
Programmierung II
52
Das Beispiel dient als Muster einer wertorientierten Klasse. So macht man es im Prinzip immer! Alle wertorientierten Klassen sollten nach diesem Muster als konkrete Datentypen definiert werden! (Die statischen Komponenten
sind eher untypisch.)
2.4.14 Vordefinierte überladene Operatoren
Für alle benutzerdefinierten Klassen haben folgende Operatoren eine vordefinierte Bedeutung:
= Zuweisung
& Adressoperator (Adresse eines Objekts)
, Komma–Operator (Hintereinander–Auswertung von Ausdrücken)
Alle anderen Operatoren müssen bei Bedarf selbst definiert werden. Der Zuweisungsoperator muss nur dann selbst
definiert werden, wenn er nicht – wie vordefiniert – als komponentenweise Zuweisung arbeiten soll. Es wird
aber empfohlen alle benötigten Operatoren selbst zu definieren, auch die Zuweisung. Man beachte dabei, dass die
kombinierten Operatoren, wie etwa “+=”, eigenständige Operatoren sind, die nicht automatisch mit definiert sind,
wenn ihre Komponenten, wie “+” und “=”, definiert sind.
2.4.15 Konkrete Datentypen müssen nicht wertorientiert sein
Unser Beispiel für konkrete Datentypen, die Vektorenklasse, ist rein wertorientiert. Das muss nicht so sein. Ein
konkreter Datentyp muss nicht wertorientiert sein. Die Konzepte sind unabhängig voneinander.
Konkret ist ein “technischer” Begriff: Eine Klasse ist konkreter Datentyp, wenn sie in technischer Hinsicht
vollständig mit allem Notwendigen ausgestattet ist, damit ihre Objekte in allen Situationen korrekt funktionieren.
Wertorientiert und zustandsorientiert sind “philosophische” Begriffe: Eine Klasse ist wertorientiert, wenn
ihre Objekte als reine unveränderliche Werte verwendet werden; sie ist zustandsorientiert, wenn ihre Objekte
veränderlich sind.
Wertorientierte Klassen sollten immer auch konkrete Datentypen sein. Ihre Objekte können nicht verändert werden,
man verwendet sie darum zwangsläufig in Zuweisungen, als Parameter und Funktionsergebnisse, etc. Sie müssen
dort also korrekt funktionieren.
2.4.16 Zustandsorientierte konkrete Datentypen
Ein Stapel dient dazu Werte zwischenzuspeichern. Er ist naturgemäß zustandsorientiert. Sollte er auch ein konkreter Datentyp sein? Nicht unbedingt. Einen Stapel kann man – im Gegensatz zu einem Vektor – sinnvoll verwenden,
ohne ihn von einer Variablen an eine andere zuzuweisen, oder ihn an eine Funktion zu übergeben. Sollte er aber
zugewiesen oder übergeben werden, dann muss die Klasse das auch können: Zuweisungsoperator, Kopierkonstruktor und das was sonst noch gebraucht wird, muss definiert sein und korrekt arbeiten. Der Stapel wird damit zum
konkreten Datentyp:
class Stack {
public:
Stack ();
// Default-Konstruktor
Stack (const Stack &);
// Kopierkonstruktor
Stack & operator= (Stack &);
// Zuweisungsoperator
bool operator== (const Stack &) const; // Vergleich
char top
() const;
// liefert das oberste Element
bool empty () const;
// ist der Stapel leer
bool full () const;
// ist der Stapel voll
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
void pop
();
void push (int);
private:
...
};
...
Stack s1, s2;
int i;
Stack f( Stack );
...
while (!s1.full()) {
cin >> i;
s1.push(i);
}
if ( s1 == s2 ) ...
s2 = s1;
s1 = f(s2);
53
// entfernt das oberste Element
// legt ein Element ab
// normale zustandsorientierte Verwendung
// Vergleich, Aufruf von
operator==
// Zuweisung, Aufruf von
operator=
// Wertuebergabe, Aufruf von Stack (const Stack &)
Zuweisungen und Wertübergabe sind bei Stapeln sicher ungewöhnlich; sie sind aber nicht undenkbar. Als Autor
einer Klasse über deren Verwendung man nicht, oder noch nicht, genau Bescheid weiß, sollte man aber alle möglichen Verwendungen in Betracht ziehen. Alle Verwendungen in Betracht ziehen heißt, sie zum konkreten Datentyp
zu machen.
2.4.17 Zusammenfassung
Unsere Diskussion über abstrakte und konkrete Datentypen ist kurz zusammengefasst:
Abstrakte Datentypen sind selbst implementierte Typen.
C++ bietet das Klassenkonzept. Mit ihm können abstrakte Datentypen gut definiert werden.
Konkrete Datentypen sind vordefinierte Typen sowie Klassen die wie vordefinierte Typen verwendet werden
können.
Klassen können immer so definiert werden, dass sie konkrete Datentypen darstellen, d.h sich wie vordefinierte Typen verhalten.
Um eine Klasse zum konkreten Datentyp zu machen wird geprüft, ob die automatisch erzeugten Methoden
für Zuweisung und Kopie korrekt funktionieren und – wenn nicht – dann werden Kopierkonstruktor und
Zuweisungsoperator selbst definiert.
Bei zustandsorientierten Klassen ist es empfehlenswert sie konkret zu machen, bei wertorientierten Klassen
ist es unabdingbar.
2.5
2.5.1
Beziehungen zwischen Klassendefinitionen
Die Benutzt–Relation
Eine Klassendefinition kommt meist nicht allein. Ernsthafte Programme enthalten in der Regel mehrere davon.
Diesen Definitionen stehen zum großen Teil auch in Beziehung (Relation) zueinander. Die allgemeinste Form
einer Beziehung zwischen Klassen (–Definitionen) ist die Benutzt–Relation:
Eine Klasse benutzt eine andere Klasse
Definition von abhängt.
, wenn die Definition von
in irgendeiner Form von der
Eine Benutzt–Relation besteht beispielsweise dann, wenn ein Parameter einer Methode von
den Typ
hat:
Programmierung II
54
class A { // benutzt B
...
void m (B b); // hier wird B benutzt
...
};
class B { ... }
Die Benutzt–Relation besteht praktisch immer dann, wenn in der Definition von irgendwo ein auftaucht. Sie
sagt nicht mehr, als dass die Definition von sich ändern könnte, wenn die von geändert wird. Man beachte,
dass es um Relationen zwischen Klassen–Definitionen geht! In UML wird die Benutzt–Relation als gestrichelter
Pfeil dargestellt (siehe Abbildung 6).
A
B
void m(B);
Abbildung 6: Benutzt–Relation:
2.5.2
benutzt
Benutzt–Relation mit Kennzeichnung
Eine Benutzt–Relation besteht auch zwischen Freunden. Ist Klasse
ein Freund von Klasse , dann benutzt
Klasse Klasse . In UML können solche speziellen Varianten der Benutzt–Relation durch textuelle Kennzeichnungen verdeutlicht werden (siehe Abbildung 7).
<<friend>>
A
class A { .. benutzt B Komponenten .. };
B
class B { friend class A; ...};
Abbildung 7: Freundschaft als Benutzt–Relation mit Kennzeichnung
Der Pfeil geht hier vom Freund zu dem der die Freundschaft erklärt. Das ist auf den ersten Blick vielleicht etwas
verwirrend, aber konsistent. Wenn die Klasse die Klasse benutzt, dann bedeutet das, dass eine Veränderung
von
Auswirkungen auf
haben kann. Ist
ein Freund von , dann benutzt private Komponenten von
. Verändert man , dann sollte entsprechend angepasst werden. Eine Veränderung von hat dagegen keine
Auswirkungen auf .
2.5.3
Komposition
Die Enthält–Relation – auch Komposition genannt – ist, wie die Freundschaft, eine spezielle Variante der Benutzt–
Relation:
Eine Klasse
enthält eine andere Klasse
, wenn
Datenkomponenten vom Typ
hat.
Diese Variante der Benutzt–Relation ist so wichtig, dass ihr in UML ein spezielles Symbol zugeordnet wurde
(siehe Abbildung 8).
Auch die Komposition ist eine Beziehung zwischen Klassendefinitionen. Wir können aus ihr aber auch eine konkrete Beziehung zwischen Objekten ableiten. Wenn Klasse A Klasse B enthält, dann besteht auch jedes A–Objekt
aus mindestens einem B–Objekt.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
55
A
B
Ganzes
Komponente
class A { ... B b; ... };
Abbildung 8: Komposition
2.6
2.6.1
Beispiel: Geometrische Objekte
Klassendefinitionen
Ein etwas umfangreicheres aber immer noch einfaches Beispiel mehrerer Klassendefinitionen, die zueinander in
Beziehung stehen, sind die geometrischen Objekte Vektor, Punkt, Gerade. Wir beginnen mit der Definition der
Vektoren:
class Vektor {
friend ostream & operator<< (ostream &, const Vektor &);
friend istream & operator>> (istream &, Vektor &);
public:
Vektor ();
// Nullvektor
Vektor (float, float);
Vektor (const Vektor &); // Kopierkonstruktor
Vektor & operator=
Vektor
operator+
Vektor
operatorbool
operator==
(const
(const
(const
(const
Vektor
Vektor
Vektor
Vektor
&);
&) const;
&) const;
&) const;
//
//
//
//
Zuweisung
Vektoraddition
Vektorsubtraktion
Vergleich
float x () const; // x-Koordinate
float y () const; // y-Koordinate
static bool kollinear
(const Vektor &, const Vektor &);
static float determinante (const Vektor &, const Vektor &);
private:
float x_;
float y_;
};
Punkte sind durch ihre Position im Koordinatensystem bestimmt, und diese wird durch einen Ortsvektor festgelegt:
class Punkt {
public:
Punkt (const Vektor &);
Punkt (float, float);
Vektor pos () const;
// Position des Punkts
private:
Vektor ov_;
// ein Punkt ist durch seinen Ortsvektor defininiert
};
Geraden werden in Punkt–Richtungs–Form angegeben. Der Einfachheit halber wird der Punkt durch seinen Ortsvektor angegeben:
class Gerade {
friend ostream & operator<< (ostream &, const Gerade &);
friend istream & operator>> (istream &, Gerade &);
Programmierung II
56
public:
Gerade
Gerade
Gerade
Gerade
();
(const Vektor &, const Vektor &);
(const Punkt &, const Vektor &);
(const Gerade &);
Gerade & operator= (const Gerade
bool
operator== (const Gerade
bool
operator|| (const Gerade
Vektor
operator* (const Gerade
&);
&) const;
&) const;
&) const;
//
//
//
//
Zuweisung
Vergleich
Parallel
Schnittpunkt
Vektor p () const; // Punkt
Vektor a () const; // Richtung
static const Gerade x_Achse;
static const Gerade y_Achse;
private:
Vektor
Vektor
};
p_;
a_;
Gerade
Punkt
Vektor
Abbildung 9: Relationen zwischen den drei Geometrieklassen
2.6.2
Klassendiagramm
Die Beziehungen der Klassen lassen sich durch ein Klassendiagramm darstellen (siehe Abbildung 9). Die freien
Funktionen und Operatoren können nicht dargestellt werden, da das Konzept einer freien Funktion in UML nicht
existiert.
Geraden und Punkte haben jeweils Vektoren als Komponenten. Ein Parameter eines Konstruktors der Geraden ist
vom Typ Punkt. Dies lässt sich nur allgemein als Benutzung darstellen. Wir haben damit drei Klassen und drei
Beziehungen zwischen ihnen:
Punkt enthält Vektor.
Gerade enthält Vektor.
Gerade benutzt Punkt.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
2.7
57
Übungen
Aufgabe 1
1. Welche Konversionen von einem l–Wert in einen r–Wert und/oder umgekehrt finden in folgendem Programm
statt:
int
i = 50;
int & j = i;
int & f (int & p1, int p2) {
p1 = p1 + p2;
p2 = p2 - p1;
return p1;
}
int g (int p1, int p2) {
p1 = p2;
return p1;
}
int main () {
i = j + 1;
j = i + 1;
f (i, j) = f (i+1, j) + g (i+2, j);
}
2. Ist folgendes Programm korrekt? Wenn nein, warum nicht, wenn ja, welche Werte liefert es?
#include <iostream>
int
a = 10;
int & b = a;
int
c = a;
int & f (int &b) {
b++;
return b;
}
using namespace std;
int main () {
int &x = c;
x = a;
f(c) = f(c) + f(x);
cout << f(b)++ << endl;
cout << f(b) + f(b) << endl;
}
3. Was ist alles falsch an folgender Definition eines Zuweisungsoperators:
class X {
public:
...
X & operator= (const X x) {
X res;
res.i = x.i;
return res;
}
private:
int i;
};
Programmierung II
58
4. Ist die Zuweisung links– oder rechts-assoziativ? Ist also a=b=c; das Gleiche wie (a=b)=c; oder das
Gleiche wie a=(b=c);? Sind beide geklammerten Formen erlaubt, haben sie die gleiche oder eine unterschiedliche Wirkung?
5. Warum liefert der Zuweisungsoperator als Ergebnis eine Referenz?
6. Welche Konsequenz hat es, wenn der Zuweisungsoperator als Ergebnis eine konstante Referenz liefert:
class C {
...
const C & operator= (const C &);
...
};
Welches Konstrukt wird damit unmöglich gemacht?
Aufgabe 2
Definieren Sie die Klasse der Brüche als wertorientierten konkreten Datentypen so, dass mit Ihrer Definition Programme wie beispielsweise:
int main () {
Bruch b0,
b1(2),
b2(2,3),
b3 = -Bruch (4,6);
b0 = 4;
//
//
//
//
b0
b1
b2
b3
=
=
=
=
0
2
2/3
-(4/6) (unaeres Minus)
// implizite Konversion int->Bruch
// Gemischte Typen, int und Bruch:
b3 = Bruch(5,7) + (2 + (b1 - b2) + 3) - b0;
// Ein- und Ausgabe:
cin >> b1;
cout << b1;
}
ausgeführt werden können. Vergessen Sie nicht zu prüfen, ob der vom Compiler erzeugte Kopierkonstruktor und
Zuweisungsoperator korrekt sind – wenn nicht dann müssen Sie sie selbst definieren.
Aufgabe 3
1. Definieren Sie einen Stapel als zustandsorientierten Datentyp.
2. Erläutern Sie, warum wertorientierte Klassen als konkrete Datentypen definiert werden sollten und warum
dies bei zustandsorientierten Klassen nicht immer notwendig ist.
3. Erweitern Sie Ihren Stapel zu einem zustandsorientierten konkreten Datentyp.
Aufgabe 4
1. Vervollständigen Sie folgenden konkreten Datentyp Menge durch die fehlenden Definitionen der Methoden:
class Menge {
public:
Menge ();
Menge (int);
// leere Menge
// ein-elementige Menge
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
Menge operator+ (Menge) const;
Menge operator* (Menge) const;
bool
istEnthalten (int i) const;
private:
void fuegeEin (int i);
void entferne (int i);
int m[10];
int a;
};
59
// Vereinigung
// Schnitt
// ist Element
2. Handelt es sich um eine wertorientierte oder um eine zustandsorientierte Klasse?
3. Erweitern/modifizieren Sie die Definition von Menge derart, dass die aktuelle Zahl aller Mengenobjekte
jederzeit festgestellt mit der Methode
static int anzahl();// aktuelle Anzahl aller Objekte
festgestellt werden kann. Müssen in dieser Variante ein Kopierkonstruktor und Zuweisungsoperator definiert
werden?
4. Definieren Sie Ein– und Ausgabe–Funktionen für die Menge als freie Shift–Operatoren.
Aufgabe 5
Zuweisungsoperator und Kopierkonstruktor sind sich immer sehr ähnlich. Sie haben beide die Aufgabe Kopien zu
erzeugen. Trotzdem ist es bei den Vektoren, die ihre Exemplare zählen, notwendig den Kopierkonstruktor selbst zu
definieren. Der vom Compiler erzeugte Zuweisungsoperator ist aber völlig ausreichend und es ist nicht notwendig
einen eigenen Zuweisungsoperator zu definieren. Warum? Erläutern Sie!
Programmierung II
60
3
Verweise und dynamische Objekte
3.1
Zeiger (Pointer)
3.1.1
Verweis: Referenz oder Zeiger
Ein Verweis ist ein Hinweis auf ein Objekt. Verweise gibt es in zwei Varianten: einmal als Referenz (engl. Reference) und einmal als Zeiger (engl. Pointer). Referenzen haben wir bereits im letzten Kapitel kennengelernt.
Zeiger und Referenzen sind ähnlich aber nicht das Gleiche. Beide beziehen sich indirekt auf ein Objekt. Der Unterschied besteht darin, dass Zeiger als Werte (r–Werte) einer Variablen erscheinen und direkt manipuliert werden
können, Referenzen dagegen nicht.
Verweise – Zeiger und Refernzen – werden als Speicheradessen implementiert. Jeder Zeiger und jede Referenz ist
darum letztlich nichts anderes, als die Adresse einer Speicherstelle.
3.1.2
Zeiger sind Verweise
Zeiger sind, wie auch Referenzen, Verweise auf Variablen. Beispiel:
int
int
int
...
x =
p =
p =
x;
y;
* p;
// x ist eine Variable vom Typ "int"
// y ist eine Variable vom Typ "int"
// p ist eine Variable vom Typ "Zeiger auf int"
1;
&x;
&y;
// x hat (enth"alt) den Wert "1"
// p hat (enth"alt) den Wert "Zeiger auf (= Adresse von) x"
// p hat (enth"alt) den Wert "Zeiger auf (= Adresse von) y"
In
int * p,
der Definition der Variablen p, ist der Ausdruck “int *” die Bezeichnung des Zeiger–Typs “Zeiger auf ein int–
Objekt”. Generell bezeichnet für jeden Typ T
T *
den Typ “Zeiger auf eine Variable vom Typ T”. In der Zuweisung
p = &x
ist “&” der Adressoperator, mit ihm wird die Adresse von p bestimmt. Für jede Variable l bezeichnet
&l
deren Adresse (= den Zeiger auf die Variable). Man beachte die unterschiedlichen Bedeutungen von & in Ausdrücken, bei Referenzparametern und bei der Definition einer Referenz:
3.1.3
p = &l;
Bestimme die Adresse von l.
void f (int &l)
l ist ein Referenzparameter
int &l;
l ist vom Typ “Referenz auf int”.
Zeiger sind r–Werte
Referenzen und Zeiger sind Verweise auf Objekte. Beide werden intern mit Hilfe von Speicheradressen realisiert.
Bei den Referenzen agieren die Adressen immer nur “hinter den Kulissen”. Zeiger dagegen sind ganz offen AdressWerte: es sind – anders als Referenzen – r–Werte.
Der Unterschied zwischen Zeigern und Referenzen, also zwischen Adressen als r– und l–Werten, zeigt sich an
folgendem Beispiel:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
int
i;
int
x;
int & r = x;
int & s = i;
// r ist eine Referenz (Typ: "int &") auf x
// Referenzen m"ussen initialisiert werden
int * p;
int * q;
// p ist eine Zeiger-Variable (Typ "int *")
// Zeiger m"ussen nicht initialisiert werden.
r = 2;
r = &x;
// OK: x (== r) hat jetzt den Wert "2"
// FEHLER: &x hat den (falschen) Typ "int *" statt "int"
p = 2;
p = &x;
// FEHLER: 2 hat den (falschen) Typ "int" statt "int *"
// OK: p hat (enth"alt) jetzt den Wert "Adresse von x"
61
if (p == q) {
...
// Wird ausgef"uhrt wenn p und q auf die gleiche Variable
...
// zeigen (p und q beinhalten die gleiche Adresse.)
...
// Der Inhalt der Variablen auf die p und q zeigen
...
// ist nicht relevant.
}
if (r == s) {
...
// Wird ausgef"uhrt wenn der Inhalt von x (= Referenz
...
// von r) und i (= Referenz von s) gleich ist.
...
// Die Adresse der Variablen ist nicht relevant
}
Bei der Zuweisung an eine Referenz r
r = 2;
wird “das auf das r verweist” (nämlich die Variable x) mit dem Wert “2” belegt. Bei der Zuweisung an einen
Zeiger p, wie in
p = &x;
wird p selbst mit dem Wert “Adresse von x” belegt. Die Zuweisung
r = &x;
ist nicht erlaubt. Die Referenz r kann nicht verändert werden. Eine Zuweisung an r wird als Zuweisung an x
interpretiert.
3.1.4
Referenzen und Zeiger unterschiedlichen Typs
Man kann Referenzen auf Variablen von beliebigem Typ setzen, auch auf Zeiger–Variablen:
int
int
int
...
p =
q =
x, y;
* p;
* &q = p;
// p ist eine Zeiger-Variable (Typ "int *")
// q ist eine Referenz auf p (Typ "int * &")
&x;
&y;
// p hat jetzt den Wert "Adresse von x"
// p (== q) hat jetzt den Wert "Adresse von y"
Mit dem Adressoperator kann die Adresse beliebiger Variablen bestimmt werden:
struct S { int a; char b; };
int
char
S
x = 1;
y = ’a’;
s = {1, ’B’};
Programmierung II
62
int *
char *
S *
p = &x;
q = &y;
r = &s;
Hier werden sechs Variablen mit unterschiedlichen Typen und Werten definiert:
x
Typ: int,
Wert: .
y
Typ: char,
Wert: .
s
Typ: S,
Wert:
p
Typ: int *,
Wert: Zeiger auf x.
q
Typ: char *,
Wert: Zeiger auf y.
r
Typ: S *,
Wert: Zeiger auf s.
.
Man beachte, dass sowohl Zeiger als auch Referenzen unterschiedliche Typen haben, wenn sie auf Variablen von
unterschiedlichem Typ verweisen:
char
c;
char * pc;
int
i;
int * pi, pi1;
int ** ppi;
..
pi = &i; // OK
pc = pi; // FEHLER: Typen passen nicht
pi1 = pi; // OK
ppi = π // OK
ppi = pi; // FEHLER: Typen passen nicht
3.1.5
Zeigergrafiken
Oft sind einfache Grafiken hilfreich, bei denen Zeiger als Pfeile dargestellt werden. Der Pfeil führt dabei von der
Zeigervariablen zu dem Objekt auf das ihr Wert zeigt (siehe Abbildung 10).
...
...
4724
4716
4718
4720
A
4722
Adresse von p
4724
...
Speicher
Speicheradressen
Adresse von c
A
p
entprechende Zeigergraphik
c
char *p;
char c = ’A’;
p = &c;
dargestellte
Situation
Abbildung 10: Zeigergrafik: Speicheradressen (Zeiger) als Pfeile
Der Wert einer Zeigervariablen ist eine Speicheradresse. In einer Zeigergrafik wird diese Adresse als Pfeil dargestellt. Der Pfeil führt zur der Speicherstelle mit dieser Adresse. Die durch
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
struct
int
char
S
int *
char *
S
*
S
x
y
s
p
q
r
{
=
=
=
=
=
=
63
int a; char b;};
1;
’a’;
{ 1, ’B’ };
&x;
&y;
&s;
erzeugte Situation kann beispielsweise durch das Zeigerdigaramm in Abbildung 11 in recht übersichtlicher Form
dargestellt werden.
x
1
p
y
a
s
q
1
B
r
Abbildung 11: Beispiel Zeigergrafik
Uninitalisierte Variablen enthalten Zufallswerte. Das gilt auch für Zeigervariablen. Ein Zeigervariable mit Zufallswert kann – zufällig – auf eine definierte Speicherstelle zeigen. In der Regel zeigt sie aber “irgendwo in die
Landschaft”: Ihr Inhalt ist keine zulässige Adresse.
3.1.6
Dereferenzierung von Zeigern
Zeiger zeigen auf Variablen. Zu einer Variablen – generell zu einem l–Wert – kann man jederzeit mit Hilfe des
Adressoperators & den Zeiger bestimmen, der auf sie zeigt. Der Adressoperator macht dabei aus einem l–Wert
(“Variable”) den äquivalenten r–Wert (“Zeiger auf die Variable”).
Adressoperator &
&x
x
&
Dereferenzierungsop. *
p
*p
*
Abbildung 12: Adress– und Dereferenzierungsoperator
Der Dereferenzierungs–Operator * ist die Umkehrfunktion zum Adressoperator. Er führt vom Verweis auf die
Variable, auf die gezeigt wird:
int
i;
int * p;
..
i = 5;
p = &i; // p hat den Wert ‘‘Adresse von i’’
*p = 6; // *p ist die Variable i; jetzt hat i den Wert 6,
// der Wert von p wurde NICHT veraendert.
Der Operator * nimmt einen Zeiger – also einen r–Wert – und macht aus ihm den äquivalenten l–Wert, indem er
“zu der Variablen geht, auf die der Zeiger zeigt”. *p ist ein l–Wert und kann darum links vom Zuweisungszeichen
stehen.
Programmierung II
64
Adressoperator
& : Variable (l–Wert)
Dereferenzierung * : Zeiger (r–Wert)
Zeiger (r–Wert) auf die Variable
Variable (l–Wert) auf die der Zeiger zeigt
Wie andere l–Werte auch darf *p natürlich links und rechts vom Zuweisungszeichen auftreten. Rechts wird es in
einen r–Wert konvertiert: in den Wert der Variablen “auf die gezeigt wird”. Beispiel:
int
i;
int * p;
..
p = &i;
//
*p = 6;
//
i = *p + 1; //
*P = *p + 2, //
p enthaelt einen Zeiger auf i
*p links = Variable i;
jetzt hat i den Wert 6,
*p rechts = Inhalt der Variable i; jetzt hat i den Wert 7,
jetzt hat i den Wert 9
Die ersten drei Zuweisungen haben folgende Wirkung:
In p = &i;
ist &i der r–Wert “Adresse von i”, er wird in p abgespeichert.
In *p = 6;
ist *p der l–Wert “Variable i”.
In i = *p + 1;
ist *p zunächst l–Wert “Variable i”, er wird aber – wir sind auf der rechten Seite – in den r–Wert “Inhalt
der Variable i” konvertiert.
Man beachte den unterschiedlichen Gebrauch des Zeichens *. In
int * p;
ist “int *” der Typ “Verweis auf eine int–Variable”. In
*p = 6;
dagegen ist “*p” der l–Wert “Ergebnis der Anwendung des Dereferenzierungsoperators * auf den Inhalt von p”,
oder kurz: “das auf das p zeigt”. Der Inhalt von p ist dessen r–Wert. Hier im Beispiel der Zeiger “&i”, der auf i
zeigt.
Eine uninitalisierte Zeigervaribale enthält einen Zufallswert, der meist nicht als legale Speicheradresse interpretiert werden kann. Der Versuch eine uninialisierte Zeigervariable zu derefenzieren führt darum meist zu einem
Programmabsturz.
3.2
Definition von Zeigervariablen und –Typen
Die Theorie der Referenzen, Zeiger, l– und r–Werte ist beim ersten Durchgang nicht ganz leicht zu verstehen.
Glücklicherweise ist die Praxis intuitiv recht eingängig. Betrachten wir einige Beispiele:
int * p;
p ist eine Zeigervariable für Verweise auf eine int Variable. Man kann diese Definition so lesen: Wenn p
mit *p dereferenziert wird, dann ist das Ergebnis vom Typ
int.
Mögliche Operationen auf p sind:
int * p; int a = 4; p = &a; *p = 12 + a - *p;
int *p[10];
Hier wird p als ein Feld definiert. Es enthält 10 Zeiger auf int–Variablen.
int *
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
65
ist der Typ der Feldelemente, und mit
p[10]
wird gesagt, dass p 10 Elemente hat.
int *p[10]; ist kein Verweis auf ein Feld mit 10 int–Komponenten, denn die Klammern [] binden
stärker als der Stern *. p ist zuerst ein Feld, dessen Inhalt sind dann Zeiger, die schließlich auf int–
Variablen zeigen.
Man liest stets von der zu definierenden Variablen weg. An p hängen der Stern und die eckige Klammer. Die
eckige Klammer bindet stärker als der Stern, also ist p ein Feld. Wir wissen jetzt, dass p ein Feld ist.
Die nächste Frage ist, welchen Inhalt die Feldkomponenten haben. Es kommt – von p weg – der Stern, er
sagt, dass der Inhalt des Feldes Verweise sind.
Als letztes ist zu klären, worauf die Verweise zeigen. Wir finden, am weitesten von p weg, “int”, es sagt,
dass die Verweise auf int–Werte zeigen.
p in int *p[10]; ist also von p weg gelesen:
1. [] : ein Feld
2. * : von Zeigern,
3. int : die auf int zeigen
Mögliche Operationen auf p sind:
int *p[10]; int a=4; p[2] = &a; *p[2] = 12 + a - *p[2];
int * (p[10]);
Diese Definition ist identisch zu der vorhergehenden.
int (*p)[10];
Mit den runden Klammern wird die Priorität der eckigen Klammern außer Kraft gesetzt. p ist ein Verweis
auf ein Feld mit 10 int–Komponenten. Also zuerst ein Verweis – der Stern ist an p geklammert –, dann ein
Verweis auf ein Feld – eckige Klammern – und schließlich zeigen die Verweise auf int.
p in int (*p)[10]; ist also von p weg gelesen:
1. * : ein Verweis
2. [] : auf ein Feld,
3. int : von int
Mit einer expliziten Typdefinition durch typedef kann das Gleiche meist etwas klarer ausgedrückt werden:
typedef int A[10]; // A ist der Typ ’Feld mit 10 ints’
A * p; // p ist eine Variable mit Verweisen auf A’s
Ein Anwendungsbeispiel ist:
typedef int A[10];
A * p;
int a[10] = {1,2,3,4,5,6,7,8,9,0};
p = &a;
(*p)[2] = 12 - (*p)[2];
int * f();
Hier wird eine Funktion f deklariert. f ist eine parameterlose Funktion die einen Verweis auf ein int liefert.
Genau wie die eckigen Klammern binden auch die runden Klammern stärker als der Stern.
Ein Anwendungsbeispiel ist:
Programmierung II
66
int * f(); // Deklaration
int a = 5;
int main () {
int *p;
p = f();
cout << *p + 2 << endl; // gibt 7 aus
}
int * f () { return &a; } // Definition
int (* f)();
f ist eine Variable, die Verweise enthält. Jeder der Verweise zeigt auf eine Funktion, die einen int–Wert
liefert und kein Argument hat.
Ein Anwendungsbeispiel ist:
int g () { return 5; }
int main () {
int (* f)();
f = &g;
cout << (*f)() << endl; // gibt 5 aus
}
Bei Zeigern auf Funktionen gibt es die Sonderregel, dass der Stern– und der Adressoperator auch weggelassen werden können. Statt wie oben kann man darum auch schreiben:
int g () { return 5; }
int main () {
int (* f)();
f = g;
cout << f() << endl;
}
Zeiger auf Funktionen und Funktionen werden also fast gleich behandlet.
int **p;
p enthält Zeiger, die auf Variablen zeigen, deren Inhalt Zeiger auf int–Variablen sind.
Mögliche Operationen auf p sind:
int **p; int a=1; int *q; p = &q; *p = &a; **p = 12 - *q + **p;
int **p[10];
p enthält 10 Zeiger auf Zeiger, die auf int–Variablen zeigen.
int *(*p[10]);
Identisch zum Vorherigen.
int *(*p)[10];
p enthält einen Zeiger auf ein Feld mit 10 Zeigern auf int–Variablen (siehe Abbildung 13).
int *(*f)(int);
Dies ist die Definition einer Variablen f. f enthält Zeiger auf eine Funktion die einen Zeiger auf eine int–
Variable liefert und ein int Argument hat (siehe Abbildung 14).
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
67
...
p
Abbildung 13: Zeiger auf Feld von Zeigern
int
f
int *
Abbildung 14: Zeiger auf eine Funktion
typedef int * (*PF)(float *); PF (*p)[10];
Dies ist die Definition eines Typs PF und einer Variablen p. p zeigt auf ein Feld mit 10 Elementen, das
Objekte vom Typ PF enthält. Objekte vom Typ PF sind Zeiger auf Funktionen, die Zeiger auf Floats in
Zeiger auf Ints abbilden (siehe Abbildung 15).
Ein Anwendungsbeispiel (mit einem Feld der Größe 2 statt 10) ist:
int i = 10,
j = 20;
// Zwei Funktionen:
int *f (float *x) { i = i + int (*x);
int *g (float *x) { j = j + int (*x);
return &i; }
return &j; }
int main () {
// Typ des Feldinhalts: Zeiger auf Funktionen wie f und g
typedef int * (*PF)(float *);
// Feld a, auf dieses Feld wird p zeigen:
PF a[2] = {&f, &g};
// Variable p:
PF (*p)[2] = &a;
for (int i=0; i<2; ++i) {
float y = i;
cout << *(*p)[i](&y) << endl; // gibt 10 und 21 aus
}
}
Komplexere Definitionen sollte man stets mit typedefs strukturieren. So ist
int * (*)(float *) a[2] = &f, &g;
int * (*)(float *) (*p)[2] = &a;
das Gleiche wie:
typedef int * (*PF)(float *);
PF a[2] = &f, &g ;
Programmierung II
68
float *
float *
float *
...
int *
int *
int *
...
a
p
Abbildung 15: Zeiger auf ein Feld mit Zeigern auf Funktionen
PF (*p)[2] = &a;
Die erste Version ist sicher noch etwas schwieriger zu verstehen als die zweite. (An der ersten scheitern
sogar manche Compiler.) Ebenfalls äquivalent und noch etwas übersichtlicher ist:
typedef int * F(float *); // Typ der Funktionen
typedef F *PF;
// Typ der Zeiger auf Funktionen
typedef PF A[2];
// Typ des Feldes
// Das Feld
A a = &f, &g ;
A *p = &a;
// Der Zeiger auf das Feld
3.3
3.3.1
Verkettete Objekte, der Null–Zeiger
Zeiger auf Verbunde
Ein Zeiger kann auf einen Verbund oder ein Objekt einer Klasse zeigen:
struct S {
int a;
int b;
};
S
s = {2, 3};
S * p = &s;
int i = (*p).a + (*p).b; // entspricht: int i = s.a + s.b;
S
t = *p;
// entspricht hier: S t = s;
int j = *p.a;
// FALSCH: p hat keine Komponente a
Über p kommt man jetzt nicht nur an den gesamten Verbund s sondern auch an seine beiden Komponenten:
*p
=s
(der gesamte Verbund)
(*p).a
= s.a
(die erste Komponente)
(*p).b
= s.b
(die zweite Komponente)
Man beachte die Klammern! In (*p).a und (*p).b wird zuerst p dereferenziert und dann wird die Komponente
selektiert. Der Punkt bindet stärker als der Stern und *p.a ist das Gleiche wie *(p.a). Da p kein Verbund ist,
ist die Zuweisung
*p.a = *(p.a); // FALSCH
nicht erlaubt.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
3.3.2
69
Der Pfeiloperator ->
Ein Ausdruck wie (*p).a ist etwas unhandlich. Es gibt darum eine äquivalente vereinfachte Notation:
p->a
p->a ist exakt das Gleiche wie (*p).a, es schreibt und liest (“p Pfeil a”) sich nur etwas einfacher.
3.3.3
Verkettete Verbunde
Eine Komponente eines Verbunds kann auch ein Verweis sein. Der Verweis kann selbst wieder auf einen Verbund
vom gleichen Typ zeigen. Beispiel:
struct S {
int a;
S * l;
};
...
S s;
s.a = 7;
s.l = &s;
s zeigt hier mit seiner zweiten Komponenten auf sich selbst (siehe Abbildung 16):
a
7
l
s
Abbildung 16: Verbund mit Zeiger auf sich selbst
Man kann s.l natürlich auch auf einen anderen Verbund zeigen lassen und so Ketten oder Ringe erzeugen.
struct S {
int a;
S * l;
};
...
S x, y;
x.a = 7;
y.a = 8;
x.l = &y;
y.l = &x;
x zeigt hier auf y und y auf x (siehe Abbildung 17):
x
7
y
8
Abbildung 17: Ein Zeigerring
Eine Kette von Verbunden kann jetzt von ihrem Anfang her durchlaufen werden:
x.l->a = 2; //== (*x.l).a = 2
Programmierung II
70
x
7
y
2
Abbildung 18: Modifizierter Zeigerring
Mit dem Ergebnis (siehe Abbildung 18):
Wir können auch noch einen einfachen Verweis auf den Anfang der Kette zeigen lassen und von ihm aus die Kette
durchlaufen:
... // S,
S * p
=
p->a
=
p->l->a =
x, y wie oben
&x;
3;
4; //== (p->l)->a
Damit enthalten die Datenkomponeten von x und y die Werte
x
3
und (siehe Abbildung 19):
y
4
p
Abbildung 19: Zeigerring mit Verweis auf den Anfang
3.3.4
Der Null–Zeiger zeigt auf nichts
Gelegentlich ist es nützlich einen Zeiger zu haben, der auf nichts zeigt. Beispielsweise um das Ende einer Kette
anzuzeigen. Der Null–Zeiger – mit dem Wert 0 – zeigt auf nichts.
Mit ihm kann unser Ring zu einer zweielementigen Kette gemacht werden (siehe Abbildung 20):
...
y.l = 0;
x
3
y
4
0
p
Abbildung 20: Zeigerkette
Der Wert vom Typ “Zeiger auf irgendwas” ist etwas anderes als der Wert
Typprüfung von C++ verhindert, dass es hier zu Verwechslungen kommt. 7
vom Typ int oder float. Die
7 In C ist die Typprüfung etwas laxer als in C++, darum hat sich unter C–Programmierern die Sitte eingebürgert statt 0 eine definierte
Konstante NULL zu verwenden. Dies ist bei C++ jedoch nicht notwendig (aber auch nicht schädlich).
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
3.3.5
71
Kettendurchlauf in einer Schleife
Zeigerketten können sehr einfach in einer Schleife durchlaufen werden. Bauen wir zunächst eine Kette aus drei
Verbunden x, y und z auf. Der Zeiger p zeigt auf den Kettenanfang (siehe Abbildung 21):
struct S {
int a;
S * l;
};
S
x, y, z;
S * p = &x;
x.a
x.l
y.a
y.l
z.a
z.l
=
=
=
=
=
=
7;
&y;
8;
&z;
9;
0;
x
7
y
8
z
9
0
p
Abbildung 21: eine dreielementige Zeigerkette
Mit einer einfachen Schleife können jetzt alle Datenfelder in der Kette verzehnfacht werden:
for (S* r = p; r != 0; r = r->l)
r->a = r->a * 10;
r ist die Laufvariable vom Typ “Verweis auf S”. Sie zeigt auf einen Verbund nach dem anderen. r != 0 testet ob
das Ende der Kette erreicht ist und r = r->l versetzt die Laufvariable auf das nächste Kettenelement.
3.3.6
Zeiger auf Komponenten
Bis jetzt haben die Zeiger auf eine Variable insgesamt verwiesen. Bei zusammengesetzten Variablen können sie
aber auch auf einzelne Komponenten zeigen. Ein Feld wie etwa
int a[10];
besteht beispielsweise aus 10 Komponenten vom Typ int. Ein Zeiger kann, wie im folgenden Beispiel, auf eine
einzelne Feldkomponente zeigen:
int a[10];
int *p = &a[1]; // Zeiger auf die zweite Komponente von a
Ähnliches gilt für Verbunde und Klassen:
struct S { int a;
S s;
int
* p = &s.a;
float * q = &s.b;
S
* r = &s;
float b;};
// Zeiger auf erste Komponente
// Zeiger auf zweite Komponente
// Zeiger auf gesamten Verbund
Programmierung II
72
3.4
3.4.1
Dynamisch erzeugte Objekte: Der new– und delete–Operator
Speicherstruktur eines Programms
int i;
inf f (int x) {
int j;
...
}
x
Instanz von f
Funktionsaufruf
j
Stack
int main () {
int k;
...
.. f(k)..
...
}
k
i
Instanz von main
Funktionsende
globale Daten
Abbildung 22: Der statische Speicherbereich
Wir kennen bereits zwei der Speicherbereiche, die jedes C++–Programm für seine Objekte zur Verfügung hat
(siehe Abbildung 22):
Den globalen Speicherbereich:
In ihm werden die globalen Variablen, die statischen lokalen Variablen von Funktionen und Methoden, sowie
die statischen Datenkomponenten von Klassen abgelegt. Hier findet sich alles, was von Programmbeginn bis
zu seinem Ende existiert.
Den Stapel (Stack):
In ihm befinden sich die Instanzen der gerade aktiven Funktionen und Methoden mit ihren lokalen nicht–
statischen Variablen.
3.4.2
Lebensdauer der Objekte
Die Lebensdauer der Objekte ist streng durch die Struktur der Programme bestimmt:
Die statischen und globalen Variablen leben so lange wie das gesamte Programm.
Die lokalen nicht–statischen Variablen leben genauso lange wie die Instanz der Funktion zu der sie gehören.
Mit jeder Instanz werden sie neu erzeugt und mit ihrem Ende vernichtet.
Der Programmierer hat weder die Pflicht noch die Möglichkeit sich um die Erzeugung oder die Vernichtung der
Objekte seines Programms zu kümmern. Beides passiert automatisch. Es kann durch keine Anweisung beeinflusst
werden. Die textuelle Struktur des Programms (d.h. die Programmstatik) legt den Zeitpunkt des Entstehens und
Vergehens fest. Die Variablen werden darum statische Variablen genannt.
3.4.3
Der Heap: Sammlung dynamisch erzeugter Objekte
Um Objekte unabhängig von der Programmstruktur willkürlich zur Laufzeit – d.h. dynamisch – erzeugen und
vernichten zu können, gibt es einen weiteren dritten Speicherbereich für jedes aktive C++–Programm: den sogenannten Heap (“Haufen”). Er heißt so, weil in ihm Objekte nach Belieben und völlig ungeordnet angelegt und
wieder zerstört werden können.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
3.4.4
73
Speicherplatzerzeugung mit new
Objekte im Heap werden mit new erzeugt und delete wieder freigegeben. Beispiel:
int * p;
p = new int;
// p = Zeiger auf einen neuen int-Platz
Mit
p = new int;
wird eine anonyme (namenlose) Variable vom Typ int erzeugt und p ein Verweis auf diese Variable zugewiesen.
p ist also nicht (!) eine neue Int–Variable, sondern an p wird ein Zeiger auf eine neue Int–Variable zugewiesen
(siehe Abbildung 23).
Der Ausdruck new int hat also zwei Funktionen:
Im Heap wird Platz für einen int–Wert reserviert, außerdem
liefert er einen Verweis auf diesen Platz.
Man nennt dies auch Speicheranforderung oder Speicherallokation (engl: Memory Allocation)
T *p
T *p
p = new T;
T
delete p;
T *p
T
Stack
Heap
T *p
Stack
T
Heap
Abbildung 23: Der new– und der delete–Operator
3.4.5
Speicherplatzfreigabe mit delete
Mit
delete p;
wird dieser Platz wieder freigegeben. Achtung: die delete–Anweisung richtet sich nur an die Verwaltung des
Heap. Im Programm selbst wird mit ihr nichts verändert. Insbesondere enthält p immer noch den alten Zeiger.
Über diesen Zeiger kann man weiter auf einen Datenbereich im Heap zugreifen – auch wenn die Heap–Verwaltung
wieder über ihn verfügt und ihn eventuell inzwischen anderweitig vergeben hat.
Es empfieht sich freigegebene Verweise sofort mit 0 zu belegen, um so Zugriffskonflikte zu vermeiden:
int *p = new int;
//.. *p verwenden
delete p;
p = 0;
Natürlich muss delete aufgerufen werden bevor p auf 0 gesetzt wird. delete p; sagt der Speicherverwaltung, dass der Speicherplatz auf den p zeigt freigegeben werden soll. p muss darum auf einen vorher reservierten
Speicherplatz zeigen.
Der Aufruf von delete ist nur dann erforderlich, wenn andernfalls mehr Speicher vom Heap gefordert werden
könnte, als dem Programm insgesamt zugeteilt wurde. Bei einfachen Programmen, die nur wenig dynamischen
Speicher anfordern, kann darum auf delete verzichtet werden. Bei seinem Ende gibt ein Programm immer den
gesamten statischen und dynamischen Speicher frei.
Programmierung II
74
new
5
delete
Heap
new
Aufruf
p
Instanz von f
Aufruf
Funktionsende
q
Funktionsaufruf
Stack
Instanz von main
Funktionsende
Funktionsende
Programmstart
i
globale Daten
Programmende
Abbildung 24: Statischer und dynamischer Speicher
3.4.6
Die Lebensdauer von statischen und dynamischen Variablen
Dynamische Variablen, d.h. Objekte im Heap, existieren unabhängig von der Programmstruktur. Ihre Existenz
wird durch new und delete gesteuert. Die Existenz statischer Variablen dagegen wird durch das Betreten und
Verlassen von Funktionen bestimmt. Beispiel (siehe Abbildung 24):
int * f () {
// Variable p wird erzeugt.
int *p = new int; // im Heap wird eine neue (anonyme) Variable
// angelegt.
*p = 5;
// Die anonyme Variable wird mit 5 belegt.
return p;
// Ein Zeiger auf die anonyme Variable
}
// wird zurueckgegeben. Die
// Variable p wird vernichtet (= freigegeben),
// die anonyme Variable lebt weiter.
int main () {
int *q = f();
// Jetzt wird q erzeugt und mit einem Verweis
...
// auf die anonyme Variable belegt.
delete q;
// Die anonyme Variable im Heap wird vernichtet
q = 0;
// (= freigegeben), q lebt weiter.
...
}
// Variable q wird vernichtet.
3.4.7
Strukturen im Heap
Daten können im Heap genauso abgespeichert werden wie im Stack oder im globalen Speicherbereich. Betrachten
wir ein einfaches Beispiel (siehe Abbildung 25):
struct S {
int a;
S * l;
};
...
S *x, *y;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
75
x = new S;
y = new S;
x->a = 7;
y->a = 8;
x->l = y;
y->l = x;
y
x
statischer Speicherbereich
(Stack)
7
dynamischer Speicherbereich
(Heap)
8
Abbildung 25: Ein Zeigerring im Heap
x und y sind Variablen vom Typ “S *” im statischen Speicherbereich. Daneben existieren noch zwei Variablen
vom Typ “S” im Heap. Sie sind anonym, haben also keinen Namen. Variablen im Heap sind immer anonym. Auf
sie kann darum nur über Zeiger zugegriffen werden.
3.4.8
Beispiel: Verkettete Liste im Heap
Der Heap ist immer dann der richtige Platz zur Speicherung von Daten, wenn nicht im Voraus bekannt ist, wieviel
Platz im Laufe des Programms benötigt wird.
n
r
p
1
2
...
8
0
9
0
Abbildung 26: Verkettete Liste einlesen: vor Einketten des Letzten
Betrachten wir ein Programm das Zahlen einliest und in einer verketten Liste von Verbunden im Heap ablegt:
#include <iostream>
struct S {
int a;
S * l;
};
int main () {
S * r = 0; // Anfang der Liste
S * p = 0; // Letztes Element der Liste
int i;
// zuletzt eingelesene Zahl
Programmierung II
76
n
r
p
1
2
...
8
9
0
Abbildung 27: Verkettete Liste einlesen: nach Einketten des Letzten
// Listenelemente einlesen und in der Liste speichern:
cin >> i;
while (i!=0) {
S * n = new S; // neues letztes Element erzeugen
n->a = i;
// und mit Werten belegen.
n->l = 0;
if (p != 0)
p->l = n;
// das alte zeigt auf das neue letzte
else
r = n;
// das neue ist der Anfang
p = n;
// Das neue ist jetzt das letzte
cin >> i;
}
// Listenelemente ausgeben
for (S* x = r; x != 0; x = x->l)
cout << x->a << endl;
}
Für jede eingegebene Zahl, die nicht gleich Null ist, wird mit
S * n = new S;
ein Verbund vom Typ S im Heap reserviert. In ihm werden die eingegebene Zahl und ein Verweis auf den nächsten
Verbund gespeichert. Zunächst gibt es noch keinen nächsten, darum wird hier der Null–Zeiger gespeichert:
n->a = i;
n->l = 0;
Nach der Eingabe von 1 ... 9 entspricht die Situation jetzt Abb. 26.
Der vorherige letzte ist – falls es ihn gibt – jetzt nicht mehr der letzte, er muss darum auf den neuen letzten zeigen:
p->l = n;
p = n;
Damit ergibt sich Abb. 27.
3.5
3.5.1
Zeiger und Felder
Felder im Heap: new[] und delete[]
Im Heap können ganze Felder von Objekten mit einer einzigen new–Anweisung angelegt und mit einer delete–
Anweisung wieder freigegeben werden:
int * p;
p = new int[10];
//Feld allokieren (Speicher im Heap anfordern)
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
...
delete[] p;
77
//Feld freigeben
Bei new muss dabei die Zahl der Elemente in eckigen Klammern angegeben werden. Bei delete reichen die
eckigen Klammern aus. Die Heapverwaltung des Systems hat sich die Anzahl der Elemente (und ihre Größe)
gemerkt.
3.5.2
Felder sind eigentlich Zeiger und damit r–Werte
Zeiger und Felder sind eng verwandt. Felder “sind” eigentlich nichts anderes als Zeiger. Sie sind damit reine r–
Werte und keine l–Werte. Zuweisungen an ein ganzes Feld sind darum nicht möglich. “a ist ein r– und kein l–Wert”
bedeutet: a ist ein Adresswert. Es ist keine Variable, die eine Adresse hat und eventuell eine, als Zeiger, enthält.
Betrachten wir dazu ein kleines Beispiel (siehe auch Abbildung 28):
Heap
Stack
p
a
Abbildung 28: Feld im Stack und im Heap
int main () {
int * p = new int[10]; //
//
int
a[10];
//
//
p[2] = 7; // OK
a[2] = 3; // OK
10 ints im Heap anlegen und
deren Adresse in p speichern
10 ints im Stack anlegen und
deren Adresse heisst a
*p = 1; // OK
*a = 1; // OK: *a ist das gleiche wie a[0]
// (*a ist l-Wert und somit zuweisbar)
p = a;
a = p;
// OK: implizite Konversion int[10] -> int *
// FEHLER: a ist nicht zuweisbar (a ist r-Wert, kein l-Wert!)
}
int a[10]; legt ein Feld von 10 ints im Stack an. a ist jetzt der Name der Startadresse dieser 10 ints und
damit natürlich gleichzeitig die Adresse des ersten Elementes a[0]. a[0] und *a sind darum äquivalent.
int * p = new int[10]; legt ein Feld von 10 ints im Heap an. Die Startadresse dieser 10 ints wird in
der Variablen p abgelegt.
p ist ein l–Wert und somit zuweisbar. a dagegen enthält nicht die Adresse eines Speicherbereichs, es ist der Name
des Feldes, also ein Synonym für die Adresse an der das Feld beginnt – es ist ein r–Wert.
Die Zuweisung a = p; ist darum nicht erlaubt – an den Namen einer Adresse kann man nichts zuweisen. p =
a; dagegen macht keine Probleme. p ist eine normale Zeigervariable und damit zuweisbar.
Man merkt sich am besten, dass bei der Definition eines statischen Feldes im statischen Speicherbereich entsprechend Platz geschaffen wird und der Feldname dann ein Synonym für dessen Adresse ist. Diese Adresse wird aber
nicht in einer Variablen abgelegt.
Programmierung II
78
3.5.3
Mehrdimensionale Felder
Die Äquivalenz von Zeigern und Feldern gilt auch für mehrdimensionale Felder. Man betrachte dazu folgendes
Beispiel:
char a[2];
char *p = a; // entspricht: p = &a[0];
char b[2][3];
typedef char T[3];
T *q = b;
// entspricht: q = &b[0];
char * r[2];
r[0] = new char [3];
r[1] = new T;
char **s;
s = r;
// entspricht: s = &r[0]
for (int i=0; i<2; ++i)
for (int j=0; j<3; ++j) {
b[i][j] = 1;
r[i][j] = 2;
s[i][j] = 3;
}
b
r
q
s
b[1][1]
q[1][1]
r[1][1]
s[1][1]
Abbildung 29: Die Indexberechung ist ahängig vom Typ
Die Felder a und b werden als Zeiger auf ihr erstes Element interpretiert. Das erste Element von a hat den Typ
char, darum ist die Zuweisung
p = a
korrekt. Das erste Element von b hat den Typ T, dem entsprechend ist auch
q = b;
korrekt. Auf b, q, r und s kann interessanterweise mit den gleichen indizierten Ausdrücken zugegriffen werden. Man sollte sich dadurch aber nicht zu dem Glauben verleiten lassen, dass b[i][j] und q[i][j] sowie
r[i][j] und s[i][j] jeweils in den gleichem Maschinencode übersetzt werden. Der Compiler kennt die Typen von b q, r und s und kann darum die Indexrechnung korrekt umsetzen (siehe auch Abbildung 29). Beim
Zugriff über q bzw. s muss einmal mehr dereferenziert werden, als bei Zugriff über b bzw. r. Der Compiler
erkennt dies daran, dass q und s im Gegensatz zu b und r l–Werte sind.
3.5.4
Felder als Wertparameter: Zeigerübergabe
Felder werden also durch einen Zeiger auf ihr erstes Element repräsentiert. Als solche werden sie auch bei einer
Wertübergabe an Funktionen übergeben. Bei der Wertübergabe eines Felds wird nicht geprüft, ob die Feldgröße
des übergebenen Feldes mit den Erwartungen der Funktion übereinstimmt.
Beispiel:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
79
void f (char x[10]) {
for (int i=0; i<10; i++) x[i] = ’0’;
}
int main () {
char a[3] = {’u’, ’v’, ’w’};
...
f (a); // OK: Die Adresse a wird uebergeben obwohl
...
//
das Feld nur 3 statt 10 Elemente enthaelt.
}
Dieses Programm provoziert keine Fehlermeldung des Compilers, obwohl es in übelster Weise seinen statischen
Speicherbereich überschreibt. Die for–Schleife in f beschreibt gnadenlos zehn Speicherstellen – egal was sich
dort befindet. Man lässt darum bei formalen Wertparametern für Felder die Größenangabe besser weg – sie ist
sowieso irrelevant. Die folgenden Parameterdefinitionen sind völlig äquivalent:
void f (char x[10]);
void f (char x[]);
void f (char *x);
Will man eine Information über die Größe des aktuellen Feld–Parameters in die Funktion hinein schleusen, dann
übergibt man die Feldgröße am besten explizit in einem extra Parameter:
void f (char x[], int size) {
for (int i=0; i<size; i++) x[i] = ’0’;
}
int main () {
char a[3] = {’u’, ’v’, ’w’};
...
f (a, 3);
...
}
3.5.5
Felder als Referenzsparameter: Referenzübergabe
Felder können – statt als Zeiger – auch als Referenzen an eine Funktion übergeben werden:
void f (char (&x)[10]);
Man beachte die Klammern um &x, ohne sie wäre der Parameter x ein Feld von Referenzen statt einer Referenz
auf ein Feld.
Zwischen Zeigern und Referenzen besteht kein wesentlicher Unterschied und in der Tat wird zur Laufzeit auch der
gleiche Wert – die Adresse des Feldanfangs – zur Funktion transferiert. Der Unterschied zeigt sich zur Übersetzungszeit: Bei der Referenzübergabe eines Feldes prüft der Compiler ob die Feldgröße des formalen mit der des
aktuellen Parameters übereinstimmt:
char a[5];
void f1 (char
x [10]);
void f2 (char (&x)[10]);
int main () {
f1 (a); // OK (nach Meinung des Compiler)
f2 (a); // FEHLER-Meldung des Compilers!!
}
Inhaltlich hat das Programm zweimal den gleichen Fehler. Bei der Übergabe eines Feldes als Zeiger wird er vom
Compiler ignoriert, bei der Übergabe als Referenz dagegen nicht.
Wenn Felder von bekannter und fester Größe an eine Funktion übergeben werden sollen, dann übergibt man sie
am besten per Referenz:
Programmierung II
80
.. f (... T (&x)[size], ...);
Sollen der Funktion Felder unterschiedlicher Größe übergeben werden, dann ist die Wertübergabe mit einem
zusätzlichem Parameter für die Zahl der Feldelemente angebracht:
.. f (... T x[], int size, ...);
3.5.6
Stringliterale
Stringliterale sind Zeichenfolgen in Anführungszeichen, z.B. "klausi". Sie werden als Felder (also als Zeiger auf eine Folge) von chars im statischen Speicherbereich des Programms abgespeichert. Hinter dem letzten
Zeichen steht als Abschlussmarke das Zeichen mit dem numerischen Wert 0 (numerische Null, das ist nicht das
Zeichen ’0’!).
Das Literal "hugo emilie" wird beispielsweise als Feld der Länge 12 mit dem Inhalt:
’h’,’u’,’g’,’o’,’ ’,’e’,’m’,’i’,’l’,’i’,’e’,0
gespeichert. Wegen der Endemarkierung mit
Literal.
ist die Feldlänge stets um eins größer als die Zahl der Zeichen im
Die beiden Felder a und b im folgenden Beispiel werden darum auf den exakt gleichen Wert initialisiert:
// a und b werden den gleichen Wert initialisiert:
char a[4] = {’A’, ’B’, ’C’, 0};
char b[4] = "ABC";
3.5.7
C–String: Zeiger auf 0–terminiertes char–Feld
Zeiger auf char–Felder deren letzter Wert der int(!) Wert ist, sind in C die Standard–Darstellung von Zeichenstrings, sie werden darum i.A. C–Strings genannt. C–Strings kann man leicht in “richtige” (C++–) Strings
umwandeln:
#include <string>
int main () {
char c[4] = "ABC";
string s1 ("abc");
string s2 (c);
string s3 = c;
s1 = "xyz";
s1 = string(c);
s1 = c;
}
//
//
//
//
//
//
//
Feldinitialisierung mit Stringliteral
String-Konstruktor mit Stringliteral
String-Konstruktor mit char-Feld
String-Initialisierung mit char-Feld (C-String)
Zuweisung eines Sringliterals, implizite Konversion
explizite Konversion C-String -> String
Zuweisung eines char-Felds, implizite Konversion
c ist hier ein C–String, s1, s2 und s3 sind (C++–) Strings. Das Programmbeispiel zeigt die verschiedenen
Möglichkeiten einen String mit den Zeichen eines C–Strings zu belegen.
Für den umgekehrten Weg von einem (C++–) String zu einem C–String (= Null-terminiertes Char–Feld) gibt es
die Methode c str von string. Beispiel:
#include <string>
int main () {
string s ("abc");
const char *c;
c = s.c_str();
}
s.c str() liefert einen Zeiger auf ein Char–Feld, das die Zeichen von s gefolgt von einer “0” enthält. Das
Schlüsselwort const zeigt an, dass auf den Speicherbereich, auf den c zeigt und der von s.c str() beschafft
wird, nicht verändernd zugegriffen werden darf.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
81
Will man die Zeichen in einen Speicherbereich kopieren, der unter der Kontrolle des Programms steht, dann muss
explizit umkopiert werden:
#include <string>
int main () {
string
s ("abc");
const char *c = s.c_str(); // c zeigt auf fremden Speicherplatz
char
*p = new char[s.length()+1];
// p zeigt auf eigenen Speicherplatz
//Kopieren von *c nach *p:
for (char *q = p; *c != 0; ++c, ++q)
*q = *c;
...
}
3.5.8
Vordefinierte Funktionen auf C–Strings
Zur Vereinfachung der Arbeit mit C–Strings sind folgende Funktionen (aus cstring) vordefiniert:
int strlen (const char *): liefert die Länge
int strcmp (const char *, const char *): testet auf Gleichheit
char * strcpy (char *, const char *): kopiert.
Damit vereinfacht sich das letzte Beispiel zu:
#include <string>
#include <cstring>
int main () {
string
s = "abc";
const char *c = s.c_str();
char
*p = new char[s.length()+1];
//Kopieren: *c -> *p:
strcpy (p, c);
...
}
Bei der Verarbeitung von Texten wird generell empfohlen mit C++–Strings statt mit C–Strings zu arbeiten.
3.5.9
Mehrdimensionale Felder im Heap
Im Heap können auch mehrdimensionale Felder angelegt werden. Eine Konstruktion wie etwa
int a[][5] = new int[3][5]; // FALSCH
ist allerdings nicht erlaubt. Auf new folgt ein Typ und dann eine Größenangabe. Wir müssen darum zuerst den Typ
der Zeile mit einem typedef definieren und können dann drei Zeilen anlegen:
typedef int A[5]; // Zeilenlaenge muss dem Compiler bekannt sein
// Zeilenzahl kann dynamisch sein (z.B. eingelesen werden)
int main () {
A *a = new A[3]; // Ein 3x5 Feld im Heap
for (int i=0; i<3; ++i)
for (int j=0; j<5; ++j)
a[i][j] = 10*i+j;
}
Programmierung II
82
Man beachte, dass die Länge der Zeilen zur Übersetzungszeit bekannt sein muss. Der Compiler kann sonst einen
Ausdruck wie
a[i][j]
nicht korrekt übersetzen. Sollen beide Dimensionen eines zweidimensionalen Feldes eingelesen werden, dann
muss die Indexrechnung selbst organisiert werden. Beispiel:
int main () {
int n, m;
cin >> n;
cin >> m;
int *a = new int[n*m];
// Ein NxM Feld im Heap
for (int i=0; i<n; ++i)
for (int j=0; j<m; ++j)
a[i*m + j] = 10*i+j; // eigene Adressrechnung
}
3.6
3.6.1
Zeiger, Konstruktoren und Destruktoren
new ruft einen Konstruktor
Jeder Aufruf von new für eine Klasse aktiviert den Default–Konstruktor für das neu erzeugte Objekt. Durch Angabe einer Parameterliste kann man auch den Aufruf eines anderen Konstruktors veranlassen. Betrachten wir als
Beispiel Namen, die als verkettete Liste im Heap gespeichert werden:
struct NamenListe {
NamenListe
();
NamenListe
(string, NamenListe *);
˜NamenListe ();
string
name;
NamenListe * vorheriger;
};
NamenListe::NamenListe ()
: name (""), vorheriger (0) {}
NamenListe::NamenListe (string p_n, NamenListe *p_l)
: name (p_n), vorheriger (p_l) {}
Der Default–Konstruktor erzeugt in diesem Beispiel eine einelementige Liste mit dem leeren String als einzigem
Namen. Der zweite Konstruktor setzt vor die übergebene Liste (2. Parameter) einen Eintrag mit dem übergebenen
Namen (1. Parameter). Beide Konstruktoren können durch new aktiviert werden (siehe auch Abbildung 30):
NamenListe * p1 = new NamenListe;
// Aufruf Default--Konstruktor
NamenListe * p2 = new NamenListe ("Hugo", p1); // Aufruf 2-ter Konstruktor
name
"Hugo"
vorheriger
p2
name
""
vorheriger
0
p1
Abbildung 30: Mit Konstuktoren erzeugte Zeigerkette
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
3.6.2
83
delete ruft den Destruktor
Objekte der Klasse NamenListe liegen nicht nur selbst im Heap, sie haben auch eine Komponente die im Heap
liegt: Das worauf vorheriger zeigt. Wird mit delete ein Objekt der Klasse NamenListe freigegeben, dann
sollte vorher auch das worauf es zeigt freigegeben werden, und davor dessen Vorgänger und davor ... bis zum letzten
in der Liste der Vorgänger, der als erstes freizugeben ist. Andernfalls bleiben nicht mehr zugreifbare “Leichen” im
Heap liegen: Objekte auf die kein Zeiger mehr zeigt, die aber nicht freigegeben wurden.
Glücklicherweise kann die Freigabe leicht arrangiert werden, denn delete ruft automatisch den Destruktor auf
– soweit vorhanden. Im Destruktor gibt man einfach mit delete den Vorgänger frei:
struct NamenListe {
...
˜NamenListe ();
...
NamenListe * vorheriger;
};
NamenListe::˜NamenListe () {
delete vorheriger;
}
Der Mechanismus arbeitet rekursiv (siehe Abbildung 31): Ein delete auf den Vorgänger aktiviert dessen Destruktor, der wiederum ruft delete für den Vor–Vorgänger auf, etc. bis zu einem delete auf den Null–Zeiger
des letzten Listenelements das ohne Wirkung ist.
delete
Destruktor
delete
Destruktor
...
delete 0
Generell sollten alle Klassen mit Komponenten im Heap einen Destruktor definieren, in dem diese Komponenten
freigegeben werden.
new
struct NamenListe {
...
NamenListe * vorheriger;
};
delete in: ~NamenListe()
new
klara
0
hugo
delete p2
int main () {
NamenListe *p1, p2;
...
aktiviert ~NamenListe()
HEAP
STACK
NamenListe nl("emil", 0);
p1 = new NamenListe ("klara", 0);
p2 = new NamenListe ("hugo", p1);
...
Aufruf
emil
0
nl
delete p2; //Aufruf p2->~NamenListe()
p1
}// Aufruf: nl.~NamenListe()
p2
Funktionsende
Abbildung 31: Kooperation von Destrukor und delete
3.6.3
Destruktor für statische und dynamische Objekte
Der Destruktor eines Objekts o der Klasse C wird insgesamt also nach folgenden Regeln aktiviert:
main
Programmierung II
84
o ist eine globale Variable:
Der Destruktor wird bei Programmende aktiviert.
o ist eine lokale Variable oder ein Parameter:
Der Destruktor wird aktiviert, wenn die Funktion oder der Block verlassen wird, in dem o definiert ist.
o ist eine anonyme Variable im Heap:
Der Destruktor von *p wird aufgerufen wenn p auf o zeigt und delete p; ausgeführt wird.
Zeiger sind keine Variablen, darum werden beim Verlassen einer Funktion zwar alle lokalen Zeigervariablen weggeräumt, es wird aber keinfalls ein Destruktor ausgeführt für eines der Objekte auf die sie zeigen. Beispiel:
struct S { S(); ˜S(); ... };
int f (...) {
S * p;
S s;
p = new S;
...
} // Aufruf von s.˜S();
// aber KEIN Aufruf von p->˜S()
Fassen wir noch einmal kurz die wesentlichen Dinge in Bezug auf die Zeiger, Konstruktoren und Destruktoren
zusammen:
Der Konstruktor konstruiert nicht, er initalisiert nach der Erzeugung.
Der Destruktor vernichtet nicht, er macht Aufräumarbeiten vor der Vernichtung.
new erzeugt und ruft danach den Konstruktor.
delete vernichtet und ruft vorher den Destruktor.
3.6.4
Beispiel: Verkette Liste
An dem einfachen Beispiel einer verketteten Liste zeigen wir das Zusammenspiel von Konstruktoren, Destruktoren
und Zeigern.
Eine Liste besteht aus einem Objekt der Klasse Liste, die auf eine Folge von Objekten der Klasse Knoten
verweist. In den Knoten sind die Listenelemente in einer Komponente v gespeichert und eine Komponente n zeigt
auf den nächsten Knoten.
class Knoten {
public:
Knoten ()
: v(0), n(0)
{}
Knoten (int i, Knoten * p_n) : v(i), n(p_n) {}
˜Knoten () { delete n; }
int v;
Knoten * n;
};
Die beiden Konstruktoren erzeugen Knoten, in denen 0 bzw. die übergebenen Argumente gespeichert werden. Der
Destruktor gibt seinen Nachfolger frei (und dieser rekursiv seinen etc.).
Die Liste selbst besteht aus einem Verweis auf den Anfang der Knoten und den Listen–Methoden:
class Liste {
public:
Liste () : anfang (0) {}
˜Liste () { delete anfang; }
// leere Liste
// der Listenanfang wird freigegeben,
// und damit rekrusiv die gesamte Liste
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
void schreib () const;
void fuegEin (int i);
// alle Listenwerte ausgeben
// einen Wert in die Liste einfuegen
Knoten *anfang;
// Verweis auf die verkettete Liste der Werte
85
};
void Liste::schreib () const {
for (Knoten *p = anfang; p != 0; p = p->n)
cout << p->v << endl;
}
void Liste::fuegEin (int i) {
anfang = new Knoten (i, anfang); // neuer Wert vor die alten
}
Wird in einer Funktion – ob sie nun main oder anders heißt – eine lokale Variable vom Typ Liste angelegt, dann
aktiviert der Funktionsanfang den Konstruktor und das Funktionsende den Destruktor der Liste:
int main () {
Liste l;
// Compiler aktiviert Konstruktor Liste::Liste() :
// sorgt fuer Speicherplatz und die richtige Initialisierung
...
...
// Compiler aktiviert Destruktor Liste::˜Liste() :
// sorgt dafuer dass alle Listen- (Leichen-) Teile
// im Heap beseitigt werden.
}
Mit dem Funktionsende verschwinden automatisch alle Bestandteile der Liste, die auf dem Stack angelegt wurden.
Für die Beseitigung der Bestandteile, die im Heap angelegt wurden, muss der Destruktor sich aktiv kümmern.
Kümmern muss sich genau genommen immer der Destruktor der Klasse, die Unter–Komponenten im Heap hat:
Objekte der Klasse Liste enthalten – in anfang – einen Zeiger auf einen Knoten der garantiert im Heap
liegt – so wurde die Liste eben programmiert. Liste muss darum einen Destruktor haben, der diesen Knoten
wegräumt.
Objekte der Klasse Knoten haben – in n – einen Zeiger auf ein Objekt im Heap. Knoten muss darum
einen Destruktor enthalten, der dieses Objekt wegräumt.
Der Destruktor eines Objekts ist also nicht für die Beseitigung des Objekts selbst verantwortlich, sondern immer
für die Objekte, auf die seine Zeiger zeigen. Das Objekt selbst wird vom Mechanismus der Stackbereinigung oder
vom Destruktor eines anderen Objekts weggekehrt. (siehe Abbildung 32)
3.7
3.7.1
Zeiger und const
Konstanter Zeiger oder konstantes Gezeigtes
Eine konstante Variable wie
const int bufSize = 256;
wird vom Compiler daraufhin überwacht, dass ihr Wert nicht verändert wird. Kommen Zeiger ins Spiel, wie etwa
bei
char * pc;
dann gibt es zwei Variablen die konstant sein können:
Die Zeigervariable pc selbst, und
Programmierung II
86
v 1
Liste::~ Liste()
delete
n
v 2
Knoten::~ Knoten
delete
Der Destruktor von l
beseitigt dieses Objekt (mit delete)
Die Beseitigung ruft den Destruktor,
der Destruktor beseitigt das nächste
n
v 3
n 0
Knoten::~ Knoten
delete
Heap
Stack
Liste l
anfang
Stackbereinigung
Die Stackbereinignug beseitigt:
sie ruft den Destruktor dieses Objekts
und räumt es dann weg
Abbildung 32: Kooperation von Destrukor und delete
das auf das pc zeigt.
In C++ gibt es – wenig überraschend – die Möglichkeit beides als konstant zu erklären und damit vom Compiler
überwachen zu lassen (siehe Abbildung 33):
Konstanter Zeiger:
char * const pc;
Konstantes Gezeigtes:
const char * pc;
konstanter Zeiger
char * const pc
char c
const char *pc
const char c
konstantes Gezeigtes
Abbildung 33: Konstanter Zeiger und konstantes Gezeigtes
3.7.2
Konstantes Gezeigtes
Mit
const char * pc;
wird ausgedrückt, dass das “Gezeigte”, also das auf das pc zeigt konstant ist. pc selbst kann unterschiedliche
Werte annehmen, aber auf was immer es zeigt, darf nicht (über pc) verändert werden. Beispiel:
const char * pc = 0;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
char
87
a[5];
pc
= a; // OK obwohl pc jetzt auf nicht-konstantes zeigt
*pc = 3; // FEHLER: "uber pc darf nichts ver"andert werden
a[0] = 3; // OK (obwohl a[0] == *pc)
Die Zuweisung an *pc ist hier ein Fehler, obwohl *pc und a[0] exakt die gleiche Speicherstelle bezeichnen.
const char *pc; sagt also nichts aus über das, auf das gezeigt wird!. Es bedeutet schlicht, dass über den
Zeiger in pc nichts verändert werden darf – völlig unabhängig davon, auf was er tatsächlich zeigt! Der Compiler
hat dabei den Auftrag dies zu überwachen – er passt einfach auf, dass an *pc nicht zugewiesen wird.
Die Definition “const char *pc” interpertiert man also eher als: “Über den Zeiger in pc wird nichts
verändert”, statt als “pc zeigt auf etwas Konstantes”.
3.7.3
Zeiger auf Konstanten
Ein Zeiger vom Typ const char * kann also durchaus auf etwas Veränderliches zeigen. Es kann eben nur
nicht über pc verändert werden. Natürlich darf pc auch auf etwas zeigen, das selbst – sozusagen aus sich heraus
– konstant ist:
const char a = ’X’;// a ist konstant
const char *pc = &a; // konstantes Gezeigte, pc zeigt
// tats"achlich auf eine Konstante
Das ist gewissermaßen der Idealfall. a hat den Typ “const char” und pc ist ein Zeiger auf “const char”.
Lässt man const bei pc weg, zeigt der Compiler einen Fehler oder eine Warnung an:
const char
char *pc;
pc = &a;
*pc = ’Y’;
a
= ’X’;
// FEHLER: die Konstantheit von a geht ueber p verloren
// OK ! (const-Eigenschaft wurde ja eben weggeworfen)
Die Zuweisung an p ist fehlerhaft, denn sie überträgt die “Konstantheit” von a nicht auf p: Nach dieser Zuweisung
kann das konstante a über *p verändert werden. Der Compiler kommentiert dies mit einer Meldung wie:
assignment to ‘char *’ from ‘const char *’ discards const.
An der nächsten Zuweisung findet er dagegen nichts auszusetzen! Der Compiler macht nicht einmal in diesem
offensichtlichen Fall den Versuch herauszufinden, auf was pc tatsächlich zeigt, um mit diesem Wissen Fehler
aufzudecken.
Der Ausdruck
const char * pc;
sagt, dass *pc konstant ist. Man liest in der üblichen Art “von pc weg nach links”:
pc ist ein Zeiger,
ein Zeiger auf ein char,
ein char das const ist.
Insgesamt ist es also ein variabler Zeiger auf einen konstanten Wert.
3.7.4
Konstante Zeigerwerte
Will man zum Ausdruck bringen, dass der Zeigerwert selbst nicht veränderlich ist, definiert man:
Programmierung II
88
char * const pc;
Wieder ist “von pc weg nach links” zu lesen:
pc ist const,
es ist ein konstanter Verweis,
ein konstanter Verweis auf ein (nicht konstantes) char
Insgesamt ist es also ein konstanter Zeiger auf einen variablen Wert.
Beispiel:
char
b = ’Y’;
char * const pc = &b; // Konstanten muss man initialisieren !
*pc = ’X’;
pc = &b;
// OK:
*pc ist nicht konstant
// FEHLER: pc ist konstant
Bedauerlicherweise ist es auch erlaubt const zwischen char und * zu platzieren:
char const * pc;
Diese Definition ist aber äquivalent zu
const char * pc;
Am besten merkt man sich zur Festellung, was denn konstant ist folgendes Verfahren: Geht man vom Namen
der Variablen immer weiter weg, trifft man irgendwann auf ein const, das was man direkt vorher passierte ist
konstant.
const char *pc;
char - das worauf pc zeigt - ist konstant
char * const pc;
pc selbst ist konstant
char const * pc;
*pc - das worauf pc zeigt - ist konstant
Abbildung 34: Konstantheit
Die beiden Arten der Konstantheit können selbstverständlich kombiniert werden:
const char
b = ’Y’;
const char * const pc = &b; // konstanter Zeiger auf konstante Variable
*pc = ’X’;
pc = &b;
3.7.5
// FEHLER: *pc ist konstant
// FEHLER: pc ist konstant
Parameter und const
Eine häufige Verwendung findet const in Parameterlisten. Hier bedeutet es, wie bereits in Kapitel 1 erläutert,
dass die Funktion garantiert, dass sie den Parameterwert nicht verändern wird:
void f (const char * pc) ...
f verspricht hier, dass es das, auf das sein Parameter zeigt, nicht zu verändern. Der Parameter ist nicht konstant,
sondern das worauf er zeigt.
Konstante Wertparameter selbst sind sinnlos, Wertparameter werden niemals verändert:
void f (char * const pc) ... // UNSINN !
Konstante Referenzparameter können dagegen sinnvoll sein (siehe Abbildung 35):
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
89
Referenzübergabe eines Zeigers auf char. Der Zeiger wird nicht verändert:
void f (char * const &pc) ...
Referenzübergabe eines Zeigers auf char. Das worauf der Zeiger zeigt ist konstant:
void f (const char * &pc) ...
char * const &pc
const char * &pc
Referenz
Referenz
Zeiger
char * const
Zeiger
char
const char *
const char
Abbildung 35: Referenzübergabe und const
Beispiel:
char
b
= ’Y’;
void f (char * const &pc) { // konstanter Referenzparameter
// f wird pc nicht "andern
*pc = ’X’; // OK
pc = &b;
// FEHLER: pc ist konstant
}
void g (const char * &pc) { // Konstantes Gezeigtes
// g wird nichts "uber pc "andern
*pc = ’X’; // FEHLER: *pc ist konstant
pc = &b;
// OK
}
Die Übergabe – ob per Wert oder per Referenz –, bei der das Gezeigte konstant ist:
...(const char *p)...
ist insbesondere in Zusammenhang mit C–Strings wichtig, da diese sehr oft als const char * dargestellt werden. Beispiel:
void f (const char *p) {...}
void g (char *p)
{...}
int main () {
string s ("AB");
f(s.c_str());
g(s.c_str());
// OK
// Fehler/Warnung: c_str() liefert "const char *"
f("AB");
g("AB");
// OK
// NICHT OK: VERMEIDEN !
const char * p1 = "Hugo";
char *
p1 = "Charlotte";
// OK
// NICHT OK: VERMEIDEN !
}
Stringliterale stellen Zeiger dar, die in einen Speicherbereich zeigen, der mit großer Wahrscheinlichkeit nicht beschreibbar ist. Dieser Speicherschutz wird zur Laufzeit vom Betriebssystem überwacht. Ein Versuch, den Inhalt
eines geschützten Speicherbereichs zu verändern, führt zum Programmabsturz. Stringliterale sollten darum immer
Programmierung II
90
nur an konstante Variablen zugewiesen und an Funktionen mit konstantem Parameter übergeben werden. Damit
kann der Compiler zur Übersetzungszeit ausschießen, dass das Betriebssystem zur Laufzeit einen Grund zum Programmabbruch finden kann.
void f (const char *p) {
p[0] = 0; // FEHLER bei der Uebersetzung
}
void g (char *p) {
p[0] = 0; // OK (fuer den Compiler)
}
int main () {
f("AB");
// OK
g("AB");
// PROGRAMMABSTURZ: Speicherzugriffsfehler
}
3.7.6
Zeiger in UML: Aggregation
Wenn Objekte einer Klasse A Objekte der Klasse B als Bestandteil haben, dann besteht zwischen A und B eine
Kompositionsrelation. Eine Komposition ist nach UML als fester Bestandteil zu verstehen. Die Relation zwischen
etwas lockerer zusammengesetzten Dingen wird als Aggregation bezeichnet. An dieser Stelle wollen wir nicht auf
die philosophische Tiefe des Unterschieds zwischen einer “lockeren” einer “festen” Zusammensetzung nachdenken.Es hat sich jedenfalls eingebürgert die lockere Variante der Aggregationen zur Darstellung einer Verzeigerung
zu verwenden.
A
B
class A { ... B *pb; .. };
Abbildung 36: Aggregation:
zeigt auf
Enthalten Objekte der Klasse A Zeiger auf Objekte der Klasse B, dann besteht zwischen A und B eine Aggregation–
Beziehung (siehe Abbildung 36).
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
3.8
91
Übungen
Aufgabe 1
Geben Sie zu den folgenden graphisch dargestellten Situationen geeignete Definitionen und Anweisungen an, die
zu dieser Situation führen.
1.
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
v
V V
|
|
|
+---+ +---+
+-|-+
+-|-+
+-|-+
| 0 | | 1 |
| + |
| + |
| + |
+---+ +---+
+---+
+---+
+---+
x
y
p
q
t
2.
+---------------+
|
+--------|------------------+
v
V
|
q
|
+---+ +---+
+-|-+
+---+
+-|-+
| 0 | | 1 |
| + |<-+ | + |
| + |
+---+ +---+
+---+ | +-|-+
+---+
x
y
p
+----+
t
3.
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +---+
x
p
y
q
z
r
t
o
Welche Wirkung haben – falls sie korrekt sind – jeweils in jeder der drei Situationen die Anweisungen:
1. t = x;
2. *t = x;
3. t = *x;
4. *t = *x;
5. *p = *q;
6. p = &x;
7. q = &t;
Zeichen Sie zu jeder Situation und jeder dort zulässigen Anweisung ein Diagramm zur Darstellung der neuen
Situation nach Ausführung der Anweisung.
Aufgabe 2
1. Zeichen Sie ein Diagramm zur Darstellung der durch folgendes Programm erzeugten Situation:
const int Size = 5;
struct S {
S ();
S (int, S *);
Programmierung II
92
int v;
S
*n;
};
S::S () : v(0), n (0) {};
S::S (int pv, S *pn) : v(pv), n (pn) {};
int main () {
S a[Size];
S b(-1, &a[0]);
for (int i=0; i<Size; i++) {
a[i].v = i;
a[i].n = &a[(i+1)%(Size -1)];
}
(*((*(b.n)).n)).v = 125;
...
2. Ersetzen Sie in obigem Programm alle geigneten Komponentenselektionen und Dereferenzierungen (also
Kombinationen von “*” und “.”) durch den Operator ->.
Aufgabe 3
1. i habe den Typ int, und p den Typ int *. Welcher der folgenden Ausdrücke ist korrekt bzw. nicht
korrekt. Geben Sie zu den korrekten Ausdrücken den Typ an.
(a) i + 1
(b) *P
(c) &p
(d) **(&p)
(e) &i == p
(f) i == *p
(g) *p + i > i
2. Definieren Sie folgende Variablen und Typen:
(a) Ein Feld von Zeigern auf float–Variablen.
(b) Eine Funktion die einen Zeiger auf eine float–Variable als Argument hat und einen Zeiger auf eine
int–Variable liefert.
(c) Ein Feld von Zeigern auf Funktionen die einen Zeiger auf float–Variablen als Argument und Ergebnis haben.
(d) Ein Feld von 10 Verbunden (structs) die einen Zeiger auf int und eine Methode als Komponenten
haben. Die Methode soll Zeiger auf float in Zeiger auf int abbilden.
(e) Eine Variable, die einen Zeiger auf ein float–Objekt enthält.
(f) Eine Variable, die einen Zeiger auf eine Variable enthält, deren Inhalt auf ein float–Objekt zeigt.
(g) Den Typ der Zeiger auf float–Objekte.
(h) Den Typ S wobei jedes Objekt vom Typ S aus einem int und einem Zeiger auf ein bool, sowie
einem Zeiger auf ein S Objekt besteht.
(i) Eine Variable die aus 10 S Objekten besteht.
(j) Eine Variable die aus 10 Zeigern auf S Objekte besteht.
(k) Den Typ den ein Feld von Zeigern auf float–Variablen hat.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
93
(l) Den Typ den eine Funktion hat, die einen Zeiger auf eine float–Variable als Argument hat und einen
Zeiger auf eine int–Variable liefert.
3. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die p[i] mit der Adresse
von a[i] belegt (i = 0..9).
4. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die a[i] mit dem Wert
dessen belegt, auf das p[i] zeigt (i = 0..9).
5. Wird der Adressoperator & auf l–Werte oder r–Werte angewendet, hat er einen l–Wert oder einen r–Wert als
Ergebnis?
6. Wird der Dereferenzierungsoperator * auf l–Werte oder r–Werte angewendet, hat er einen l–Wert oder einen
r–Wert als Ergebnis?
7. Welche der Zuweisungen in
int & f (int &i) {
return i;
}
int main () {
int x;
int *p;
p = &i;
p = &(*p);
p = &f(x);
p = &f(&(*p));
}
sind erlaubt und wenn ja welche Wirkung haben sie?
Aufgabe 4
1. Es sei definiert:
struct S {
int x;
};
S
s;
S * p = &s;
Welcher Ausdruck ist korrekt und welchen Typ haben die korrekten Ausdrücke:
*p
*p.x
(*p).x
*(p.x)
p->x
x->p
p->.x
*p->x
Geben Sie zu jedem korrekten Ausdruck ein Anwendungsbeispiel an.
2. Es sei definiert:
Programmierung II
94
struct S {
int a;
S * l;
};
S
x, y, z;
S * p = &x;
int i = 0;
Stellen Sie graphisch die Situation dar, die durch folgende Anweisungsfolge erzeugt wird:
x.l = &y;
y.l = &z;
z.l = 0;
for (S* r = p; r != 0; r = r->l){
r->a = i;
i++;
}
3. Schreiben Sie eine Schleife, die in der erzeugten Struktur in jedem Verbund das Datenfeld um eins erhöht
und dann ausgibt.
Aufgabe 5
1. Warum ist:
int * get_int1 () {
int * p;
p = new int;
return p;
}
eine sinnvolle Methode einen Verweis auf neue int Variable zu erzeugen, dagegen
int * get_int2 () {
int i;
int p = &i;
return p;
}
völlig ungeeignet?
2. Es seien die Definitionen / Anweisungen:
int * p;
p = new int;
*p = 17;
ausgeführt worden. Welchen Wert haben p und *p nach Ausführung von
delete p;
3. Warum ist die Aufruffolge:
p = 0;
delete p;
unsinnig dagegen aber
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
95
delete p;
p = 0;
sinnvoll?
4. Kann ein Objekt (Variable) im Heap einen Namen tragen?
5. Zeichen Sie eine Graphik der von folgendem Programmstück erzeugten Struktur.
struct S {
int a;
S * l;
};
int main () {
S *x, *y;
x = new S;
y = new S;
x->a = 0;
y->a = 1;
x->l = y;
y->l = 0;
...
}
Geben Sie zu jedem Objekt in Ihrer Graphik an, in welchem Speicherbereich es angelegt wird.
6. Zeichen Sie eine Graphik der von folgendem Programmstück erzeugten Struktur.
struct S {
int a;
S * l;
};
int main () {
S * r = 0;
S * l = 0;
for (int i = 0; i< 5; i++) {
S * n = new S;
if (i == 0) {
n->a = 1;
r = n;
} else {
n->a = l->a * i;
l->l = n;
}
l = n;
n->l = 0;
}
...
7. Ergänzen Sie das obige Programmstück um eine Ausgabeschleife, welche die Datenfelder der verketteten
Verbunde in der Reihenfolge ihrer Verkettung ausgibt.
8. Schreiben Sie ein Programm in dem eine Zahlenfolge (z.B. bis zur Eingabe von 0) eingelesen und in umgekehrter Reihenfolge in verketteten Verbunden im Heap abgelegt wird. Die zuletzt eingelesene Zahl soll also
am Anfang der Liste stehen.
Programmierung II
96
Aufgabe 6
Geben Sie Typdefinitionen, Variablendefinitionen und Anweisungen an, mit denen folgende Strukturen programmintern erzeugt werden können:
1.
A-->B-->C-->D-->E-->F-->G
2.
1-->2-->3-->4-->5-->6-->7
|
|
|
|
|
|
|
V
V
V
V
V
V
V
A
B
C
D
E
F
G
3.
1-->2-->3-->4-->5-->6-->7--8
ˆ
|
|
|
+--------------------------+
4.
1-->2-->3-->4-->5-->6-->7
|
|
|
|
|
|
|
V
V
V
V
V
V
V
A-->B-->C-->D-->E-->F-->G
Hier sollen jeweils durch Verweise verknüpfte Objekte im Heap (!) angelegt werden. Benutzen Sie nach Möglichkeit Schleifen (oder Rekursion)!
Aufgabe 7
Schreiben Sie ein Programm, das eine Folge von int–Werten bis zur ersten Eingabe von einliest, in einer
verketteten Liste speichert, die größte sowie die kleinste Zahl sucht und beide jeweils zusammen mit der Angabe
ausgibt, als wievielte Zahl sie eingegeben wurde. (Z.B: “7 ist die kleinste, sie wurde als 3. eingegeben; 1251 ist die
größte Zahl, sie wurde als 132. eingegeben.”)
Aufgabe 8
1. Welche Ausgabe erzeugt folgendes Programm:
#include <iostream.h>
#include <string>
using namespace std;
void assign_1 (int *x, int *y) {
x = y;
}
void assign_2 (int * &x, int * &y) {
x = y;
}
int main () {
int
a[10] = {90,90,90,90,90,90,90,90,90,90};
int * p = new int[10];
for (int i=0; i<10; i++) p[i] = i;
assign_1 (a, p);
for (int i=0; i<10; i++)
cout << a[i] << " , " << p[i] << endl;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
97
assign_2 (a, p);
for (int i=0; i<10; i++)
cout << a[i] << " , " << p[i] << endl;
p = a;
for (int i=0; i<10; i++)
cout << a[i] << " , " << p[i] << endl;
}
Erläutern Sie die Aktionen der beiden Funktionen! Was genau wird bei der Parameterübergabe übergeben
(l–Wert, r–Wert)? Ist das Programm überhaupt korrekt?
2. Wäre die Anweisung a = p; in obigem Programm erlaubt?
3. Was passiert, wenn ein mit new [] allokiertes Feld mit delete statt mit delete[] freigeben wird.
4. Welche Wirkung hat delete 0;?
5. Kann man Objekte auf dem Stack freigeben, wenn nicht, kann man es wenigstens versuchen?
6. Was ist der Unterschied zwischen
int *p = new int [10];
und
int *p = new int (10);
7. Wodurch unterscheiden sich die beiden Variablen p und q mit der Definition:
int * const &p
und
const int * &q
Zeichen Sie zur Illustration jeweils ein Diagramm.
8. Sie schreiben eine Funktion void f (??? pc), die einen C–String (Typ char *) als Parameter hat und
die beispielsweise wie folgt aufgerufen werden soll:
void f (...) {
...
}
int main () {
char *p = new char [10];
const char *q = "Charlotte";
p[0] = ’X’;
p[1] = 0;
f (p);
f (q);
f ("Hugo");
}
Welchen Typ sollte der Parameter von f haben?
Aufgabe 9
Welche der folgenden Aussagen ist richtig:
Der Destruktor einer Klasse C ist dafür verantwortlich ...
1. ... alle Objekte der Klasse C wegzuräumen.
Programmierung II
98
2. ... Objekte der Klasse C im Heap wegzuräumen.
3. ... alle Komponenten von Objekten der Klasse C wegzuräumen.
4. ... Komponenten von Objekten der Klasse C, die im Heap liegen, wegzuräumen.
5. delete ist nichts anderes, als eine spezielle Art den Destruktor aufzurufen: p sei vom Typ S*, dann ist
delete p; das Gleiche wie (*p). S()
Aufgabe 10
Welche Ausgabe erzeugt folgendes Programm:
#include <iostream>
using namespace std;
struct S {
S()
: a(0),
l(0)
{}
S (int p_a, S * p_l) : a(p_a), l(p_l) {}
int a;
S * l;
};
int main () {
S * p = new S(1,0);
for (int i = 0; i < 3; i++)
p = new S (2*(p->a), p);
cout << "In main:\n";
for (S * q = p; q != 0; q = q->l)
cout << q->a << " ";
cout << endl;
}
Aufgabe 11
Was ist alles falsch an folgender Klassendefinition:
class S {
public:
S ()
: v(0), l(0) {}
S (int i, S *pl) : v(i), l(pl) {}
S (const S &ps) {
delete l;
l = 0;
v = ps.v;
for ( S *p = ps.l; p != 0; p=p->l)
l = new S(p->v, l);
}
S & operator= (const S &ps) : l(0), v(ps->v) {
for ( S *p = ps.l; p != 0; p=p->l)
l = new S(p->v, l);
return *this;
}
private:
int v;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
S
*l;
};
Korrigieren Sie!
99
Programmierung II
100
4
Programmorganisation
4.1
4.1.1
Konsolenanwendungen
Programmstart: Laden und main–Funktion Aktivieren
Ein Programm wird nicht von selbst, aus eigenem Entschluss heraus, aktiv. Es wird vom Betriebssystem aus einer
Datei in den Hauptspeicher geladen und dann gestartet. C– und C++–Programme müssen eine main–Funktion
enthalten, die beim Start dann aufgerufen wird.
Damit das Betriebssystem ein Programm startet, muss die Benutzerin ihm einen entsprechenden Wunsch mitteilen.
Typischerweise macht sie das entweder über eine graphische Benutzeroberfläche, auf der ein Symbol angeklickt
wird, das dem Programm zugeordnet ist, oder indem ein entsprechendes Kommando eingetippt wird. Das Kommando besteht meist einfach aus dem Namen der Datei, die das ausführbare Programm enthält.
Auf welche Art auch immer, das Programm wird von einem anderen System aktiviert und und signalisiert diesem nach einer gewissen Zeit sein Ende. Normalerweise aktiviert das Betriebssystem des Rechners, auf dem es
abläuft, das Program, im Prinzip sind aber auch andere Szenarien der Aktivierung denkbar. In jedem Fall wird ein
Programm aber von einem irgendwie gearteten “externen System” aktiviert.
4.1.2
Das Programmergebnis
Das einfachste C++–Programm ist:
int main () {}
Es wird aktiviert, tut nichts und teilt dann dem System mit, dass es dabei erfolgreich war.
System und Programm kommunizieren miteinander. Das Programm kann Daten vom System übernehmen und
umgekehrt erwartet das System vom Programm eine Statusmeldung vom Typ int: das Programm–Ergebnis. Das
ist der Grund für den Ergebnistyp int, den jede main–Funktion haben sollte.
Eine main–Funktion die nichts explizit zurück gibt, liefert implizit den Wert . als Statusmeldung bedeutet
“Alles OK, kein Fehler!”. Trotz int–Ergebnis in main lassen wir darum dort (aber nur dort) in der Regel die
return–Anweisung weg. 8
Die weitere Verwendung des Programm–Status’ hängt natürlich vom (Betriebs–) System ab, das die Stausmeldung
in Empfang nimmt. Bei einer Unix–Shell kann man beispielsweise den Status zur Kontrolle weiterer Aktionen
verwenden. Beispiel (Kommandozeile):
if ./a.out; then echo "OK"; else echo "FEHLER"; fi;
4.1.3
Programmargumente
Ein Programm kann an seine Umgebung nicht nur Werte zurückliefern, es kann auch Eingaben – Programmargumente genannt – annehmen. Die Eingabe an das Programm besteht aus der Kommandozeile mit der das Programm
aktiviert wurde. Innerhalb des Programms kann mit den formalen Parametern von main darauf zugegriffen werden.
Beispiel:
int main (int argc, char * argv[]) {
cout << "Zahl der Argumente: " << argc << endl;
for (int i=0; i< argc; i++)
cout << argv[i] << endl;
}
8 Achtung C–Programmierer: dies gilt nur für C++ und nach aktuellem Sprachstandard! In C sollte main entweder vom Typ void sein,
oder vom Typ int und dann tatsächlich einen Wert zurückgeben.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
101
Nach dem Programmaufruf enthält argc die Anzahl der Worte in der Kommandozeile und argv ist ein Feld
von Zeigern auf die einzelnen Worte. argv ist also ein Feld von C–Strings. Wird das Programm etwa mit der
Kommandozeile
a.out paula hugo emilie
aktiviert, dann erzeugt es folgende Ausgabe:
Zahl der Argumente: 4
a.out
paula
hugo
emilie
Die Kommandozeile hat 4 Elemente: a.out, den Namen der Datei in der der ausführbare Programmcode liegt,
sowie paula, hugo und emilie. Man beachte, dass der Name des Programms, hier a.out, selbst zur Argumentliste gehört.
Ein weiteres Beispiel ist ein Programm, welches das Minimum seiner beiden Argumente bestimmt:
#include <iostream>
#include <stdlib.h>
int main (int argc, char * argv[]) {
if (argc != 3) // Programm-Name + 2 Argumente
cout << "Falscher Aufruf!" << endl;
else
if (atoi (argv[1]) < atoi (argv[2]))
cout << argv[1] << endl;
else
cout << argv[2] << endl;
}
atoi ist eine Bibliotheksfunktion die Zeichen–Felder (C–Strings) in ints konvertiert.
4.1.4
Zugriff auf die Programmargumente
Der Zugriff auf die Programmargumente ist eine ausgezeichnete Übung zum Umgang mit Feldern und Zeigern.
argv (argument vector) ist ein Feld von C–Strings, dessen Länge durch argc (argument count) festgelegt ist.
Die C–Strings sind Felder von chars, deren Länge durch die Endemarkierung 0 (Null als binärer Zahlwert, nicht
das Zeichen) bestimmt ist. Am besten macht man sich die Situation an einer kleinen Graphik klar (siehe Abbildung
37).
argc
Kommandozeile: Prog arg-1 arg-2
3
0 1 2
argv
P r o g0
a r g -1 0
a r g -2 0
Abbildung 37: Argumentvektor
Programmierung II
102
4.1.5
Konversion der Eingabe in Strings
C–Strings bieten zwar gutes Übungsmaterial für den Umgang mit Zeigern. In einem ernsthaften Programm sollte
man aber besser mit “richtigen” Strings arbeiten. Die Eingabeargumente können leicht in Strings umgewandelt
werden:
#include <string>
#include <iostream>
int main (int argc, char * argv[]) {
if (argc != 3) // Programm-Name + 2 Argumente
cout << "Falscher Aufruf!" << endl;
else {
string a1 (argv[1]); // C-String -> C++-String
string a2 (argv[2]); // mit String-Konstruktor
...
}
}
4.1.6
Konversion der Eingabe mit String–Streams
Zur Konversion der Eingabe in int–Werte oder in einen anderen numerischen Typ kann man auch String–Streams
verwenden:
#include <string>
#include <iostream>
#include <sstream>
int main (int argc, char * argv[]) {
if (argc != 3) // Programm-Name + 2 Argumente
cout << "Falscher Aufruf!" << endl;
else {
istringstream iStrS1 (argv[1]); // C-String -> String -> String-Stream
istringstream iStrS2 (argv[2]);
int i1, i2;
iStrS1 >> i1; // Konversion mit Operator >>
iStrS2 >> i2;
if (i1 > i2)
else
cout << i1 << " > " << i2 << endl;
cout << i2 << " > " << i1 << endl;
}
}
Der Konstruktor von istringstream akzeptiert einen String und füllt mit ihn einen internen Eingabestrom,
aus dem mit allen Möglichkeiten des >>–Operators gelesen werden kann. Hier im Beispiel wird ein C–String
übergeben, der zuerst noch in einen “richtigen” Sting konvertiert wird.
4.2
4.2.1
Speicherbereiche und Speicherschutz
Speicherbereiche
Ein laufendes Programm besteht aus mehreren Komponenten, die jeweils bestimmte Bereiche im Hauptspeicher
in Anspruch nehmen:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
103
Programmcode
Der Programmcode besteht aus den Maschinenanweisungen in die das Programm übersetzt wurde. Aus
diesem Bereich liest der Prozessor sukzessive die auszuführenden Anweisungen.
statische Daten
Statische Daten sind alle Objekte mit einer Lebensdauer, die der des gesamten Programms entspricht. Beispielsweise gehören die globalen Variablen sowie die statischen Datenkomponenten einer Klasse dazu.
Stapel (Stack)
Der Stapel besteht aus den Instanzen aller gerade aktiven Funktionen und Methoden. In einer solchen Instanz finden sich die lokalen (dynamischen) Variablen der Funktion, temporäre Zwischenspeicher (für die
Auswertung von Ausdrücken) und Verwaltungsinformationen (z.B. Rücksprungadressen).
Heap
Der Heap besteht aus den dynamisch (d.h. zur Laufzeit) mit new angeforderten Objekten.
Umgebungsinformation
In einem letzten kleinen Speicherbereich sind die Umgebungsinformationen gespeichert. Das sind in erster
Linie die Programmargumente.
Das genaue Layout hängt ab vom verwendeten Compiler und dem System auf dem der Code läuft. Die Grundstruktur ist aber überall gleich und kann graphisch dargestellt werden. (Siehe Abbildung 38).
Umgebung: argc, argv, ...
Instanz von main
...
Parameter
lokale Variablen
Programmzähler
Funktionsergebnis
temporärer Bereich
Instanz von f
Rückkehr aus einer
Funktion
Stack
Aufruf einer Funktion
Aufruf von new
Heap
statische Daten
Code von main
Code von f
...
Programmcode
Abbildung 38: Speicherbereiche eines aktiven Programms
Programmierung II
104
4.2.2
Speicherschutz
Der Speicher ist den Aktivitäten des Programms nicht hilflos ausgeliefert. Das System, in Form der Software
des Betriebssystems, eventuell auch mit Hilfe von Hardwaremechanismen, schützt zunächst einmal den Speicherbereich eines Programms vor Zugriffen eines anderen. Aber auch innerhalb des Bereichs, der einem Programm
zugeteilt wurde, sind Schutzmechanismen aktiv.
Beispielsweise ist es aus offensichtlichen Gründen nicht erlaubt in den Bereich des Programmcodes zu schreiben.
Den eigenen Code darf ein Programm also nicht verändern. Manche Compiler betrachten dabei String–Literale als
Bestandteile des Programmcodes, für andere gehören sie zu den statischen Daten. Das Programm
char *p = "HALLO WELT";
int main () {
p[0] = ’h’;
}
lässt sich darum problemlos übersetzen (und binden), stürzt aber eventuell mit der Fehlermeldung
“Segmentation fault” (oder etwas ähnlichem) ab.
Der Kontextbereich des Programms ist dagegen in der Regel ungeschützt. So ist zwar
int main (int argc, char *argv []) {
argc = 15;
argv [16] = "Hallo";
}
kompletter Unsinn, aber leider auch völlig korrekter C++–Code. Ebenso bedauerlich ist die Tatsache, dass die
Wahrscheinlichkeit eines Absturzes recht gering ist. Das Programm treibt zwar Unsinn, bleibt aber dabei in seinem
Speicherbereich und niemand klopft ihm darum auf die Finger.
Auf Programmargumente sollte natürlich nur lesend zugegriffen werden und auch erst nachdem man sich vergewissert hat, wieviele Argumente überhaupt vorliegen.
4.2.3
const und Speicherschutz
Der “harte” Schutz von Speicherbereichen durch das System ist etwas völlig anderes als der Schutz, den der
Einsatz von const bietet. Der Speicherschutz des Systems schützt dieses und andere Programme in erster Linie
vor Programmierfehlern anderer. Dieser Schutz ist zur Laufzeit aktiv.
Mit const kann man sich dagegen vor den eigenen Fehlern schützen. const wird vom Compiler und nur zur
Übersetzungszeit beachtet. Der Compiler vergleicht dabei die in Deklarationen/Definitionen dargelegten Absichten
mit den Aktionen.
Mit einem guten und typischen Einsatz von const kann man “Fehlgriffen” im Speicher vorbeugen. Beispielsweise
bringt man zum Ausdruck, dass C–String Literale nicht beschrieben werden können und Programmargumente nicht
beschrieben werden sollen:
int main (const int argc, const
const char * p = "HALLO";
p [0]
= ...
argc
= ...
argv[0]
= ...
argv[0][0] = ...
}
char * const argv []) {
//FEHLER
//FEHLER
//FEHLER
//FEHLER
(const
(const
(const
(const
char)
int)
argv)
char *)
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
4.3
105
Übersetzen und Binden
4.3.1
Die drei Phasen der Programmbearbeitung
Nützliche Programme tendieren dazu recht groß zu werden. Bei zunehmender Größe der Programme spielt die Organsisation eine immer wichtigere Rolle. Wie verteilt man den Quelltext auf diverse Dateien und wie wird daraus
ein ausführbares Programm erzeugt, ohne dass man komplett die Übersicht verliert? Eine vernünftige Programmorganisation setzt voraus, dass man sich über die Programmverarbeitung im Klaren ist, also den Weg vom Text
zum ausführbaren Maschinencode kennt.
Ein Programm ist zunächst ein Stück Text in einer Datei. Es muss übersetzt und gebunden werden und kann dann
ausgeführt werden (siehe Abbildung 39).
Quellprogramm
Compiler
Präprozessor
Übersetzer
Assembler
Objektprogramm
Binder
Maschinenprogramm
Abbildung 39: Phasen der Programmbearbeitung
Beim Übersetzen wird der Quelltext in Objektcode umgewandelt. Der Objektcode ist der Maschinencode,
der dem Quellprogramm direkt entspricht. Er ist in der Regel unvollständig und darum nicht lauffähig.
Der Binder (engl. Linker) macht aus dem Objektcode ein vollständiges Programm, das dann vom Betriebssystem geladen und ausgeführt werden kann.
4.3.2
Übersetzen = Präprozessor + Compiler + Assembler
In der Übersetzungsphase verarbeitet der Compiler den Quellcode und erzeugt Objektcode. Auch das läuft wieder
in mehreren Unterphasen ab:
Präprozessor: Zusammenstellung des vollständigen Quellcodes,
Compiler: Erzeugung von Assemblercode (symbolischem Objektcode),
Assembler: Umwandlung von Assemblercode in Objektcode.
Der Präprozessor verarbeitet vor allem die Inklusions–Direktiven und erzeugt damit den vollständigen Quellcode.
Der Compiler erzeugt den Objektcode nicht sofort aus dem Quellcode. Als Zwischenstufe wird zunächst oft symbolischer Objektcode erzeugt – Assembler (–code) genannt. 9 Der Assemblercode wird dann von einem Assembler
genannten Programm in den Objektcode umgesetzt. Der Zwischenschritt über den Assembler hat den Vorteil, dass
man in halbwegs verständlicher Form das Ergebnis der Übersetzung betrachten kann.
9 Das
machen nicht alle Compiler so, oft wird der Objektcode sofort erzeugt.
Programmierung II
106
4.3.3
Der Präprozessor: Text verarbeiten
Der Präprozessor ist eine Textverarbeitungsmaschinerie. Er verarbeitet die Präprozessor–Direktiven. Deren wichtigste ist die Inklusions–Direktive, mit der der Präprozessor angewiesen wird den Inhalt einer anderen Datei in den
Quelltext einzufügen.
4.3.4
Übersetzer: Datentypen und ihre Operationen umsetzen
Bei der Übersetzung wird der Quelltext geprüft und in (symbolischen) Maschinencode umgewandelt. Im Maschinencode gibt es nur Bits und Bytes. All die schönen Datentypen die in C++ vorgegeben sind oder im Programm
definiert wurden, müssen darum zu Bits und Bytes und in entsprechende Operationen umgesetzt werden.
Textdatei
Präptozessor
vollständiger
Compiler
Quelltext
Assembler
Textdatei
Objektdatei
Binder
vollständiges
Maschinenprogramm
Objektdatei
Abbildung 40: Präprozessor und Binder
4.3.5
Binder: Referenzen auflösen, Programme vervollständigen
Praktisch jedes Programm benutzt Konstrukte, die nicht von ihm selbst definiert wurden. Man denke nur an die
Ein– und Ausgabe–Operationen. Der Binder hat die Aufgabe die fehlenden Teile hinzuzufügen. Man beachte den
Unterschied zum Präprozessor: Der Präprozessor vervollständigt das Programm auf textueller Ebene. Alles was
er macht könnte man auch mit einem Texteditor selbst erledigen. Der Binder vervollständigt Programme auf der
Ebene des Objektcodes (siehe Abbildung 40).
4.3.6
Übersetzungseinheit
Die Arbeitseinheit des Compilers – das was er übersetzt – nennt man Übersetzungseinheit. Dateien und Übersetzungseinheiten sind nur sehr selten identisch. Normalerweiser verarbeitet der Compiler in einem Lauf mehrere
Eingabe–Dateien und erzeugt daraus eine Objektdatei. Weiter sind meist mehrere Objektdateien – die in mehreren
Compilerläufen erzeugt wurden – notwendig, um ein Maschinen–Programm zu erzeugen (siehe Abbildung 41) .
4.4
4.4.1
Präprozessor und Inklusionen
Die Inklusions–Direktive: Eine Übersetzungseinheit wird auf mehrere Dateien verteilt
Die Arbeitseinheit des Compilers – das was er übersetzt – ist zunächst einmal eine Datei. Mit Hilfe der Inklusions–
Direktive kann man den Compiler jedoch dazu bringen, seine Eingabe aus mehreren Dateien zu lesen. Das was in
einem Lauf übersetzt wird – die Übersetzungseinheit – ist dann auf mehrere Dateien verteilt.
Die Inklusions–Direktive kennen wir schon lange. Fast jedes Programm beginnt mit Zeilen wie:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
107
Text−Datei
Text−Datei
Compiler
Text−Datei
Text−Datei
Übersetzungseinheit
Compiler
Objekt−Datei
Objekt−Datei
Objekt−Datei
Binder
Objekt−Datei
Compiler
Programm
(Maschinen−) Programm
Compiler
Abbildung 41: Datei, Übersetzungseinheit, Programm
#include <iostream>
#include <string>
...
Die Inklusionsdirektive ist kein richtiger Ausdruck der Programmiersprache C++, sondern schlicht eine Anweisung
an den Präprozessor eine andere Datei zu lesen und den gelesenen Text so zu behandeln, als sei er hier an dieser
Stelle in der Datei vorhanden.
Die mit <iostream> etc. bezeichneten Dateien enthalten Deklarationen und Definitionen von Ein– und
Ausgabe–Routinen und andere allgemein nützliche Dinge. Ohne den include–Mechanismus müssten sie in
jeden Quelltext kopiert werden.
4.4.2
Inklusion eigener Dateien
Eine Inklusions–Direktive muss sich nicht unbedingt auf eine Systemdatei mit wichtigen allgemeinen Definitionen
und Deklarationen beziehen. Man kann auch seinen eigenen Programmtext auf mehrere Dateien verteilen. Beispiel:
dat-1.cc
#include "dat-2.cc"
dat-2.cc
int f (int x)
return x;
int main ()
int x = 5;
f (x);
Beide Dateien zusammen bilden ein Programm und eine (!) Übersetzungseinheit. Eine Übersetzungseinheit bedeutet einen Compileraufruf, z.B.:
g++ dat-1.cc
Damit wird das gesamte Programm mit zwei Quelldateien übersetzt. Der Compiler liest zuerst dat-1.cc und mit
#include "dat-2.cc"
auch dat-2.cc. Eine inkludierte Datei kann selbst wieder Inklusionsdirektiven enthalten. Auf diese Art kann die
Inklusion beliebig tief verschachtelt werden.
Programmierung II
108
4.4.3
Inklusion: spitze Klammern oder Hochkommas
Bei der Inklusion der eigenen Datei im Beispiel oben wurden doppelte Hochkommas (“Gänsefüßchen”) verwendet
#include "dat-2.cc"
Bei der Inklusion von globalen Systemdateien verwendet man dagegen spitze Klammern. Z.B:
#include <iostream>
In der “Gänsefüßchen–Form” wird die Datei direkt angegeben. Hier im Beispiel die Datei dat-2.cc. Die
Gänsefüßchen–Datei wird in dem Verzeichnis gesucht, in dem sich die Datei mit der include–Anweisung selbst
befindet. Man kann durch Angabe des vollständigen Pfades beliebige Dateien (auf die man Lesezugriff hat) inkludieren:
#include "/irgend/wo/anders/datei.cc"
4.4.4
Inklusion von Systemdateien
Bei Verwendung der spitzen Klammen in der include–Anweisung sucht der Compiler in ihm bekannten
Standard–Verzeichnissen nach einer Datei mit dem angegebenen Namen. Die Standard–Inklusionsdateien sind
in der Regel überall die gleichen. Ihre Position im Dateisystem hängt aber vom konkreten System ab. Mit diesem
Mechanismus können Programme erstellt werden, deren Quellcode von dem übersetzenden System unabhängig
ist.
Mit der Compileroption
-I Verzeichnispfad
kann die Liste der nach Systemdateien zu durchsuchenden Verzeichnisse erweitert werden. Beispiel:
> g++ -I/ein/inklusions/verzeichnis dat.cc
Bei der Übersetzung von dat.cc sucht der Compiler jetzt auch im Verzeichnis
/ein/inklusions/verzeichnis
nach Inklusionsdateien in spitzen Klammern.
4.4.5
Konventionen: C– und H–Dateien
C++–Programme können im Prinzip völlig willkürlich auf beliebig viele Dateien verstreut werden. Natürlich ist
das weder üblich noch empfehlenswert. Nach einer weit verbreiteten Konvention schreibt man alle ausführbaren
Anweisungen einer Übersetzungseinheit in eine Datei mit der Endung .cc (oder .c, .C, o.ä.): in die sogenannte
C–Datei. Inkludierte Definitionen und Deklarationen gehören in H–Dateien, Dateien mit der Endung .h.
Anders ausgedrückt:
Bei jedem Compileraufruf wird genau eine C–Datei verarbeitet.
Jede von einer anderen inkludierte Datei muss eine H–Datei sein.
Eine H–Datei enthält keine ausführbaren Konstrukte.
Unter “ausführbaren Konstrukten” versteht man solche, die zu einer Aktion zur Laufzeit führen: Anweisungen,
ebenso wie die Speicherallokationen durch die Definition einer globalen Variablen oder einer statischen Datenkomponente einer Klasse.
In eine H–Datei gehören also speziell: keine Funktions– oder Methoden–Definitionen, keine globalen oder statischen Variablen– oder Felddefinitionen.
In eine H–Datei kommen solche Konstrukte, die nur während der Übersetzung relevant sind, wie etwa:
Funktionsdeklarationen,
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
109
Klassendefinitionen,
typdefs
etc.
4.4.6
Präprozessor–Konstanten
Die Inklusionsdirektive ist ein wichtiger Mechanismus des Präprozessors, aber nicht der einzige. Mit der
Definitions–Direktive können Präprozessor–Konstanten definiert werden. Beispiel:
#define X
1.0
#define WAHR true
...
if ( (X > 0) == WAHR ) ...
Hier werden X und WAHR als textuelle Symbole definiert. Der Präprozessor ersetzt jedes Vorkommen von X und
WAHR durch den definierten Text. Der eigentliche Compiler bemerkt davon genauso wenig wie von Inklusionen.
Es ist üblich Präprozessor–Konstanten mit Großbuchstaben zu bezeichnen.
Eine Präprozessor–Konstante kann als Wert auch einen leeren Text haben:
#define Z
Z hat hier als Wert den leeren Text.
4.4.7
Bedingte Direktive
Mit einer bedingten Direktive kann getestet werden, ob eine Präprozessorkonstante definiert ist. Beispiel:
#ifdef INT_GROSS
int
a;
// Programmtext fuer den Fall INT_GROSS wurde definiert
short b;
#else
long a;
// Programmtext fuer den Fall INT_GROSS wurde nicht definiert
int b;
#endif
Hier testet der Präprozessor, ob die Konstante INT GROSS definiert wurde. Wenn ja reicht er den Text
int
a;
short b;
// Programmtext fuer den Fall INT_GROSS wurde definiert
an den Compiler weiter. Andernfalls wird der alternative Text übersetzt. Mit diesem Mechanismus kann der
Übersetzungsvorgang gesteuert werden. Dabei ist es hilfreich, dass Präprozessor–Konstanten auch durch eine
Compiler–Direktive gesteuert werden können. Mit dem Aufruf:
g++ -DINT GROSS prog.cc
agiert der Präprozessor so, als sei INT GROSS im Quelltext von prog.cc (als leerer Text) definiert.
In C spielen Präprozessor–Konstanten eine wichtige Rolle. In C++ sollte sich ihr Einsatz auf die Steuerung des
Übersetzungsvorgangs beschränken.
4.4.8
Inklusionswächter
Bereits bei Programmen mäßiger Größe lässt es sich kaum verhindern, dass die Datei mehrfach von einer anderen inkludiert wird. Dies führt praktisch immer zu Problemen. Definitionen müssen ja eindeutig sein und dürfen
Programmierung II
110
nicht wiederholt werden. Um diesen Schwierigkeiten aus dem Weg zu gehen, werden alle Inklusionsdirektiven
prinzipiell mit Inklusionswächtern versehen. Im Beispiel:
Datei XY.h:
#ifndef XY_H_
#define XY_H_
... Text der Datei XY.h
#endif
Dies bedeutet: Falls XY H nicht definiert ist, dann definiere es und verarbeite den Quelltext bis zum #endif,
andernfalls ignoriere alles bis zum #endif. Im Endeffekt wird der Compiler den eingeschlossenen Quelltext nur
einmal verarbeiten, egal wie oft er auf #include "XY.h" stoßen wird. Alle Dateien, die potentiell mehrfach
inkludiert werden – also eigentlich alle H–Dateien –, sollten in dieser Art gestaltet werden.
4.5
4.5.1
Getrennte Übersetzung
Dateien, Übersetzungseinheiten und Programme
Nur in den einfachsten Fällen sind Quell–Dateien, Übersetzungseinheiten und Programme mehr oder weniger
identisch. In realistischen Situationen müssen die Konzepte “Quelldatei”, “übersetzter Code” und “ausführbares
Programm” genauer unterschieden werden: Der Quellcode eines Programms findet sich oft in vielen Dateien. Der
Compiler verarbeitet in einem Lauf mehrere Dateien, und um ein Programm zu erzeugen sind mehrere Übersetzungsläufe nötig:
Mehrere Quelldateien bilden eine Übersetzungseinheit. Sie werden vom Compiler zu einer Objektdatei übersetzt.
Mehrere Übersetzungseinheiten werden vom Binder zu einem Programm zusammengefügt.
Textdatei−11
Textdatei−21
Textdatei−12
Textdatei−22
Textdatei−13
Textdatei−23
Textdatei−1n
Textdatei−2m
Übersetzungseinheit−1
Übersetzungseinheit−2
Compiler
Compiler
Objektdatei−1
Objektdatei−2
Binder
Maschinen−Programm
Abbildung 42: Ein Programm aus zwei Übersetzungseinheiten
Übersetzungseinheiten sind die Textstücke, die der Compiler in einem Lauf verarbeitet. Sie müssen sowohl korrekte als auch – in gewissem Sinn – vollständige Teile eines Gesamtprogramms sein. Der einfachste Fall liegt dann
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
111
vor, wenn das Gesamtprogramm auf eine einzige Übersetzungseinheit “verteilt” wird. Beschäftigen wir uns also
zunächst mit dem Begriff “Programm”.
4.5.2
Programm: vollständige Übersetzungseinheit mit Funktion main
Ein Programm ist eine Kollektion von korrekten und eindeutigen Definitionen (von Funktionen, Typen, Variablen,
... etc.) die
genau eine Definition einer Funktion mit Namen main, sowie
die Definiton aller direkt oder indirekt von main benutzten Namen enthält.
Ein Programm ist also einfach eine Funktion main mit allem, was von ihr benutzt wird.
4.5.3
Getrennte Übersetzung
Von getrennter Übersetzung spricht man, wenn ein Programm durch mehrere Übersetzungsläufe erzeugt wird.
Getrennte Übersetzung darf nicht mit dem Inklusions–Mechanismus verwechselt werden. Bei der Datei–Inklusion
werden in einem Compilerlauf mehrere Dateien verarbeitet. Bei der getrennten Übersetzung wird ein Programm
aus mehreren Übersetzungseinheiten erzeugt. Getrennte Übersetzung und Datei–Inklusion sind völlig unabhängig.
Selbst wenn der Compiler in einem Lauf den Inhalt von hunderten von Dateien übersetzt und daraus ein Programm
erzeugt, handelt es sich immer noch nicht um getrennte Übersetzung. Erst wenn zur Erzeugung eines Programms
der Compiler mindestens zweimal aktiv wird, haben wir es mit getrennter Übersetzung zu tun.
4.5.4
Übersetzen, Binden, Laden
Ausführbare Programme werden in mehreren Schritten aus dem Quellcode erzeugt. Zuerst erzeugt der Compiler
aus dem Quellcode den Objektcode, der Objektcode wird gebunden und schliesslich geladen und ausgeführt.
Quellcode
Compiler
Objektcode
Binder (Linker)
ausführbarer Maschinencode
Angenommen eine Quelldatei hallo-Welt.cc enthält ein vollständiges C++–Programm:
hallo-welt.cc
#include <iostream>
using namespace std;
void f ();
int main () { f (); }
void f () { cout << "Hallo Welt" << endl; }
hallo-welt.cc
Die drei Schritte – übersetzen, binden, laden und ausführen – sind dann konkret: 10
1. > g++ -c hallo-welt.cc
Mit der Option -c wird der Compiler aufgefordert, nur den Objektcode und nicht gleich ein ausführbares
Programm zu erzeugen.
10 Mit g++ hallo-welt.cc wird Übersetzen und Binden auf einmal erledigt. Die Aufteilung in einzelne Schritte soll Abfolge der Aktionen klar machen.
Programmierung II
112
2. > g++ hallo-welt.o
An der Endung .o erkennt der Compiler, dass es sich nicht um Quell– sondern bereits um Objektcode
handelt. Er bindet ihn zum ausführbaren Programm in der Datei a.out. (Genauer gesagt wird g++ nicht als
Compiler sondern als Binder aktiv.)
3. > a.out
Das Programm wird geladen und gestartet.
4.5.5
Aufteilung eines Programms in zwei Übersetzungseinheiten
Das Beispielprogramm von oben teilen wir jetzt in zwei Übersetzungseinheiten (ÜE) auf – also in zwei Dateien,
die getrennt voneinander übersetzbar sind:
ÜE-1, main.cc:
ÜE-2, f.cc:
void f ();
#include <iostream>
int main ()
f ();
void f ()
cout << "Hallo Welt < endl;
Die erste Datei – main.cc – enthält die Funktion main die zweite – f.cc – enthält f. Beide Dateien können übersetzt
werden:
> g++ -c main.cc
> g++ -c f.cc
Das Ergebnis der beiden Übersetzungen sind zwei Objektdateien main.o und f.o. Man beachte, dass keine der
beiden Dateien für sich allein zu einem ausführbaren Programm gebunden werden kann. Die Aufrufe:
> g++ main.cc
> g++ f.cc
führen zu Fehlermeldungen, in denen sich der Compiler über das fehlende main bzw. f beklagt. Beide zusammen
machen erst ein ausführbares Programm aus. Die Option -c oben weist g++ an, nur als Compiler tätig zu werden
und nicht zu versuchen ein ausführbares Programm zu erzeugen. Mit
g++ main.o f.o
werden die erzeugten Objektdateien main.o und f.o zu einem ausführbaren Programm a.out gebunden. Auch
hier erkennt g++ wieder an den Endungen .o dass er als Binder (engl. Linker) und nicht als Compiler agieren soll.
Der Binder nimmt den auf zwei Dateien verteilten Objekt–Code und fügt ihn zu einem Programm zusammen.
Objekte mit externer Bindung werden dabei über die Grenzen von Übersetzungseinheiten identifiziert. In diesem
Beispiel ist f ein Objekt mit externer Bindung. Der Aufruf von f in der einen Datei wird darum mit der Definition
von f in der anderen Datei zusammengebracht. Wenn das Auftreten des gleichen Bezeichners in unterschiedlichen
Übersetzungseinheiten nicht in Bezug gesetzt wird, dann handlet es sich um eine interne Bindung.
4.6
4.6.1
Konventionen zur Gestaltung von Übersetzungseinheiten
Vorteile der getrennten Übersetzung
Realistische Programme sind groß und werden von mehreren, eventuell sogar von sehr vielen Autoren erstellt.
Es ist darum unbedingt notwendig sie in getrennt zu bearbeitende Teile aufzuspalten. Diese Teile sind die einzelnen Übersetzungseinheiten. Jeder Entwickler kann seinen Quellcode erstellen und jederzeit unabhängig von allen
anderen übersetzen. Versorgt man ihn dazu noch mit dem Objektcode stabiler Versionen anderer Übersetzungseinheiten, dann kann er sogar seinen Teil in einer stabilen Umgebung und unabhängig von den anderen testen. Am
Ende der Entwicklung brauchen die einzelnen Teile nur noch gebunden zu werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
4.6.2
113
Nachteil der getrennten Übersetzung: Inkonsistenzen
Die getrennte Übersetzung hat auch Nachteile. Da der Compiler immer nur Teile und nie das gesamte Programm zu
Gesicht bekommt und kontrollieren kann, kommt es leicht zu Inkonsistenzen zwischen den verschiedenen Quelltexten.
Im Beispiel oben wird in einer Übersetzungseinheit (ÜE) die Funktion f definiert und in der anderen benutzt. Jede
ÜE muss für sich eine vollständige Eingabe für den Compiler darstellen. Darum enthält auch die ÜE, die f nur
benutzt und nicht selbst definiert, eine Deklaration von f.
Deklaration und Definition von f stehen nun in verschiedenen ÜEs. Die Deklaration wird benutzt, um den Code für
den Aufruf zu erzeugen. Die Definition wird benutzt um den Code für die Funktion selbst zu erzeugen. Das Problem
liegt darin, dass der Compiler beide – Definition und Deklaration – nicht mehr gleichzeitig zu sehen bekommt und
infolgedessen nicht mehr prüfen kann, ob beide zusammen passen (konsistent sind). Würde beispielsweise main.cc
geändert werden zu:
main.cc:
int & f (int &);
// FALSCHE Deklaration:
// f ist vom Typ void f();
int main () {
int x, y= 123;
x = f(y);
}
dann würde das weder bei der Übersetzung dieser Datei noch bei der Übersetzung von f.cc auffallen. Mit etwas
Glück erzeugt der Binder bei der Konstruktion des Gesamtprogramms einen Fehler. Im ungünstigsten Fall aber
kommt es zu unerklärlichen Laufzeitfehlern und Programmstürzen.
4.6.3
Erzwungene Konsistenz von Export und Import von Funktionsdefinitionen
In unserem Beispiel definiert ÜE f.cc die Funktion f und ÜE main.cc benutzt sie. Man kann auch sagen, dass die
eine ÜE f exportiert und die andere es importiert.
Die Konsistenz der Definition beim Exporteur und der Deklaration beim Importeur kann glücklicherweise recht
einfach erzwungen werden: Die Deklaration wird in eine eigene Datei f.h geschrieben, die dann von beiden
C–Dateien inkludiert wird. Die beiden Übersetzungseinheiten sind:
UE-1, besteht aus einer Datei
main.cc:
#include f.h"
int main ()
int x, y = 123;
x = f(y);
UE-1
UE-2, besteht aus zwei Dateien
f.h:
void f ();
f.cc :
#include f.h"
#include <iostream>
void f ()
cout << "Hallo Welt < endl;
Programmierung II
114
UE-2
Bei dieser Konstruktion fällt die mangelnde Übereinstimmung von Deklaration und Definition bei der Übersetzung
sofort auf: Die Konsistenz wird durch die Gestaltung des Quellcodes und seine Verteilung auf Dateien erzwungen.
4.6.4
Export und Import von Funktionen
Diese Konvention formulieren wir als Grundregel zur Gestaltung von ÜEen die Funktionen exportieren und importieren:
Export–Regel 1: Jede ÜE, die Funktionen an andere exportiert, hat deren Deklarationen in einer H–
Datei zu sammeln. Diese H–Datei muss vom Exporteur und allen Importeuren inkludiert werden.
Funktionen werden zu Codesequenzen im Objektcode übersetzt. Funktionsaufrufe werden zu Sprüngen zum Anfang dieser Codesequenzen. Liegen Definition und Verwendung einer Funktion in unterschiedlichen ÜEs, dann
stellt der Binder (engl. Linker) die Verbindung zwischen beiden her (Funktionen haben externe Bindung). Die
Deklaration sorgt dafür, dass der richtige Code erzeugt wird und die Codierkonvention sorgt dafür, dass die Korrektheit der Deklaration vom Compiler geprüft werden kann. (Siehe Abbildung 43).
f.H
void f ();
f.cc
main.cc
#include "f.H"
#include "f.H"
... f (); ...
void f () { ... }
g++ −c
main.o
g++ −c
Compiler verfolgt include−Referenzen
f.o
JUMP f
START f
g++ −o prog main.o f.o
unaufgelöste Referenz
im
Objektcode von main
Binder löst Referenzen im Objektcode auf
prog
Abbildung 43: Getrennte Übersetzung einer Funktion
4.6.5
Export und Import von Typdefinitionen
Im Gegensatz zu Funktionen existieren Typdefinitionen nur zur Übersetzungszeit. Es sind Informationen an den
Compiler über durchzuführende Prüfungen und darüber, wie der Code für andere Konstrukte – Zuweisungen,
Funktionsaufrufe, etc. – zu erzeugen ist.
Zur Laufzeit und auch innerhalb eines Objektprogramms gibt es nichts, was einer Typdefinition direkt entsprechen
würde. Aus diesem Grund kann – anders als bei Funktionen – auch nicht der Binder eine Verbindung zwischen der
Definition eines Typs in einer ÜE und seiner Verwendung in einer anderen ÜE herstellen. Export und Import von
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
115
Typdefinitionen muss auf rein textueller Ebene erfolgen. Beispielsweise indem in allen ÜEen die Typdefinitionen
exakt gleich wiederholt werden.
Beispiel:
ue-1.cc
typedef int
Gramm;
typedef float Kilo;
// Gemeinsame Definitionen
Kilo f (Gramm);
int main () {
Kilo x;
Gramm y;
x = f(y);
}
ue-1.cc
Die zweite ÜE:
ue-2.cc
typedef int
Gramm;
typedef float Kilo;
// Gemeinsame Definitionen,
// Exakte Kopie
Kilo f (Gramm g) {
return (float)g/1000;
}
ue-2.cc
In der praktischen Arbeit ist es natürlich nur schwer möglich über verschiedene Dateien verstreute Typ–
Definitionen stets auf dem gleichen Stand zu halten. Es ist auch nicht notwendig, denn mit dem include–
Mechanismus können die Definitionen in einer Datei konzentriert werden.
Export–Regel 2: Wird eine Typdefinition von mehreren ÜEen benutzt, dann schreibt man sie in eine
H–Datei, die von allen Importeuren inkludiert wird. Am besten ordnet man die Typdefinition einer ÜE zu und platziert die Definition dann in deren H–Datei.
Unser Beispiel wird damit zu (siehe auch Abbildung 44):
Die erste ÜE:
UE-1
ue-1.cc:
#include "ue-2.h"
//inkludiere H-Datei von UE-2
int main () {
Kilo x;
Gramm y;
x = f(y);
}
UE-1
Die zweite ÜE, besteht aus zwei Dateien:
UE-2
Programmierung II
116
ue-2.h:
typedef int
Gramm; // Gemeinsame Typdefinitionen
typedef float Kilo;
Kilo f (Gramm);
// Deklaration der exportierten Funktion
ue-2.cc:
#include "ue-2.h"
Kilo f (Gramm g) {
return (float)g/1000;
}
UE-2
ue-2.h
typedef int Gramm
ue-1.cc
#include "ue-2.h"
ue-2.cc
#include "ue-2.h"
... Gramm ...
... Gramm ...
Referenz auf Gramm wird
g++ -c
g++ -c
ue-2.o
main.o
vom Compiler per include
aufgelöst
Keine unaufgelöste Referenz auf Gramm
im Objektcode
prog
Abbildung 44: Getrennte Übersetzung mit gemeinsamen Definitionen
4.6.6
Typdefinitionen haben keine externe Bindung
Typdefinitionen haben interne Bindung.11 Im Gegensatz etwa zu Funktionen, werden Definition und Anwendung
nicht vom Binder zusammengeführt. Eine Übersetzungseinheit kann nicht übersetzt werden, wenn ihr nicht alle
benutzten Typdefintionen vorliegen.12 Sie kann aber – wie oben demonstriert – sehr wohl mit fehlenden Funktionsdefinitionen übersetzt werden.
11 Sie
existieren nicht im Objektcode und haben damit genau gesagt gar keine Bindung, weil es nichts zu binden gibt.
gewissen Umständen sind Vorausdeklarationen von Typen möglich. In diesen Fällen benötigt der Compiler die Typdefinition aber
nicht wirklich.
12 Unter
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
4.6.7
117
Export und Import von Klassendefinitionen
Eine Klasse besteht aus der Klassendefinition und der Definition der Methoden. Export und Import von Klassendefinitionen sind darum eine Kombination von Export/Import eines Typs mit der von Funktionsdefinitionen.
Export–Regel 3: Wird eine Klasse exportiert, dann enthält die exportierende ÜE die Klassendefinition
in ihrer H–Datei und die Definition der Methoden in ihrer C–Datei. Die C–Dateien
der exportierenden und die C–Dateien der importierenden ÜEen inkludieren diese H–
Datei. Eine Klasse bildet in der Regel eine eigene ÜE.
Beispiel: H–Datei:
Buch.h
#ifndef BUCH_H_
#define BUCH_H_
#include <string>
class Buch {
public:
Buch ();
Buch (string, string);
string holAutor () const;
string holTitel () const;
void setzeAutor
void setzeTitel
(string);
(string);
static bool neuer (const Buch &, const Buch &);
private:
string
titel;
string
autor;
const int regNr;
static int naechsteNr;
};
#endif
Buch.h
und C–Datei:
Buch.cc
#include "Buch.h"
Buch::Buch(string p_autor, string p_titel)
: titel (p_titel),
autor (p_autor),
regNr (naechsteNr) {naechsteNr++;}
Buch::Buch()
: titel (""),
autor (""),
regNr (naechsteNr) { naechsteNr++; }
string
string
void
Buch::holAutor () const { return autor; }
Buch::holTitel () const { return titel; }
Buch::setzeAutor (string p_autor) { autor = p_autor; }
Programmierung II
118
void
bool
int
Buch::setzeTitel (string p_titel) { titel = p_titel; }
Buch::neuer (Buch &b1, const Buch &b2) { return b1.regNr > b2.regNr; }
Buch::naechsteNr = 0;
Buch.cc
H–Datei und C–Datei bilden zusammen die ÜE Buch. Die folgenden Prinzipien wurden in diesem Beispiel beachtet:
Eine Klasse bildet eine ÜE.
Die Klassendefinition kommt in die H–Datei.
Alle Methoden – statisch oder nicht – kommen in die C–Datei.
Alle statischen Datenkomponenten werden in der C–Datei definiert und initialisiert.
Die H–Datei inkludiert die Defintionen die von der Klassendefinition benutzt werden (im Beispiel
#include <string>),
Die C–Datei inkludiert als erstes die H–Datei, dann eventuell weitere Dateien, deren Definitionen in der
Implementierung der Methoden benötigt werden.
Von der Regel, dass jede Klasse eine eigene ÜE bildet, sollte nur bei kleinen oder sehr eng verwandten oder
befreundeten Klassen und Funktionen abgewichen werden.
Siehe Abbildung 45.
Buch.h
class Buch { ... };
main.cc
Buch.cc
#include "Buch.h"
#include "Buch.h"
Buch::Buch () { ... }
Buch bibel;
g++ −c
g++ −c
main.o
Buch.o
Referenz auf
gemeinsame Deklarationen
Compiler löst Referenz auf Deklarationen
per include auf
unaufgelöste Referenzen auf
JUMP Buch_Konstr
START Buch_Konstr
Methodenimplementierungen im
Objektcode
Binder löst Referenzen im Objektcode
auf (für alles mit externer Bindung)
prog
Abbildung 45: Getrennte Übersetzung einer Klasse
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
4.6.8
119
Import und Export von globalen Variablen: externe Bindung
Globale Variablen13 haben externe Bindung (engl. external linkage): Eine globale Variabale, die in einer Übersetzungseinheit definiert wurde, kann in dieser und allen anderen benutzt werden. Dazu muss der Importeur die
fremden Variablen nur durch extern dem Compiler lokal bekannt machen. 14
Beispiel:
ÜE–1, Importeur (Benutzer) von i, Exporteur (Definierer) von f:
extern int i;
// int i ist in einer anderen UE
// es wird hier benutzt
void f () {
i++;
}
// Funktionsdefinition
// Benutzung des fremden i
ÜE–2, Exporteur von i, Importeur von f:
void f ();
// Funktionsdeklaration: f ist in einer anderen UE
// es wird hier benutzt
int i = 0;
// Definition des gemeinsam genutzten i
int main () {
f();
// Benutzung des fremden f
}
Die externe Bindung der globalen Variablen i und der Funktion f bedeutet, dass sie über alle ÜE–Grenzen hinweg
im gesamten Programm genutzt werden. Beide – i und f – existieren im Objektcode einer ÜE und der Binder
kann Definitions– und Verwendungsstellen zusammenführen.
Externe Bindung bedeutet nicht nur, dass der Binder die Verbindung herstellen kann, sondern auch dass er es auch
tatsächlich macht. Objekte mit externer Bindung müssen darum über alle ÜE–Grenzen hinweg eindeutig sein, d.h.
sie dürfen nur einmal im gesamten Programm definiert werden. Beispiel:
ÜE 1:
int i = 0;
//ein i
void f () {
i++;
}
und ÜE 2:
void f ();
int i = 0; //FEHLER, ein ANDERES globales i ist NICHT erlaubt
int main () {
i++;
f();
}
Werden ÜE–1 und ÜE–2 zu einem Programm gebunden, dann wird der Binder einen Fehler melden: i wurde
zweimal definiert; zwar in unterschiedlichen Dateien und unterschiedlichen ÜEen, aber in einem Programm – das
darf nicht sein! Siehe Abbildung 46.
13 Es
wird generell nicht empfohlen mit globalen Variablen zu arbeiten, es sei denn es liegen wirklich stichhaltige Gründe dafür vor.
C–Programmierer: Hier weicht C++ von C und auch von älteren C++–Versionen ab. static als Kennzeichnung der Lokalität
globaler Variablen (interne Bindung, internal linkage) gibt es nicht mehr. Alle globalen Variablen haben externe Bindung (external linkage.)
14 Achtung
Programmierung II
120
ÜE−1.cc
ÜE−2.cc
int i;
ÜE−1.cc
ÜE−2.cc
int i;
int i;
extern int i;
.. i ...
.. i ...
g++ −c
g++ −c
g++ −c
ÜE−1.o
.. i ...
.. i ...
g++ −c
ÜE−2.o
externe
i
externe
i
Bindung
... i ...
... i ...
prog
... i ...
Bindung
i
... i ...
g++ ÜE−1.o ÜE−2.o
KONFLIKT
i ist doppelt
deklariert
Abbildung 46: Globale Variablen über ÜE–Grenzen hinweg
Eine Doppeldefinition einer Funktion wäre ebenfalls auch dann ein Fehler, wenn die beiden Definitionen in unterschiedlichen ÜEs zu finden sind. Natürlich kann nicht der Compiler, sondern erst der Binder solche Fehler finden.
Export–Regel 4: Der Export von Variablen ist in der Regel zu vermeiden. Sollte er doch notwendig sein, dann schreibt der Exporteur die Variablendefinition in seine C–Datei und
die extern–Deklaration dieser Variablen in seine H–Datei. Der Importeur inkludiert
diese H–Datei.
Statische Datenkomponenten einer Klasse, die Klassenvariablen, werden in Bezug auf getrennte Übersetzung wie
globale Variablen behandelt. Sie haben externe Bindung. Ihre Deklaration gehört mit der Klassendefinition in die
H–Datei und ihre Definition in die C–Datei.
4.6.9
Import und Export von Konstanten, interne Bindung
Konstanten haben im Gegensatz zu globalen Variablen und Funktionen eine interne Bindung (engl. internal linkage), d.h sie werden nicht vom Binder automatisch über ÜE–Grenzen im gesamten Programm bekannt gemacht.
Aus diesem Grund können die folgenden beiden ÜEen problemlos zu einem Programm gebunden werden:
ÜE 1:
const int i = 0;
//eine int-Konstante
void f () {
int x = i;
}
ÜE 2:
void f ();
const int i = 1; //eine andere int-Konstante
int main () {
int y = i;
f();
}
Interessanterweise kann Konstanten aber auch explizit eine externe Bindung verschafft werden. Beide – Exporteur
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
ÜE-1.cc
extern const int i = 0;
121
ÜE-2.cc
extern const int i;
.. i ...
.. i ...
g++ -c
g++ -c
externe
Bindung
keine
externe
i
... i... ... i ...
... i ...
g++ ÜE-1.o ÜE-2.o
prog
i
i
Bindung
... i ...
g++ ÜE-1.o ÜE-2.o
prog
g++ -c
ÜE-2.o
i
... i ...
... i ...
.. i ...
ÜE-1.o
ÜE-2.o
i
const int i = 1;
.. i ...
g++ -c
ÜE-1.o
ÜE-2.cc
ÜE-1.cc
const int i = 0;
i
... i... ... i ...
Abbildung 47: Konstanten über ÜE–Grenzen hinweg
und Importeur – müssen dazu die Konstante mit extern kennzeichnen; der Exporteur muss sie initialisieren, der
Importeur darf sie nicht initialisieren (siehe auch Abbildung 47):
ÜE 1:
extern const int i = 0;
//eine int-Konstante, ueber UE-Grenzen hinweg
//verwendbar (externe Bindung)
void f () {
int x = i;
}
ÜE 2:
void f ();
// Info an den Compiler uber fremdes Objekt f
extern const int i; // Info an den Compiler uber fremdes Objekt i
int main () {
int y = i;
f();
}
// i durch den Binder mit dieser Verwendung verknupft
// f durch den Binder mit dieser Verwendung verknupft
Am besten macht man von dieser Möglichkeit keinen Gebrauch. Besser man verbreitet Konstanten, die in mehreren
ÜEen bekannt sein sollen, nicht über die mögliche externe Bindung (also durch den Binder), sondern dadurch, dass
man ihre Definition (ohne extern!) in eine von allen inkludierte H–Datei schreibt (also mit dem include–
Mechanismus auf Textebene). Im Beispiel:
UE 1
ue-1.h:
const int i = 0;
void f ();
ue-1.c:
#include üe-1.h"
void f ()
int x = i;
Programmierung II
122
UE 1
UE 2
ue-2.c:
#include "ue-1.h"
int main () {
int y = i; // i ist wird nur "textuell" verbreitet
f();
// f wird durch den Binder mit dieser Verwendung verkn"upft
}
UE 2
Die Konstanten mit externer Bindung weiter oben werden vom Binder im Programm verbreitet. Im Gegensatz dazu
werden hier zwei verschiedene Konstanten i definiert. Mit der Platzierung der Definition in der H–Datei, die von
beiden ÜEen inkludiert wird, erreicht man aber, dass sie den exakt gleichen Wert haben.
In jedem Fall ist es sicher ganz schlechter Stil innerhalb eines Programms sowohl Konstanten mit interner als auch
solche mit externer Bindung willkürlich zu mischen15. Am besten vermeidet man globale Konstanten vollständig.
Statische Klassenmitglieder sind die bessere Alternative!
Export–Regel 5: Globale Konstanten sind in der Regel zu vermeiden. Sind sie dennoch notwendig,
dann werden sie wie Typdefinitionen behandelt: Man schreibt sie in eine H–Datei, die
von allen Importeuren inkludiert wird. Am besten ordnet man die Konstantendefinition einer ÜE zu und platziert die Definition dann in deren H–Datei.
4.7
4.7.1
Make
Programmerzeugung
Programme, die aus mehreren ÜEs bestehen, können nur in mehreren Schritten erzeugt werden. Die Komponenten
müssen einzeln übersetzt und dann gebunden werden. Für derartige Aufgaben wird allgemein ein Make–System
verwendet.
Nehmen wir an, dass ein Programm aus zwei ÜEen besteht:
ÜE 1 mit den Dateien: ue-1.h und ue-1.cc;
ÜE 2 mit den Dateien: ue-2.h und ue-2.cc.
wobei sowohl ue-1.cc als auch ue-2.cc beide H–Dateien inkludieren.
Das Gesamtprogramm, als ausführbare Datei prog, wird mit folgenden Anweisungen erzeugt:
g++ -c ue-1.cc
g++ -c ue-2.cc
g++ -o prog ue-1.o ue-2.o
4.7.2
Makedatei
Diese Anweisungen wird man nicht nach jeder Änderung einer Quelldatei aufs neue tippen wollen. Man könnte
sie in eine “Batch–Datei” schreiben, aber der übliche Weg ist die Erstellung einer Make–Datei mit dem Namen
Makefile:
15 Die vielen Möglichkeiten von C++ wurden zur Konstruktion klarer und effizienter Programme eingeführt, nicht, wie man gelegentlich
versucht ist zu glauben, dazu möglichst unleserliche, verwirrende oder beeindruckende Programme zu schreiben!
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
123
prog : ue-1.o ue-2.o
g++ -o prog ue-1.o ue-2.o
ue-1.o : ue-1.cc ue-1.h ue-2.h
g++ -c ue-1.cc
ue-2.o : ue-2.cc ue-1.h ue-2.h
g++ -c ue-2.cc
Achtung: Makedateien haben strenge Formatkonventionen! Die eingerückten Zeilen müssen mit einem
eingerückt werden. Die nicht eingerückten Zeilen müssen in der ersten Spalte beginnen!
Den Inhalt der Make–Datei kann man abschnittweise wie folgt deuten:
prog : ue-1.o ue-2.o
g++ -o prog ue-1.o ue-2.o
Erste Zeile: Die Datei prog wird aus den Dateien ue-1.o und ue-1.o erzeugt (prog hängt von ue-1.o
und ue-2.o ab.)
Zweite Zeile: prog wird mit dem Kommando g++ -o prog ue-1.o ue-2.o erzeugt.
ue-1.o : ue-1.cc ue-1.h ue-2.h
g++ -c ue-1.cc
Die Erzeugung der Datei ue-1.o hängt von den Dateien ue-1.cc, ue-1.h und ue-2.h ab.
ue-1.o wird mit dem Kommando g++ -c ue-1.cc erzeugt.
ue-2.o : ue-2.cc ue-1.h ue-2.h
g++ -c ue-2.cc
ue-2.o hängt von ue-1.cc, ue-1.h und ue-2.h ab und wird mit g++ -c ue-2.cc erzeugt.
Mit dem Aufruf
make prog
oder einfach
make
kann jetzt das Programm erzeugt werden. make liest standardmäßig eine Datei mit Namen Makefile und interpretiert die dort zu findenden Anweisungen.
4.7.3
Make–Dateien bestehen aus rekursiven Regeln zur Dateierzeugung
Eine Make–Datei besteht aus einer Kollektion von Make–Regeln (make rules). Jede Regel hat die Form:
Ziel–Datei : Abhängigkeitsliste
Kommando
Die Ziel–Datei ist eine Datei die mit Hilfe von Kommando generiert wird. Die Abhängigkeitsliste ist die Liste der
Dateien, die zur Erzeugung der Ziel–Datei benötigt werden. (
ist das Tabulatorzeichen!)
Ein Vorteil von make gegenüber einer Liste von Kommandos besteht darin, dass make die richtige Reihenfolge
der Erzeugung mit Hilfe der Abhängigkeitsliste selbst bestimmt. Das Kommando einer Regel wird ausgeführt,
wenn die Ziel–Datei nicht existiert, aber alle Dateien in der Abhängigkeitsliste existieren. Die Dateien in der
Abhängigkeitsliste werden vorher ihrer Regel entsprechend erzeugt – auch wenn die Regel weiter unten im Text
zu finden ist. make realisiert also einen rekursiven Algorithmus zur Dateierzeugung. Es werden also nicht einfach
Kommandos abgearbeitet, sondern nur das Notwendige und das in der richtigen Reihenfolge getan.
Programmierung II
124
4.7.4
Make beachtet die Erzeugungszeiten der Dateien
Eine Make–Regel wird immer angewendet, wenn die Ziel–Datei nicht existiert. make beachtet aber auch die Erzeugungszeiten der Dateien und erzeugt die Ziel–Datei auch dann neu, wenn sie älter ist als eine in ihrer Abhängigkeitsliste. Auch dieser Mechanismus wird selbstverständlich rekursiv angewendet.
Nehmen wir an im Beispiel oben wird ue-2.cc geändert und dann make aufgrufen. Damit wird ue-2.cc neu
übersetzt (ue-2.cc ist neuer als ue-2.o) und prog neu gebunden (ue-2.o ist neuer als prog).
Man sieht, dass die Welt von make aus Dateien besteht. Dateien werden mit Hilfe von Kommandos erzeugt und
jede Datei hängt von einer Liste von anderen Dateien ab. Entsprechend den im Makefile dargelegten Regeln
untersucht make alle Ziel–Dateien und generiert alle die neu, die älter sind als eine der Dateien, von denen ihre
Produktion abhängt. Dabei sorgt make auch dafür, dass zum einen die richtige Reihenfolge eingehalten wird und
zum anderen keine überflüssigen Generierungsprozesse ablaufen.
4.7.5
Make hält Systeme aktuell
Jeder Aufruf von make aktiviert alle notwendigen und keine überflüssigen Generierungsprozesse. Damit können
komplexe Systeme nicht nur produziert werden, man kann sie auch mit minimalem Aufwand aktuell halten.
Systeme sind sehr oft Programme. Die Anwendung von make ist aber nicht nur auf die Erzeugung von Programmen beschränkt, man kann es benutzen um den Ablauf beliebiger Aktionen zu steuern, soweit diese Aktionen sich
mit der Generierung von Dateien aus anderen Dateien beschäftigen.
Make ist ein mächtiges und flexibles Werkzeug. Die hier diskutierten Dinge kratzen gerade mal an der Oberfläche.
Jedem, der ernsthaft an SW–Entwicklung interessiert ist, wird eine eingehendere Beschäftigung mit diesem Werkzeug dringend empfohlen.
4.8
4.8.1
Bibliotheken
Bibliotheken sind Dateiarchive
Eine Bibliothek – zu Recht auch oft Archiv genannt – ist eine Datei, die eine Kollektion anderer Dateien enthält.
4.8.2
Objektbibliotheken werden vom Binder verarbeitet
Die wichtigste Anwendung der Dateiarchive sind die Objektbibliotheken: Kollektionen von Objektdateien in einer
Archivdatei. Eine Objektbibliothek kann vom Binder ebenso wie eine Objektdatei verarbeitet werden.
Nehmen wir an unser Programmsystem besteht aus den ÜEen
ue-m.cc, (Hauptprogramm)
ue-1.cc (1–te Hilfsfunktion) und
ue-2.cc (2–te Hilfsfunktion).
Dabei enthält ue-m.cc das Hauptprogramm und die beiden anderen irgendwelche Hilfsroutinen und/oder Klassendefinitionen. Wir können ue-1.cc und ue-2.cc übersetzen und in einer Bibliothek mit beliebigem Namen,
z.B. libmm.a, einsortieren:
> g++ -c ue-1.cc
> ar -r libmm.a ue-1.o
Das ar–Kommando sortiert (archiviert) die Datei ue-1.o in das Archiv libmm.a ein. Sollte dieses Archiv
noch nicht existieren, dann wird es erzeugt. Die Option -r steht für replace und bedeutet, dass das Argument
eingeführt und ein eventuell vorhandenes Element gleichen Namens gelöscht wird (d.h. replace = ersetzendes
Einfügen). Das gleiche kann mit ue-2.cc gemacht werden:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
125
> g++ -c ue-2.cc
> ar -r libmm.a ue-2.o
In libmm.a sind jetzt zwei Objektdateien vorhanden. Es ist eine Objektbibliothek die vom Binder benutzt werden
kann:
> g++ -o prog ue-m.cc libmm.a
Mit diesem Kommando wird die ÜE ue-m übersetzt, mit der Bibliothek libmm.a gebunden und das Ergebnis
als ausführbare Datei in prog erzeugt. Der Bindeprozess wird oft beschleunigt, wenn innerhalb des Archivs mit
ranlib libmm.a
ein interner Index aller definierten Symbole erzeugt wird. ranlib kann man beispielsweise nach jedem Einfügen
aufrufen:
> g++ -c ue-2.cc
> ar -r libmm.a ue-2.o
> ranlib libmm.a
4.8.3
Objektbibliotheken können programmübergreifend verwendet werden
Es ist nicht notwendig, dass alle Bestandteile einer Objektbibliothek beim Binden verarbeitet werden. Man kann
darum auch Bibliotheken erzeugen, die von vielen Programmen benutzt werden. Der Objektcode, der zu den Standardroutinen wie etwa der Ein–/Ausgabe oder zu den mathematischen Funktionen gehört, ist in einer solchen
Bibliothek zu finden – der sogenannten Standardbibliothek. Sie wird stets automatisch zu jedem Programm gebunden.
4.8.4
Bibliotheks–Suchpfade
Die Verwendung von Objektbibliotheken ist so wichtig und weitverbreitet, dass der Binder weitere
Kommandozeilen–Optionen zur Unterstützung bietet. Wichtig sind:
-LVerzeichnispfad
Mit dieser Option wird der Binder veranlasst (auch) im Verzeichnis Verzeichnispfad nach Objektbibliotheken
zu suchen.
-lBibliothekskürzel
Mit dieser Option wird die Bibliothek libBibliothekskürzel.a zum Programm gebunden.
Beispiel:
> g++ -o prog ue-m.o -L/mein/pfad -lmm
Hier wird die Objektbibliothek libmm.a aus dem Verzeichnis /mein/pfad zum Programm gebunden. Objektbibliotheken sollten wegen dieser Konvention grundsätzlich einen Namen tragen, der mit lib beginnt und mit .a
endet.
4.8.5
Objektbibliotheken und Make
Selbstverständlich können Objektbibliotheken auch in Zusammenhang mit Make verwendet werden. Der Quellcode wird übersetzt und die Objektdatei in das Archiv eingefügt. Eine entsprechende Regel ist:
ue-1.o : ue-1.cc ue-1.h ue-2.h
g++ -c ue-1.cc
ar -r libmm.a ue-1.o
ranlib libmm.a
Man sieht, dass make mehrere Kommandos für ein Ziel aktivieren kann und dass die Kommandos sich nicht
unbedingt wirklich mit der Ezeugung der Zieldatei beschäftigen müssen.
Programmierung II
126
Zum Binden des Gesamtprogramms wird die Bibliothek benötigt. Dies schlägt sich in einer entsprechenden Regel
nieder:
program : program.o libmm.a
g++ -o program program.o libmm.a
4.8.6
Make–Makros
Zur Aktualisierung der Einträge in einer Bibliothek benutzt man am besten Makros:
# im aktuellen Ziel ($@) neue Voraussetzung ($?) ersetzen:
libmm.a : ue-1.o ue-2.o ue-3.o ue-4.o
ar -r $@ $?
Im Kommando dieser Regel werden zwei Makros eingesetzt, um im aktuellen Ziel (erstes Makro, die Bibliothek)
die neue Voraussetzung (zweite Makro, die neue O–Datei) zu ersetzen.
Eine Makedatei für ein ganzes Programmsystem, das mit einer Objektbibliothek arbeitet ist:
# ausfuehrbares Programm prog erzeugen
prog : main.o Buch.o libmm.a
g++ -o prog main.o libmm.a
# UE Buch uebersetzen
Buch.o : Buch.h Buch.cc
g++ -c Buch.cc
# UE Buchmappe uebersetzen
Buchmappe.o : Buch.h Buchmappe.h Buchmappe.cc
g++ -c Buchmappe.cc
# UE main uebersetzen
main.o: Buch.h main.cc
g++ -c main.cc
# Objektbibliothek erzeugen
libmm.a : Buch.o Buchmappe.o
ar -r $@ $?
Ein Teilsystem eines größeren Gesamtsystems kann mit folgender Makedatei übersetzt und in einer Bibliothek
platziert werden:
CC=g++
AR=ar
CFLAGS=-g
ARFLAGS=r
SHARE_LIB=/Pfad/zu/lib.a
OBJS=lib-datei-1.o lib-datei-2.o
SRCS=$(OBJS:.o=.c)
${SHARE_LIB}: ${OBJS}
${AR} ${ARFLAGS} $@ $?
-ranlib $@
Hiermit werden lib-datei-1.c und lib-datei-1.c übersetzt und dann in der Bibliothek
/Pfad/zu/lib.a platziert. Die Makedatei für ein Programm, das Definitionen aus einer der beiden,
oder beiden benutzt, kann folgendes Aussehen haben:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
127
CC=g++
CFLAGS=-g
LDFLAGS=
SHARE_LIB=/Pfad/zu/lib.a
OBJS=dat-1.o dat-2.o dat-3.o dat-4.o
SRCS=$(OBJS:.o=.c)
program: ${OBJS}
${CC} ${CFLAGS} -o program ${OBJS} ${SHARE_LIB} -lpthread
Das Programm besteht hier aus vier Übersetzungseinheiten dat-1.c .. dat-4.c. Zu den Objektdateien
dat-1.o .. dat-4.o wird die Bibliothek /Pfad/zu/lib.a gebunden, in der lib-datei-1.o und
lib-datei-2.o von oben abgelegt sind. Dazu wird noch die Systembibliothek pthread gebunden.
4.8.7
Dynamisches Binden
In Systemen mit dynamischer Bindung enthalten ausführbare Programme nicht den gesamten Code. Das unvollständige Programm wird gestartet. Wenn dann bei der Ausführung ein fehlender Teil angetroffen wird, dann wird
er dynamsich nachgeladen und gebunden. Der Begriff “dynamisch” bedeutet hier wieder “zur Laufzeit”. Der
Vorteil des dynamischen Bindens sind wesentlich kleinere ausführbare Programme. Dem steht ein komplexerer
Lade/Binde–Prozess gegenüber. Eine Bibliothek, die dynamisch bindbaren Objektcode enthält, wird dynamische
Bibliothek oder auch dynamic link library (DLL) genannt.
4.9
4.9.1
Beispiel: Geometrische Objekte
Übersetzungseinheiten
Als etwas umfangreicheres Beispiel wollen wir hier die geometrischen Objekte aus dem letzten Kapitel auf mehrere
Übersetzungseinheiten verteilen. Üblicherweise macht man jede Klasse zu einer Übersetzungseinheit, die jeweils
aus einer H–Datei und einer C–Datei besteht. Unser Beispiel wird damit zu:
ÜE–1: Vektor.h, Vektor.cc
ÜE–2: Punkt.h, Punkt.cc
ÜE–3: Gerade.h, Gerade.cc
Hauptprogramm als eigene ÜE: main.cc
4.9.2
H–Dateien
Jede H–Datei wird mit einem Inklusionswächter ausgestattet. Sie umfasst die Klassendefinition und die Deklarationen der freien Funktionen für die entsprechende Klasse. Die benutzten Defintionen werden in Form ihrer
H–Dateien inkludiert. Z.B. Gerade.h:
#ifndef GERADE_H_
#define GERADE_H_
// Inklusionswaechter
#include <iostream>
#include <Punkt.h>
#include <Vektor.h>
// benutzte Definitionen
class Gerade { ... };
// Klassendefinition
ostream & operator<< (ostream &, const Gerade &); // freie Funktionen
istream & operator>> (istream &, Gerade &);
Programmierung II
128
#endif
4.9.3
C–Dateien
Die C–Dateien enthalten die Methodendefinitionen, die Definitionen der freien Funktionen und die Definition der
Klassenvariablen:
#include <Gerade.h>
// H-Datei inkludieren
Gerade::Gerade ()
: p_(Vektor(0,0)), a_(Vektor(1,1))
{}
...
// Methodendefinition
ostream & operator<< (ostream & os, const Gerade &g) { // freie Funktion
return os << "[" << g.p_ << " + " << g.a_ << "]";
}
...
const Gerade Gerade::x_Achse(Vektor(0,0), Vektor (1,0));
const Gerade Gerade::y_Achse(Vektor(0,0), Vektor (0,1));
4.9.4
// statische
// Komponenten
Dateiorganisation
Vektor, Punkt und Gerade und das Hauptprogramm werden in vier Verzeichnissen organisiert:
Ein Inklusionsverzeichnis mit allen H–Dateien
Ein Quellverzeichnis mit allen C–Dateien
Ein Bibliotheksverzeichnis mit der Bibliothek, die den Objektcode enthält.
Ein Verzeichnis mit dem Hauptprogramm, der Anwendung der Klassen.
include
Inkludeverzeichnis
Vektor.h
Punkt.h
Gerade.h
src
Quellverzeichnis
Vektor.cc
Punkt.cc
Gerade.cc
Bibliotheksverzeichnis
lib
libgeo.a
Verzeichnis mit der Anwendung
TestGeo
main.cc
Abbildung 48: Programmorganisation
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
4.9.5
129
Alle Geometrie–ÜEs in einer Bibliothek versammeln
Das Quellverzeichnis wird jetzt mit einer Make–Datei ausgestattet, welche die Bibliothek erzeugt bzw. aktuell hält:
LIBDIR=..Pfad zum Bibliotheksverzeichnis..
INCDIR=..Inklusionsverzeichnis..
CC=g++
CFLAGS=-Wall -pedantic
AR=ar
ARFLAGS=r
${LIBDIR}/libgeo.a : Gerade.o Punkt.o
${AR} ${ARFLAGS} $@ $?
-ranlib $@
Vektor.o
Gerade.o: Gerade.cc Gerade.h Punkt.h Vektor.h
${CC} -I${INCDIR} -c Gerade.cc
Punkt.o: Punkt.cc Punkt.h Vektor.h
${CC} -I${INCDIR} -c Punkt.cc
Vektor.o: Vektor.cc Vektor.h
${CC} -I${INCDIR} -c Vektor.cc
4.9.6
Anwendung
Das Hauptprogramm
#include <Vektor.h>
#include <Punkt.h>
#include <Gerade.h>
int main (){
Gerade g1, g2;
...
}
kann dann mit dem Kommando:
g++ -I..Pfad-zum-Inc-Verz.. -L..Pfad-zum-Bib-Verz.. main.cc -lgeo
übersetzt und gebunden werden.
Programmierung II
130
4.10
Übungen
Aufgabe 1
1. Erläutern Sie mit welchem Mechanismus Zugriffsfehler durch “const” entdeckt werden.
2. Das Betriebssystem schützt sich und andere vor unberechtigten Speicherzugriffen der Programme. Warum
gibt es in C++ dann noch ein const?
3. Wenn alle Programmiersprachen das const–Konstrukt von C++ hätten, müsste das Betriebssystem noch
unberechtigte Speicherzugriffe der Programme abfangen?
4. Schreiben Sie ein Programm das die Summe seiner Kommandozeilenargumente berechnet.
5. Schreiben Sie ein Programm das, wenn es mit dem Namen min aufgerufen wird, das Miniumum seiner
beiden int–Argumente berechnet und, wenn es den Namen max trägt, das Maximun berechnet:
>
>
>
>
>
>
g++
min
3
g++
max
4
-o min minmax.cc
3 4
-o max minmax.cc
3 4
6. Welchem Zweck dienen Inklusionswächter?
7. Was versteht man unter “externe Bindung”?
8. Funktionen haben externe Bindung. Was bedeutet das konkret? Was wäre beispielsweise anders, wenn Funktionen interne Bindung hätten? Geben Sie jeweils ein Beispiel an, für etwas das bei externer Bindung möglich
und bei interner Bindung unmöglich ist und umgekehrt.
9. Schreibt man Inline–Definitionen von freien Funktionen und Methoden in H– oder in C–Dateien?
10. Was ist falsch an:
class C {
public:
C ();
private:
static int x;
};
C::C() : x(5) {}
Teilen Sie Ihre korrigierte Version auf eine C– und eine H–Datei auf.
11. Zwei Quelldateien a.cc und b.cc werden übersetzt und gebunden:
a.cc:
#include <iostream>
float f();
typedef char X;
const X a = ’a’;
int main () {
std::cout<<f()<<std::endl;
}
b.cc:
#include <iostream>
typedef float X;
const X a = 0.0;
float f () {
std::cout<<a<<std::endl;
return a;
}
Ist das Programm korrekt? Wenn ja: Welche Ausgabe erzeugt es? Wenn nein: was ist falsch?
Was ändert sich wenn const in beiden Dateien weggelassen wird?
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
131
Aufgabe 2
1. Teilen Sie folgendes Programm in zwei Übersetzungseinheiten auf (Klasse B, main–Funktion). Beachten
Sie dabei die Konventionen zur Gestaltung von Übersetzungseinheiten.
#include <iostream>
using namespace std;
class B {
friend int
public:
B();
˜B();
int f();
static int
private:
int
static int
const int
};
main();
g ();
x;
y;
z;
int B::y = 0;
B::B() : x(0), z(y) { ++y; }
B::˜B() { --y; }
int B::f() { ++x; return x+z; }
int B::g() { return y; }
int main () {
cout << B::g() << endl;
B b1;
cout << B::y << endl;
B b2;
if ( b1.f() + b2.f() > 2) {
B b3;
cout << B::g() << endl;
}
cout << B::g() << endl;
}
2. Schreiben Sie eine Make–Datei zur Erzeugung eines ausführbaren Programms. Zur Übersetzung jeder Einheit und zum Binden soll diese jeweils eine Regel enthalten.
Aufgabe 3
1. Realsieren Sie das Beispiel der geometrischen Objekte mit Vektoren, Punkten und Geraden mit getrennter
Übersetzung, einer Objektbibliothek und einer Make–Datei. Jede Klasse soll dabei zu einer Übersetzungseinheit mit jeweils einer H–Datei und einer C–Datei werden. Die Objektdateien der Übersetzungseinheiten
werden in der Bibliothek abgelegt.
2. Schreiben Sie ein Hauptprogramm – als weitere Übersetzungseinheit – zum Test Ihrer Bibliothek.
Programmierung II
132
5
5.1
5.1.1
Datenstrukturen
Verkettete Liste
Listen
Listen sind Kollektionen von sequenziell angeordneten Elementen. Eine Liste kann ihren Benutzern eine Vielzahl
unterschiedlicher Operationen anbieten. Typisch für Listen ist jedoch stets, dass
die Liste nicht auf eine bestimmte Größe festgelegt ist und Elemente nach Belieben eingefügt und herausgenommen werden können, sowie dass
zum Zugriff auf ein Listenelement die Liste vom Anfang an durchlaufen werden muss. (Im Gegensatz zu
Feldern gibt es keinen “Listen–Index”)
Das sind einige typische Kriterien. Sie treffen meist, aber nicht immer zu. Der Begriff “Liste” ist so weit gefasst,
dass eine Vielzahl an Varianten möglich ist.
5.1.2
Verkettete Listen
Das Konzept “Verkettete Liste” bezieht sich auf die Implementierung einer Liste in Form von einzelnen Knoten,
die über Zeiger miteinander verbunden sind. Listen werden sehr oft als verkettete Listen realisiert, man sollte
aber trotzdem das Konzept der Liste von der Art seiner Implementierung trennen. Andere Implementierungen
sind möglich und werden gelegentlich auch angetroffen. Beispielsweise werden Zeichenketten, also Listen von
Zeichen, meist in Feldern gespeichert.
Verkettete Listen können in unterschiedlicher Art realisiert werden:
Einfach verkettete Listen: Bei ihnen enthält jeder Knoten neben dem Listenelement einen Verweis auf den
Nachfolgerknoten.
Doppelt verkettete Listen: Jeder Knoten beinhaltet einen Verweis auf den Nachfolger und den Vorgänger.
Eine weitere Variationsmöglichkeit besteht darin, für die Liste insgesamt entweder nur einen Verweis auf das erste
Element zu führen, oder sowohl auf das erste als auch auch das letzte Element zu verweisen. (Machen Sie sich die
Unterschiede an Hand kleiner Skizzen klar !)
5.1.3
Listenvarianten
Neben den unterschiedlichen Arten der Listenimplementierung gibt es auch Unterschiede in der prinzipiellen Arbeitsweise von Listen. Einige wichtige Funktions–Varianten sind:
Sortierte Liste,
Stapel (engl. Stack)
Warteschlange (engl. Queue)
Sortierte Listen speichern ihre Elemente in sortierter Form. Jede Einfügeoperation platziert ihr Argument an der
richtigen Stelle.
Stapel sind Listen, mit einem sehr eingeschränkten Satz an Operationen: Elemente können nur an einem Ende
angefügt bzw. entnommen werden.
Warteschlangen sind Listen, bei denen Elemente an einem Ende angefügt am anderen entnommen werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
5.1.4
133
Beispiel: verkettete Liste
Eine Klassendefinition für eine einfach verkettete, unsortierte Liste mit einem Verweis nur auf das Startelement
ist:
class List {
public:
List ();
˜List ();
void insert
(int i);
void erase
(int i);
void eraseAll (int i);
int isIn
(int i);
private:
class Node;
// Deklaration geschachtelte Klasse
Node *head;
void erase_r (int i, Node * &);
};
class List::Node { // geschachtelte Klasse
public:
Node ();
Node (int, Node *);
˜Node ();
int v;
Node * next;
};
Die hier definierte Liste speichert Elemente vom Typ int. Die Methode insert fügt ein Element ein, erase
(i) löscht das erste Element das gleich i ist, eraseAll (i) löscht alle Elemente gleich i und isIn (i)
stellt fest wie oft i in der Liste vorkommt.
Die Liste wird mit Hilfe von zwei Klassen definiert. List ist der Typ des Listenanfangs. Die Listenelemente
werden in Objekten der Klasse Node gespeichert. (Siehe Abbildung 49)
List
Node
Node
Node *
int
Node*
int
Node*
head
v
next
v
next
...
Stack
Heap
Abbildung 49: Struktur einer Liste mit zwei Klassen
Objekte der Klasse List stellen die “Listenvariablen” dar. Sie liegen in der Regel im Stack. Objekte der Klasse
Node liegen im Heap.
5.1.5
Geschachtelte Klassen
Die Klasse List besteht neben den Methodendefinitionen aus einem Verweis head auf die verketteten Listenelemente vom Typ Node. (erase r ist eine Hilfsroutine.)
Die Klasse Node ist eine reine Hilfsklasse. Die Benutzer der Liste brauchen diesen Typ nicht zu kennen. Node
wurde darum innerhalb des privaten Teils von List definiert. Es ist eine geschachtelte Klasse (engl.: nested class).
Programmierung II
134
Die Deklaration class Node; innerhalb von List kann auch durch die gesamte Klassendefinition ersetzt werden. Das macht die Schachtelung noch deutlicher:
class List {
public:
List ();
˜List ();
void insert
(int i);
void erase
(int i);
void eraseAll (int i);
int isIn
(int i);
private:
class Node { // geschachtelte Klasse
public:
Node ();
Node (int, Node *);
˜Node ();
int v;
Node * next;
};
Node *head;
void erase_r (int i, Node * &);
};
5.1.6
Implementierung der verketteten Liste
Die Implementierung der Methoden ist:
List::Node::Node ()
: v(0), next(0){}
List::Node::Node (int i, Node * n) : v(i), next(n){}
List::Node::˜Node () { delete next; }
List::List () : head (0) {}
List::˜List () { delete head; }
void List::insert (int i) {
head = new Node (i, head);
}
int List::isIn (int i) {
int res = 0;
for(Node * p = head; p != 0; p = p->next)
if (p->v == i) res++;
return res;
}
void List::eraseAll (int i) {
Node *p, *prev, *dead = 0;
p = head, prev = 0;
while (p != 0) {
if (p->v == i) {
dead = p;
if (p == head)
head = head->next;
else
prev->next = p->next;
p = p->next;
dead->next = 0;
delete dead;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
135
} else {
prev = p;
p = p->next;
}
}
}
void List::erase_r (int i, Node * &l) {
if (l != 0)
if (l->v == i) {
Node * dead = l;
l = l->next;
dead->next = 0;
delete dead;
} else
erase_r (i, l->next);
}
void List::erase (int i) {
erase_r (i, head);
}
An der Hilfsmethode erase r sieht man, dass ein einzelnes Element sehr elegant mit einer rekursiven Funktion
gelöscht werden kann. Man beachte dass der zweite Parameter von erase r ein Referenzparameter ist.
5.1.7
Flache Listenkopie
Eine Liste l 1 kann nicht einfach in eine Liste l 2 kopiert werden, indem man die Datenfelder von l 1 in die
entsprechenden von l 2 kopiert. In unserem Beispiel würde so nur l 1.head nach l 1.head kopiert. Beide
Listen würden auf die gleichen Knoten zeigen, mit dem Effekt, dass jede Manipulation der einen sich auf die
andere auswirkt. Diesen Effekt einer Kopieroperation wird man sich wohl kaum wünschen. Nach der Zuweisung
l 1 = l 2 teilen sich beide Listen die gleichen Knoten. Die alten Knoten von l 2 bleiben als Speichermüll
zurück und jede Veränderung der einen wirkt auch auf die andere Liste.(Siehe Abbildung 50):
l_1
1
2
3
11
12
0
gemeinsame Knoten
l_2
10
13
0
Speichermüll
Abbildung 50: Zwei Listen nach Zuweisung mit flacher Kopie
5.1.8
Tiefe Listenkopie
Es reicht also nicht die Liste nur in dieser Art flach zu kopieren. Die Kopie muss tief sein: Alle Knoten der Liste
müssen kopiert werden und nicht nur das Listenobjekt mit dem Zeiger auf den Anfang.
Der vordefinierte Kopierkonstruktor kopiert stets nur flach. Sind Zeiger im Spiel, muss überlegt werden, was genau
unter einer “Kopie” zu verstehen ist. Wenn das flache Kopieren nicht die der Anwendung angemessene Definition
von “Kopieren” ist, dann muss ein eigener Kopierkonstruktor definiert werden. Mit dem eigenen Kopierkonstruktor
wird stets auch ein eigener Zuweisungsoperator fällig. (Siehe Abbildung 51).
Programmierung II
136
class List {
public:
...
List (const List &);
List & operator= (const List &);
...
};
// Kopierkonstruktor
// Zuweisungsoperator
// Kopierkonstruktor: initialisiere mich
//
mit einer tiefen Kopie von p_l
List::List (const List &p_l) {
head = 0;
//kein delete, es gibt nichts altes !
Node **l = &head, //hier muss der Verweis auf den Neuen eingesetzt werden
*n;
//Verweis auf den Neuen
for(Node * p = p_l.head; p != 0; p = p->next){
n = new Node (p->v, 0);
*l = n;
l = &(n->next);
}
}
// Zuweisungsoperator: entsprechend dem Kopierkonstruktor
//
List & List::operator= (const List &p_l) {
if (head != p_l.head) { // keine Zuweisung an sich selbst
delete head;
// hier delete: weg mit dem alten !
head = 0;
Node **l = &head, *n; // Kopieren wie oben
for(Node * p = p_l.head; p != 0; p = p->next){
n = new Node (p->v, 0);
*l = n;
l = &(n->next);
}
}
return *this;
// liefert Referenz auf sich selbst
}
l_1
1
2
3
0
1
2
3
0
delete
l_2
10
delete
11
12
delete
13
delete
0
Abbildung 51: Zwei Listen nach Zuweisung mit tiefer Kopie
Kopier–Konstruktor und Zuweisungsoperator sind für Kopieraktionen zuständig. Sie sind sich darum auch sehr
ähnlich. Die geringen Unterschiede rühren daher, dass zum einen der Zuweisungsoperator einen alten Wert vorfindet, der weggeräumt werden muss, und dass er zum anderen einen Wert liefern muss. Die Zuweisung einer Variable
an sich selbst (l=l;) funktioniert mit dem Kopieralgorithmus nicht. Sie muss darum abgefangen werden. Glücklicherweise ist Nichtstun eine korrekte Implementierung einer Zuweisung an sich selbst.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
5.2
5.2.1
137
Menge als konkreter Datentyp
Mengen implementiert als verkettete Listen
Verkettete Strukturen im Allgemeinen und Listen im Besonderen werden verwendet, wenn eine unbekannte oder
variable Zahl von Dingen zu verwalten ist. Mengen von ganzen Zahlen können beispielsweise in Form einer
verketteten Liste geführt werden. Man muss dabei lediglich darauf achten, dass kein Element zweimal in der
Liste vorkommt. Die Menge stellt damit eine weitere Variante der Liste dar.
5.2.2
Eine Menge nicht objektorientiert
In der klassischen – Objekt–freien – Programmierung hat eine Menge in etwa folgendes Aussehen:
class Knoten {
public:
int
wert;
Knoten *naechster;
};
...
void fuegEin (Knoten *m, int w) {
.. einen neuen Knoten mit Inhalt w
.. in die Knotenliste einfuegen, falls
.. w noch nicht enthalten ist
}
void vereinigeMit (Knoten *m1, Knoten *m2) {
.. jedes Element von m2 in die
.. Knoten-Liste m1 einfuegen, falls
.. es dort noch nicht enthalten ist
}
...
Knoten *menge1, *menge2, *menge3;
menge1 = menge2 = menge3 = 0;
...
fuegeEin (menge1, 2);
...
vereingeMit (menge1, menge2);
...
5.2.3
Eine Menge einfach objektorientiert
Die Klassen der objekt–orientierten Programmierung bieten zunächst einmal das Konzept der Kapselung mit der
die Schnittstelle der Menge von der Implementierung getrennt wird:
class Menge {
public:
void leere;
void fuegEin (int w);
void vereinigeMit (const Menge &m2);
...
private
class Knoten {...};
Knoten *anfang;
};
...
void Menge::fuegEin
(int w)
{...}
Programmierung II
138
void Menge::vereinigeMit (const Menge &m2) {...}
...
Menge menge1, menge2, menge3;
menge1.leere();
menge2.leere();
menge3.leere();
...
menge1.fuegeEin (2);
...
menge1.vereinigeMit (menge2);
...
Hier wird in erster Linie der Code des Programms so umorganisiert, dass alles, was zur Menge gehört, zusammengefasst und über eine definierte Schnittstelle der Anwendung zugänglich gemacht wird. Es entsteht eine variablenorientierte Klasse.
5.2.4
Die Menge als konkreter Datentyp
Sollen Mengen genau so einfach und flexibel wie vordefinierte Datentypen verwendet werden, dann sind weitere
Sprachkonstrukte und etwas Arbeit nötig. Als erstes stellen wir fest, dass Mengen vom intuitiven Konzept her
wertartig zu verstehen sind. Das legt im Gegensatz zu oben eine Verwendung nach folgendem Muster nahe:
class Menge { .. };
...
Menge menge1, menge2, menge3; //gleich richtig initialisiert
...
menge1 = menge1 + Menge(2); // statt menge1.fuegeEin (2);
...
menge1 = menge1 + menge2;
// statt menge1.vereinigeMit (menge2);
...
Die Vereinigung wird mit dem Operator “+” bezeichnet und sollte natürlich seiteneffektfrei sein. Seiteneffektfrei
bedeutet, dass die Vereinigung ihre Operanden nicht verändert. Eine offensichtliche Forderung: Wie in der Mathematik soll die Vereinigung zweier Mengen eine dritte erzeugen und dabei die beiden Argumente in Frieden lassen.
Mengen sind Werte durch eine wertartige Klasse zu implementieren.
Damit eine wertartige Klasse nach dem Muster vordefinierter Datentypen verwendet werden kann, muss sie als
konkreter Datentyp implementiert werden, d.h. sie muss mindestens einen Defaultkonstruktor, einen Kopierkonstruktor und den Zuweisungsoperator zur Verfügung stellen. Insgesamt benötigen wir bei der Menge als konkretem
Datentyp:
Defaultkonstruktor
Mit ihm wird eine korrekt initialisierte leere Menge erzeugt.
Kopierkonstruktor
Er wird bei der Wertübergabe gebraucht und muss selbst definiert werden, wenn die Klasse Zeiger auf Hilfsobjekte (in unserem Fall die Knoten) enthält.
Zuweisungsoperator
Wenn der Kopierkonstruktor selbst definiert werden muss, dann gilt das auch für den Zuweisungsoperator.
Hier passiert ja praktisch das Gleiche.
Weitere von der speziellen Klasse abhängige Konstruktoren und Operatoren
Operatoren wie “+” (Vereinigung) und “*” (Schnitt), sowie ein Konstruktor, der eine Menge aus einem
Element erzeugen, sind sicher für jede Anwendung der mengen nützlich..
Destruktor
Ein Destruktor ist immer dann notwendig, wenn die Objekte Zeiger auf Hilfsobjekte enthalten, die sich im
Heap befinden und damit explizit weggeräumt werden müssen. Das ist bei den Mengen der Fall.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
139
Mit diesen Komponenten kommt man dann etwa zu folgender Klassendefinition für die Menge:
class Menge {
public:
Menge ();
//
Menge (int);
//
Menge (const Menge &); //
˜Menge ();
//
Menge & operator=
Menge
operator+
Menge
operator*
bool
istEnthalten
leere Menge
einelementige Menge
Kopierkonstruktor
Destruktor
(const Menge &);
// Zuweisung
(const Menge &) const; // Vereinigung
(const Menge &) const; // Schnitt
(int i) const;
private:
class Knoten {
public:
Knoten
();
Knoten
(int, Knoten *);
˜Knoten ();
int
wert;
Knoten * n;
};
Knoten *anfang; // Start der verketteten Knoten
... eventuelle Hilfsroutinen ...
};
5.3
5.3.1
Wiederverwendung: Die Liste als allgemeine Behälterklasse
Menge als Variante oder Benutzer einer Liste
Im Beispiel oben ist die Menge eine Variante der Liste. Die Mengenoperationen werden als spezielle Listenoperationen realisiert. Einfügen vermeidet Duplikate, Vereinigung und Schnitt sind Varianten der Verknüpfung von
Listen. Die Mengenimplementierung benutzt zwar auf konzeptioneller Ebene die Idee der “Liste”, aber es wird
keine bestimmte konkrete Liste benutzt (siehe Abb. 52).
einfuegen
Liste
loeschen
Modifizieren, Anpassen
vereinige
Menge
schneide
Abbildung 52: Wiederverwendung der Konzepte: Menge als Variante der Liste
So einfach und naheliegend sie ist, die Wiederverwendung der Konzepte hat auch Nachteile: Zunächst einmal muss
der Quellcode verdoppelt werden. Neben der Liste gibt es eine modifizierte Liste als Menge. Alle Erweiterungen
Programmierung II
140
und Modifikationen in der einen Variante müssen in der anderen nachgezogen werden. Die Gefahr ist groß, dass
dabei Fehler passieren und beide im Laufe der Zeit auseinanderlaufen. Besser wäre es eine einzige Liste zu pflegen,
die von allen “listenartigen Anwendungen” benutzt wird.
Bei der echten Benutzung einer realen Liste durch die Menge enthält jedes Mengenobjekt eine komplette Liste.
Eine Liste, jetzt nicht als Idee, sondern eine “reale” vollständige Liste, komplett mit Schnittstelle und Implementierung, taucht in der Menge auf (siehe Abb. 53 und als UML–Diagramm 54):
class Liste {...};
class Menge {
public:
Menge ();
... etc. Mengenschnittstelle wie oben ...
private:
Liste l;
...
};
// Die Liste als Komponente einer Menge
Liste
einfuegen
loeschen
Als Komponente benutzen !
vereinige
schneide
Menge
Liste
einfuegen
loeschen
Abbildung 53: Wiederverwendung durch Benutzung: Menge als Benutzer der Liste
Menge
vereinige
schneide
Liste
einfuegen
loeschen
Abbildung 54: Menge als Benutzer der Liste in UML
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
5.3.2
141
Klassenbenutzung als Wiederverwendung
Der Übergang vor der Menge als Listenvariante zur Menge, die eine Liste benutzt, sieht zunächst recht unschuldig aus. Tatsächlich repräsentiert er aber einen weiteren wesentlichen Schritt in der Art Software zu erstellen.
Ein Schritt der mindestens dem Übergang von der einfach gekapselten Menge zum konkreten Datentyp Menge
vergleichbar ist.
Zunächst einmal ist es wohl sehr vernünftig, eine bereits vorliegende Listenklasse bei der Implementierung der
Menge zu verwenden. Die Unterschiede sind gering, der Nutzen der Wiederverwendung ist hoch und kann scheinbar mit wenig Aufwand erreicht werden.
Versucht man aber tatsächlich eine bestehende Listenklasse in der Mengenklasse zu verwenden, dann stellt man
schnell fest, dass die Schnittstelle der Liste kaum bis gar nicht geeignet ist, die für die Menge notwendigen Dinge
zu erledigen. Das Problem verschärft sich, wenn die Liste nicht nur für eine bestimmte Klasse wie die Menge,
sondern für viele, jetzt eventuell noch unbekannte Anwendungen, ohne Modifikationen verwendet werden soll.
Die Konstruktion einer wiederverwendbaren Liste lohnt sich aber nur, wenn sie oft und vielseitig wiederverwendet
wird.
Die Objektorientierung ist ursprünglich auch mit dem Anspruch aufgetreten, dass sie die Erstellung wiederverwendbarer Software–Komponenten möglich und lohnend macht. Heute nach ca. 15 Jahren Objektorientierung
geht man allgemein davon aus, dass dieser Anspruch – zumindest in ersten Ansätzen – eingelöst werden kann 16.
5.3.3
Behälterklassen
Behälterklassen (engl. Container Classes) sind universell einsetzbare Klassen, deren Objekte, die Behälter (engl.
Container), dazu dienen andere Objekte aufzunehmen und zu verwalten. Stapel, Listen, Warteschlangen etc.
sind Behälter. Behälter sind dadurch charakterisiert, dass ihre Methoden gar nicht oder nur unwesentlich davon
abhängen, welche Objekte in ihnen gespeichert werden.
Behälterklassen sind das erste und einfachste Beispiel für Wiederverwendbarkeit. Auch wenn für die gewünschte
Flexibilität die Klassen als Schablonen (Templates) formuliert werden sollten, führen wir hier die Liste zunächst
einmal als normale (wiederverwendbare Behälter–) Klasse ein. Später werden wir uns mit Templates beschäftigen.
Listen und Behälterklassen ganz allgemein sind Bestandteil der Standard Template Library (kurz STL), die heute zu jeder vollständigen C++–Implementierung gehören muss. Einige einfache Varianten einer Behälterklasse
implementiert zu haben, hilft beim Verständnis der Bibliothekskomponenten.
5.3.4
Eine allgemein verwendbare Liste
Eine allgemein verwendbare Liste muss sicher die Möglichkeit bieten, Elemente am Anfang und am Ende anzufügen. Ebenso wird wohl die eine oder andere Listenanwendung nach einem bestimmten Element suchen, oder
auch Elemente mitten in die Liste einfügen wollen. Bei der Implementierung der Menge wird eine Einfügoperation benötigt, die Duplikate vermeidet. Die Vereinigung ist eine Verkettung, bei der Duplikate eliminiert und der
Schnitt eine Verkettung, bei der nur Duplikate berücksichtigt werden.
Insgesamt wird eine wiederverwendbare Liste sicher wesentlich reichhaltiger sein als eine auf einen speziellen
Einsatz zugeschnittene Version. Es ist aber völlig unmöglich, alle Sonderfälle und Varianten an notwendigen Listenoperationen vorherzusehen. Darum wird eine allgemeine und mächtige Operation benötigt, mit der sich im
Zweifel alles realisieren lässt. Die allgemeinste Operation auf einer Liste besteht darin, sie zu durchlaufen und an
jeder beliebigen Stelle nach Belieben zu manipulieren.
5.3.5
Liste mit aktueller Position
Um eine Liste sytematisch zu durchlaufen, muss man entweder Zugriff auf ihre “Innereien” haben (was aber
softwaretechnisch nicht empfehlenswert ist, vergl. Abb. 55), oder sie muss eine Schnittstelle bieten, über die ihre
16 Hier wird sich in den kommenden Jahren noch einiges tun. Das Komponentengeschäft ist zwar seit vielen Jahren Thema und beginnt jetzt
zur Realität zu werden. An der Software–Entwicklung ernsthaft Interessierte tun gut daran, es im Auge zu behalten.
Programmierung II
142
Benutzer auf ein Element nach dem anderen zugreifen können.
Finger Weg von meinen Listenelementen !
Listenbenutzer
Liste
Abbildung 55: Privatsphäre der Liste wird nicht beachtet.
Den dazu notwendigen Mechanismus gibt es in drei Varianten:
Als Argument des Zugriffs:
“Gib mir das zweite Element!”
Als Liste mit verschiebbarer aktueller Position:
“Gehe an den Anfang, gehe zum Nächsten!”
Als “Iterator”, bei dem die eine aktuelle Position von der Liste losgelöst und in ein eigenes Objekt gepackt
wird.
5.4
5.4.1
Cursorkonzept: Liste mit aktueller Position
Position als Argument des Listenzugriffs
Wenn der Listenbenutzer auf alle Listenelemente zugreifen will, dann kann er die Liste einfach bitten ihm das –te
Element zu liefern, bzw. es zu verändern. Eine entsprechende Liste ist schnell skizziert:
class Liste {
public:
...
int anzahl
()
int gibElement
(int nr)
void setzeElement (int nr,
...
private:
...
};
...
Liste l;
...
for (int i; i <= l.anzahl();
if (l.gibElement(i) > 10)
l.setzeElement(i, 10);
const;
const;
int wert);
++i)
Die Methode anzahl liefert die Zahl der Listenelemente, gibElement(nr) liefert den Wert des Listenelements mit der Nummer und setze(nr,wert) setzt es auf einen bestimmten Wert.
Mit Referenzergebnissen kann das noch etwas eleganter formuliert werden. Statt einer Lese– und einer Schreibmethode:
int gibElement (int nr) const;
void setzeElement (int nr, int wert);
nehmen wir eine mit der beides – Lesen und Schreiben – möglich ist:
int & element (int nr);
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
143
class Liste {
public:
...
int
anzahl
()
const;
int & element
(int nr);
...
private:
...
};
...
Liste l;
...
// Liste durchlaufen:
for (int i; i <= l.anzahl(); ++i)
if (l.element(i) > 10)
l.element(i) = 10;
Die Implementierung dieses Elementzugriffs ist einfach und wird – ohne die Berücksichtigung von Fehlbedienungen – etwa wie folgt aussehen:
int & Liste::element(int x) {
Knoten p = anfang;
int
i = 1;
while (i != x) {
p = p->n;
++i;
}
return p->v;
}
5.4.2
Position als Zustand der Liste
Bei dieser Art von positionsweisem Zugriff muss die Liste bei jedem Zugriff neu von vorne durchlaufen werden.
Der Aufruf element (100) durchläuft die Liste vom ersten bis zum hundertsten Element. element (101)
ist nur einen Schritt weiter, aber die Liste muss wieder vom Anfang bis zum hundertersten Element durchlaufen
werden.
Elemente
aktElement
Behälterklasse
geheWeiter
aktPos
aktElement
geheWeiter
Abbildung 56: Behälterklasse mit Position als Zustand
Man müsste an der aktuellen Position stehen bleiben und später dort weitermachen können. Dazu muss die Liste
sich die aktuelle Position merken:
class Liste {
public:
...
void geheAnfang ();
// erstes Element ist das aktuelle
Programmierung II
144
void geheWeiter (); //
int & aktElement (); //
bool amEnde ();
//
...
private:
Knoten * anfang;
Knoten * aktPos;
};
...
Liste l;
...
// Liste durchlaufen:
l.geheAnfang ();
while ( !l.amEnde() ) {
if (l.aktElement() > 10)
l.geheWeiter();
}
Position verschieben
Zugriff auf das aktuelle Element
Listenende erreicht ?
l.aktElement() = 10;
Diese Liste hat eine aktuelle Position als “Zustand”. Zwei Listen sind jetzt nicht mehr unbedingt gleich, wenn
sie die gleichen Elemente in der gleichen Reihenfolge enthalten. Sie können mit der aktuellen Position – ihrem
“Cursor” – in unterschiedlichen Zuständen sein. Diese Art von Liste ist nicht mehr wertorientiert sondern variablenorientiert (vergl. Abb. 56).
5.5
5.5.1
Iteratoren
Problem der Position als Zustand
Wenn die aktuelle Position ein Bestandteil der Liste ist, dann kann man keine zwei Positionen gleichzeitig in der
Liste haben. Damit ist bereits schon ein einfaches Sortieren unmöglich: Zwei geschachtelte Schleifen und damit
zwei Positionen gleichzeitig müssen dabei ja über die Liste laufen.
void sort (Liste l]) {
l.geheAnfang ();
while ( !l.amEnde() ) {
..?? zweite innere Schleife
..?? auf der Liste ist nicht moeglich
..?? da es keine zwei aktuellen Positionen gibt
while ..??
..?? if (l.aktPos-1() > l.aktPos-2())
..?? swap (l.aktPos-1(), l.aktPos-2());
}
Als Ausweg könnte man die Liste mit zwei, drei oder auch 10 Positionen versehen. Ein besseres Konzept besteht
darin, die aktuelle Position mitsamt den Zugriffs– und Verschiebeoperationen aus der Liste herauszulösen und in
eine eigene Klasse zu packen. Der Listenbenutzer kann dann soviele aktuelle Positionen erzeugen, wie er braucht.
Die aus der Liste – oder allgemein aus der Behälterklasse – herausgelöste aktuelle Position ist ein Iterator (vergl.
Abb. 57).
class Liste {
friend class Iterator;
public:
...
Iterator anfang ();
Iterator ende ();
...
private:
...
// Iterator, der auf den Anfang zeigt
// Iterator, der hinter das Ende zeigt
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
145
Elemente
Liste mit
aktueller Position
aktElement
geheWeiter
aktPos
Elemente
Liste ohne
aktuelle Position
aktElement
geheWeiter
aktPos
Iterator
Abbildung 57: Von der Liste mit Position zum Iterator: aktuelle Position in einem eigenen Objekt
};
class Iterator {
friend class Liste;
public:
void geheWeiter (); // Position verschieben
int & aktElement (); // Zugriff auf das aktuelle Element
private:
Knoten * anfang;
Knoten * aktPos;
}
...
Liste l;
...
// Liste durchlaufen:
Iterator i=l.anfang ();
while ( i != l.ende() ) {
if (i.aktElement() > 10) i.aktElement() = 10;
i.geheWeiter();
}
Die Methode anfang und ende der Liste erzeugen Iteratoren. Mit anfang wird der Iterator erzeugt, der auf das
erste Listenelement zeigt:
Iterator Liste::anfang() {
Iterator res;
res.aktPos = anfang;
return res;
}
Iterator Liste::ende() {
Iterator res;
res.aktPos = 0;
return res;
}
Das geht etwas kompakter, wenn die Klasse Iterator mit geeigneten Konstruktoren versehen wird:
Iterator Liste::anfang() {
return Iterator (anfang);
}
Iterator Liste::ende() {
return Iterator (0);
}
Programmierung II
146
Jetzt kann jeder Benutzer der Liste soviele Positionen erzeugen wie er benötigt. Z.B.:
Liste l;
...
Iterator i_1 = l.anfang ();
while ( i_1 != l.ende() ) {
Iterator i_2 = i_1;
while ( i_2 != l.ende() ) {
if (i_1.aktElement() > i_2.aktElement())
swap (i_1.aktElement(), i_2.aktElement())
i_1.geheWeiter();
}
i_2.geheWeiter();
}
5.5.2
Iteratoren mit
operator*() und operator++() Ein Iterator ist ein “abstrakter Zeiger” der in eine Behälterklasse, wie
beispielsweise eine Liste, zeigt. Mit einem Iterator können die Elemente des Behälters systematisch durchlaufen werden, ohne dass die Anwendung die Implementierung des Behälters kennen muss. Der Iterator einer Liste
beispielsweise versteckt die konkrete Implementierung der Liste: einfach, oder mehrfach verkettet, Zahl, Namen,
Typ der Komponenten eines Knotens, etc. Er bietet aber gleichzeitig wie ein Zeiger den vollen Zugriff auf die
Listenelemente (vergl. Abb. 58).
Ersetzt man die Methode
aktElement() durch operator*() und
geheWeiter() durch operator++()
dann wird das Wesen des Iterators als abstrakten Zeiger noch deutlicher:
Liste l;
...
// Alle Listenenlemente von l inkrementieren,
// ohne die Listenimlementierung zu kennen
//
for (ListenIterator i = l.anfang(); i != l.ende(); ++i)
++(*i); // <==> *i = *i+1;
Elemente
Behälterklasse
*i
...
++i
...
Iterator
i
Abbildung 58: Iteratorkonzept
Hier wird die Liste l mit dem Iterator i durchlaufen und dabei jedes Listenelement um eins erhöht. Der Ausdruck
++(*i) inkrementiert ein Listenelement mit dem vordefinierten (normalen) ++–Operator auf Int–werten. ++i im
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
147
Kopf der Schleife dagegen ist das selbstdefinierte “Weitergehen in der Liste” als Inkrementieren des Iterators. i ist
der Iterator,
*i (= i.aktElement()) ist ein Listenelement,
++(*i) erhöht ein Listenelement um eins, und
++i (= i.geheWeiter()) geht zum nächsten Listenelement über.
Alle Zugriffe sind nabhängig davon, wie die Liste implementiert ist.
5.5.3
Iteratordefinition
Da i ein Iterator und nicht einfach ein Zeiger ist, müssen die Dereferenzierung (operator*) und der Inkrement–
Operator (operator++) umdefiniert werden. Dabei wird natürlich die reale Struktur der Listen–Knoten berücksichtigt (vergl. Abb. 59):
Liste
erster
...
l
l
Iterator
++i
pos
i
*i
Abbildung 59: Iteratordefinition
class ListenIterator {
friend class Liste;
public:
...
int &
operator* ();
ListenIterator & operator++ ();
...
private:
Liste &l;
// Die Liste
Liste::Knoten *pos; // aktueller Knoten (aktuelle Position)
};
// in dieser Liste
class Liste {
friend class ListenIterator;
...
ListenIterator anfang ();
ListenIterator ende
();
...
private:
class Knorten { ... };
Knoten * erster;
...
};
Programmierung II
148
Der Iterator wurde hier erweitert. Neben der aktuellen Position enthält er noch einen Verweis auf die Liste selbst.
Damit kann der Bezug zur Liste überwacht werden. Eine besondere inhaltliche Bedeutung hat dieser Zeiger nicht.
Die überladenen Operatoren “*” und “++” sind:
int & ListenIterator::operator* () {
return pos->wert;
}
ListenIterator & ListenIterator::operator++ () {
if (pos != 0)
pos = pos->n;
return *this;
}
Und die Erweiterungen der Liste:
ListenIterator Liste::anfang () {
return ListenIterator (*this, erster);
}
ListenIterator Liste::ende () {
return ListenIterator (*this, 0);
}
this ist der Zeiger der in jeder Methode auf das aktuelle Objekt zeigt. Mit
*this
übergeben wir in beiden Methoden die Liste selbst an den Konstruktor des Iterators. erster ist der Zeiger auf
den ersten Knoten der Liste.
Ein Iterator enthält einen Verweis auf den “aktuellen” Knoten der Liste. Mit dem operator* kann lesend und
schreibend (Referenz als Ergebnis!) auf die wert–Komponente des aktuellen Knotens zugegriffen werden.
Der operator++ geht in der Liste weiter zum nächsten Knoten. Wir benutzen den Prefix–Operator ++i weil
er i zuerst inkrementiert und dann den neuen Wert liefert. Das ist einfacher als zuerst den Wert feststellen, i
inkrementieren und dann den alten Wert liefern, wie es die Semantik des Postfix–Operators i++ verlangt. Wenn
wie hier der gelieferte Wert ignoriert wird, sollte man immer die einfachere Variante des Inkrementierens, die
Prefix–Operation, wählen.
Der Sinn des Iterators gegenüber dem Hantieren mit “rohen” Zeigern besteht darin, dass Struktur und Verkettung
der Knoten vollständig vor der jeweiligen Anwendung verborgen bleiben, trotzdem aber ein Zugriff auf die Werte
aller Listenelemente möglich ist.
Beim lesenden Durchlaufen einer Liste mag man den Vorteil der versteckten Innereien eventuell noch nicht sofort
einsehen. Bei modifizierenden Listenoperationen sieht das aber schon anders aus.
5.5.4
Listenveränderung
Jeder der schon einmal Elemente in eine doppelt verkettete Liste eingefügt oder aus ihr entfernt hat, weiß dass die
Entwicklung des entsprechenden korrekten Quellcodes nicht ohne einen gewissen Aufwand möglich ist. Statt für
zahllose Anwendung jeweils die gleiche Funktionalität neu zu entwickeln, lohnt sich hier der erhöhte Aufwand für
eine allgemeine “abstrakte” Lösung schon.
Sehen wir uns die Einfügoperation an. Zunächst eine Anwendung:
for (ListenIterator i = l.anfang(); i != l.ende (); ++i)
if (...)
l.einHier (i, 10);
// hier 10 in die Liste einfuegen
l.einHier fügt in der Liste l vor der – durch den Iterator i angegebenen – aktuellen Position einen neuen
Knoten mit dem Wert 10 ein.
Die Implementierung:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
149
class Liste {
...
void einHier (ListenIterator &, int); // Vor dem Iterator einfuegen
...
private:
Knoten *erster;
Knoten *letzter;
};
class ListenIterator {
...
private:
Liste &l;
// Liste
Liste::Knoten *pos; // aktuelle Position in der Liste
};
void Liste::einHier (ListenIterator &iter, int p_wert) {
if (iter.pos == 0) { // am Ende
einEnde (p_wert); // also am Ende einfuegen
} else {
Neuer Knoten mit:
Knoten * neu = new Knoten (p_wert,
// Wert
iter.pos,
// Nachfolger
iter.pos->v); // Vorgaenger
if (iter.pos->v != 0)
iter.pos->v->n = neu;
// naechster-Verweis im Vorgaenger
iter.pos->v = neu;
// der Neue ist Vorgaenger des aktuellen
if (iter.pos == erster) erster = neu; // am Anfang ist der neue der Anfang
}
}
Graphisch dargestellt ist die Situation vor dem Einfügen der 10 (Siehe Abbildung 60):
Liste
Iterator
erster
Knoten
0
pos
letzter
1
2
3
0
Abbildung 60: Liste und Iterator vor dem Einfügen von 10
und danach (Siehe Abbildung 61):
5.5.5
Die Liste und ihr Iterator sind Freunde
Die Klasse Liste und ihr Iterator sind eng gekoppelt. Ein Iterator muss direkt in die Innereien der Liste greifen,
beispielsweise um den Wert des aktuellen Knotens verändern. So etwas ist aber auch sinnvollerweise privat und
somit vor dem Zugriff fremder Klassen geschützt. Die Liste muss den Iterator darum zu ihrem Freund erklären:
class Liste {
friend class ListenIterator;
public:
...
private: // zugreifbar fuer ListenIterator:
class Knoten {
Programmierung II
150
Liste
Iterator
erster
Knoten
0
pos
letzter
2
1
3
0
10
v
wert
n
Abbildung 61: Liste und Iterator nach dem Einfügen von 10
public:
Knoten ();
Knoten (int, Knoten *, Knoten *);
˜Knoten ();
Knoten * n; // naechster Knoten
Knoten * v; // vorheriger Knoten
int
wert;
};
Knoten *erster;
Knoten *letzter;
};
In unserem Beispiel muss die Freundschaft gegenseitig sein. Die Einfügoperation der Liste muss auf die (private)
aktuelle Position des Iterators zugreifen. Die Liste ist darum auch ein Freund des Iterators:
class ListenIterator {
friend class Liste;
public:
...
private:
// zugreifbar fuer die Liste
Liste
&l;
// Liste in die der Iterator zeigt
Liste::Knoten *pos; // aktuelle Position in dieser Liste
};
Mit dem Konzept der Freundschaft sollte man möglichst sparsam umgehen. In diesem Fall ist sie gerechtfertigt: die
Liste und ihr Iterator bilden eine konzeptionelle Einheit. Der Iterator existiert nicht, um ein Konzept mit eigener
Schnittstelle und eigener Implementierung zu realisieren. Er ist eine Erweiterung und Ergänzung der Liste. Beide
zusammen bieten eine bestimmte Funktionalität und hüten gemeinsame Implementierungsgeheimnisse.
5.6
5.6.1
Konstante Iteratoren
Konstante Iteratoren
Die Konstantheit von Objekten spielt in C++ eine große Rolle. Um mit Iteratoren auf konstante Listen zugreifen zu können, definieren wir noch die Klasse der konstanten Iteratoren ListenConstIterator. Mit einem
konstanten Iterator kann man natürlich nur lesend auf die Listenelemente zugreifen:
// lesen und schreiben:
int & ListenIterator::operator*
() { return pos->wert; }
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
// nur lesen, darum keine Referenz:
int
ListenConstIterator::operator* () { return pos->wert; }
5.6.2
Die allgemeine Behälterklasse Liste
Jetzt ist es an der Zeit die Dinge im Zusammenhang zu betrachten. Beginnen wir mit der Liste:
class Liste {
friend class ListenIterator;
friend class ListenConstIterator;
public:
Liste ();
˜Liste ();
Liste (const Liste &);
Liste & operator= (const Liste &);
Liste
operator+ (const Liste &);
void
void
void
void
einAnfang
einEnde
einHier
loescheHier
ListenIterator
ListenIterator
// Default-Konstruktor (leere Liste)
// Kopier-Konstruktor
// Zuweisung
// Verkettung
(int); // am Anfang einfuegen
(int); // am Ende einfuegen
(ListenIterator &, int); // Vor Iterator einfuegen
(ListenIterator &);
// An Iteratorposition loeschen
anfang ();
ende
();
ListenConstIterator
ListenConstIterator
constAnfang () const;
constEnde
() const;
private:
class Knoten {
public:
Knoten ();
Knoten (int, Knoten *, Knoten *);
˜Knoten ();
Knoten * n; // naechster Knoten
Knoten * v; // vorheriger Knoten
int
wert;
};
Knoten *erster;
Knoten *letzter;
};
Die beiden Iteratoren sind:
class ListenIterator {
friend class Liste;
public:
ListenIterator (Liste &, Liste::Knoten *);
int &
operator* ();
ListenIterator & operator++ ();
bool
operator== (const ListenIterator &) const;
bool
operator!= (const ListenIterator &) const;
private:
Liste &l;
Liste::Knoten *pos;
};
// Liste
// aktuelle Position in der Liste
151
Programmierung II
152
class ListenConstIterator {
friend class Liste;
public:
ListenConstIterator (const Liste
int
operator*
ListenConstIterator & operator++
bool
operator==
bool
operator!=
private:
const Liste &l;
const Liste::Knoten *pos;
};
5.6.3
&, const Liste::Knoten *);
();
();
(const ListenConstIterator &) const;
(const ListenConstIterator &) const;
// Liste
// aktuelle Position in der Liste
Die auf der Liste basierende Menge
Die auf dieser Liste basierende Menge ist einfach:
class Menge {
public:
Menge ();
Menge (int);
Menge (const Menge &);
Menge & operator=
// leere Menge, Default-Konstruktor
// ein-elementige Menge
// Kopierkonstruktor
(const Menge &); // Zuweisungsoperator
Menge operator+
(const Menge &) const;
Menge operator*
(const Menge &) const;
bool istEnthalten (int i) const;
// Vereinigung
// Schnitt
// Element enthalten
private:
Liste l;
};
Die Implementierung der Klassen überlassen wir dem Leser als Übung.
5.7
Beispiel Syntaxbäume
5.7.1
Eine Grammatik definiert eine Menge von Zeichenketten
Eine Grammatik definert eine Menge von Zeichenketten als zulässige “Ausdrücke” einer “Sprache”. Das Definitionsprinzip einer Grammatik ist die Induktion (Rekursion). Ausgehend von einer Basis elementarer Ausdrücke
werden weitere mögliche Ausdrücke definiert. Beispielsweise können einfache arithmetische Ausdrücke mit folgender Grammatik mit drei Ableitungsregeln definiert werden:
<Ausdruck>
<WertAusdruck>
<OpAusdruck>
<Op>
::=
::=
::=
::=
<WertAusdruck> | <OpAusdruck>
<Zahl>
’(’ <Ausdruck> <Op> <Ausdruck> ’)’
’+’ | ’-’ | ’*’ | ’/’
(Regel
(Regel
(Regel
(Regel
0)
1)
2)
3)
Die Basis der Ausdrücke sind die Zahlen. Jede Zahl ist ein Ausdruck (Regel 1). Aus zwei beliebigen Ausdrücken
kann ein neuer mit Hilfe von zwei Klammern und einem Operator gebildet werden (Regel 2). Ein Operator ist ein
Plus–, Minus–, Multiplikations– oder Divisionszeichen (Regel 3).
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
5.7.2
153
Ableitungsbäume zeigen die Struktur der Ausdrücke
Eine Grammatik definiert nicht nur die Menge der zulässigen Zeichenketten, der “Ausdrücke”, sie gibt ihnen auch
eine Struktur. Die Struktur eines Ausdrucks ist die Art in der er aus anderen Ausdrücken zusammengesetzt ist. Mit
einem Ableitungsbaum kann diese Struktur dargestellt werden. Der Ableitungsbaum des Ausdrucks ((2+3)-4)
wird beispielsweise in Abb. 62 dargestellt.
<Ausdruck>
(
(
<Ausdruck>
<Ausdruck>
<Op>
<Op>
<Ausdruck>
<Zahl>
<Zahl>
2
3
)
<Ausdruck>
)
<Zahl>
4
Abbildung 62: Ableitungsbaum von ((2+3)-4)
5.7.3
Syntaxbäume: Gestraffte Ableitungsbäume zur Darstellung der Textstruktur
Die Information in einem Ableitungsbaum kann ohne wesentlichen Verlust verdichtet werden. Die dabei entstehnende gestraffte Version nennt man abstrakten Syntaxbaum, weil von irrelevanten Details abstrahiert wird, oder
kurz Syntaxbaum. In Abbildung 63 wird der Syntaxbaum zum Ausdruck ((2+3)-4) dargestellt. Man sieht, dass
beispielsweise die Klammern wegfallen. In der Baumform sind sie zur Verdeutlichung der Struktur nicht notwendig und können wegfallen.
Ausdruck
Ausdruck
2
4
3
Abbildung 63: Syntaxbaum von ((2+3)-4)
In vielen Fällen ist es vorteilhaft einen Text nicht direkt zu verarbeiten, sondern zuerst den Syntaxbaum zu bestimmen und an diesem dann erst die notwendigen Berechnungen auszuführen (siehe Abb. 64).
In der ersten Phase wird die innere Struktur des Textes entsprechend der Grammatik bestimmt und in der zweiten
Phase wird die Strukturdarstellung dann weiterverarbeitet. Als interne Darstellung der Textstruktur ist ein Syntaxbaum besser geeignet als ein Ableitungsbaum, da er die gleichen Informationen in übersichtlicherer Art enthält.
5.7.4
Syntaxbaum als Datenstruktur
Syntaxbäume werden intern mit Hilfe von Zeigern gespeichert. Zur Darstellung unserer einfachen Ausdrücke
könnte man beispielsweise den Typ Ausdruck definieren. Objekte vom Typ Ausdruck enthalten die Information über die Struktur eines arithmetischen Ausdrucks in Form von Zeigern:
class Ausdruck {
friend ostream & operator<< (ostream &, const Ausdruck &);
friend istream & operator>> (istream &, Ausdruck &);
public:
Ausdruck ();
Programmierung II
154
((2+3)−4)
einlesen und analysieren
Ausdruck
Ausdruck
2
4
3
verarbeiten (auswerten)
1
Abbildung 64: Zweiphasige Verarbeitung eines Textes
Ausdruck (int);
Ausdruck (char, const Ausdruck &, const Ausdruck &);
Ausdruck (const Ausdruck &);
Ausdruck & operator=(const Ausdruck &);
˜Ausdruck ();
private:
class Knoten;
Knoten *
ak;
};
Der Eingabeoperator operator>> soll einen Ausdruck als Text einlesen und die entsprechende interne Darstellung aufbauen. Der Ausgabeoperator operator<< hat umgekeht die Aufgabe die interne Darstellung wieder in
Text umzuwandeln. Konstruktoren, Zuweisungsoperator und Destruktor müssen definiert werden, da bei Klassen
mit Zeigern die automatisch generierten Varianten in der Regel nicht korrekt sind.
Die Klasse Ausdruck ist, wie weiter oben die Klasse Liste, nur die äussere Hülle der Datenstruktur. Die Daten
selbst werden in internen Knoten gespeichert, die hier vom Typ Knoten sind:
class Ausdruck::Knoten {
public:
Knoten (int);
Knoten (char, Knoten *, Knoten *);
˜Knoten ();
Knoten (const Knoten &);
Knoten & operator= (const Knoten &);
ostream & write (ostream &) const;
private:
enum Art {wertAusdr, opAusdr};
Art
art;
int
wert;
char
op;
Knoten
*l;
Knoten
*r;
};
Ausdrucksknoten haben keinen Defaultkonstruktor, da es keine sinnvolle Default–Belegung gibt. Die beiden Kon-
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
155
struktoren erlauben die Initialisierung eines Knotens entweder als Wert-Ausdruck (Blatt eines Ausdrucksbaums)
oder als Operator-Ausdruck (innerer Knoten eines Ausdrucksbaums).
Ausdrucksknoten erfüllen die gleiche Funktion wie Listenknoten. Sie haben darum einen äquivalenten Aufbau,
allerdings mit zwei statt einem Nachfolger, geeignete Konstruktoren, einem Zuweisungsoperator und einem Destruktor. All dies kann wie die entsprechende Funktionalität der Liste definiert werden. Der Leseoperator wird
problemlos rekursiv definiert. Wir verweisen dazu auf Teil 1 des Skripts.
5.7.5
Typinforamtion in den Knoten
Ein Knoten des Syntaxbaums (vom Typ Knoten) enthält
entweder einen Operator und die Verweise auf zwei Unterausdrücke
oder eine Zahl als Wert.
Bei einem Objekt der Klasse Knoten sind also entweder op, l und r mit sinnvollen Werten belegt oder aber
wert. Damit stets klar ist, um welche Variante es sich handelt, wird art mit der entsprechenden Typinformation
belegt. Hat art den Wert wertAusdr dann handelt es sich um einen Wertausdruck und wert ist (sinnvoll)
belegt. Hat art den Wert opAusdr dann handelt es sich um einen Operatorausdruck und op, l und r haben
(sinnvolle) Werte.
Es ist17 nicht möglich zwischen zwei Arten von Knoten – z.B. Wert– und Operatorknoten – zu unterscheiden. Die
Zeiger l und r in Knoten sowie ak in Ausdruck können nur auf Objekte mit einem ganz bestimmten festen
Typ zeigen. Dieser muss dann die Informationen aller Varianten aufnehmen können. Das führt natürlich zu etwas
Platzverschwendung.
5.7.6
Implemetierung der Methoden und Funktionen
Zu den Methoden der beiden Klassen gibt es nichts weiter zu sagen, als dass mit Sorgfalt auf eine korrekte Implementierung speziell der Kopieroperationen (Zuweisungsoperator und Kopierkonstruktor) zu achten ist. Alle
Kopien müssen tief sein, also den gesamten Ausdrucksbaum duplizieren. Wir definieren dazu einen rekursiven
Kopierkonstruktor für Knoten und nutzen ihn an allen Stellen, an denen eine tiefe Kopie notwendig ist:
// Undefinierter Ausdruck
//
Ausdruck::Ausdruck () : ak(0) {}
// Wert-Ausdruck
//
Ausdruck::Ausdruck (int i) : ak(new Knoten(i)) {}
// Operator-Ausdruck
// tiefe Kopie der Komponenten von a1, a2
// mit Hilfe des Kopierkonstruktors von Knoten
//
Ausdruck::Ausdruck (char op, const Ausdruck & a1, const Ausdruck & a2)
: ak(new Knoten(op, new Knoten(*(a1.ak)), new Knoten(*(a2.ak)) )) {}
// Kopierkonstruktor
// tiefe Kopie von a mit Hilfe des Kopierkonstruktors von Knoten
//
Ausdruck::Ausdruck (const Ausdruck &a)
: ak( new Knoten(*(a.ak)) ) {} // new mit Kopierkonsruktor
// Destruktor
17 ohne das Konzept der
Vererbung
156
Ausdruck::˜Ausdruck () { delete ak; }
// Zuweisung von Ausdruecken
// tiefe Kopie von ak mit Hilfe des Kopierkonstruktors von Knoten
//
Ausdruck & Ausdruck::operator=(const Ausdruck &a) {
if ( &a != this ) {
delete (ak);
ak = new Knoten(*(a.ak));
// new mit Kopierkonsruktor
}
return *this;
}
// Knoten eines Wert-Ausdrucks erzeugen
//
Ausdruck::Knoten::Knoten (int p_i)
: art(wertAusdr), wert (p_i),
// sinnvolle Werte
op(’?’), l(0), r(0)
// Fuell-Werte
{}
// Knoten eines Operator-Ausdrucks erzeugen (kopiert flach)
//
Ausdruck::Knoten::Knoten (char p_op, Knoten *p_l, Knoten *p_r)
: art(opAusdr),
wert(-999),
// Fuell-Wert
op(p_op), l(p_l), r(p_r) {}
// Destruktor von Knoten
//
Ausdruck::Knoten::˜Knoten () {
delete(l);
delete(r);
}
// Kopierkonstruktor von Knoten
// tiefe Kopie durch rekursiven Aufruf von sich selbst
//
Ausdruck::Knoten::Knoten (const Knoten &a) {
art = a.art;
op = a.op;
wert = a.wert;
if ( a.art == opAusdr) {
l = new Knoten(*(a.l)); // rekursiver Aufruf
r = new Knoten(*(a.r)); // rekursiver Aufruf
} else {
l = 0;
r = 0;
}
}
// Zuweisungsoperator von Knoten
// tiefe Kopie mit Hilfe des Kopierkonstruktors
//
Ausdruck::Knoten & Ausdruck::Knoten::operator=(const Knoten &a) {
if (&a != this) { // keine Zuweisung an sich selbst
delete(l); l = 0;
delete(r); r = 0;
art = a.art;
Programmierung II
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
157
op = a.op;
if ( a.art == opAusdr) {
l = new Knoten(*(a.l));
r = new Knoten(*(a.r));
}
}
return *this;
}
ostream & Ausdruck::Knoten::write(ostream & os) const {
switch (art) {
case wertAusdr:
os << wert;
break;
case opAusdr:
if ( (l==0 ) || (r==0) ){ cerr << "FEHLERHAFTE STRUKTUR\n"; exit (1); }
os << ’(’;
l->write(os);
os << op;
r->write(os);
os << ’)’;
break;
default:
cerr << "FEHLERHAFTE STRUKTUR art= " << art << "\n";
exit (1);
}
return os;
}
Der Ausgabeoperator für Ausdrücke ist trivial und der Eingabeoperator kann bei deren einfacher Struktur und bei
Verzicht auf Fehlerprüfungen recht einfach rekursiv definiert werden:
std::ostream & operator<< (std::ostream &os, const Ausdruck &a) {
if ( a.ak == 0 )
return os<< "UNDEFINIERTER-AUSDRUCK";
else
return a.ak->write(os);
}
std::istream & operator>> (std::istream & is, Ausdruck & a) {
char
z;
Ausdruck a1,
a2;
char
op;
if (!(is >> z)) { cerr << "Eingabefehler\n"; exit (1); }
switch (z){
case ’(’ :
is >> a1;
is >> op;
is >> a2;
a = Ausdruck (op, a1, a2);
is >> z;
if ( z == ’)’ ) return is;
else cerr << "Ausdrucksfehler\n"; exit (1);
case ’0’: case ’1’: case ’2’: case ’3’: case ’4’:
case ’5’: case ’6’: case ’7’: case ’8’: case ’9’:
a = Ausdruck (z-’0’);
return is;
Programmierung II
158
default: cerr << "Ausdrucksfehler\n"; exit (1);
}
}
Diese Version des Eingabeoperators muss nicht einmal ein Freund sein. Er benutzt keinerlei Information über die
innere Struktur der Ausdrücke. Das macht ihn sehr einfach, es hat aber auch seinen Preis. Dieser Eingabeoperator
ist sehr ineffizient. Bei der Analyse eines Operatorausdrucks werden die Unterausdrücke a1 und a2 eingelesen
und aus ihnen wird dann ein neuer Operatorausdruck gebildet. a1 und a2 werden erst aufgebaut, dann komplett
(tief) kopiert und anschließend vernichtet (siehe Abbildung 65).
a1
ak
a2
a
ak
ak
neuer Operator−Knoten
Löschen
durch
Destruktor
Kopieren
Kopieren
Abbildung 65: Neuen Ausdruck durch Kopieren erzeugen
Da die Knotenstruktur von a1 und a2 nur dazu benutzt wird, um a aufzubauen, würde man besser flach kopieren
und auf das Vernichten verzichten. Keinesfalls darf dazu aber das allgemeine Verhalten der Klassen Ausdruck
und Knoten verändert werden, etwa indem generell aus einer tiefen eine flache Kopie gemacht wird. Der besonderen Situation im Leseoperator muss speziell angepasster Code gerecht werden:
// effizienterer Eingabeoperator
//
std::istream & operator>> (std::istream & is, Ausdruck & a) {
...
// STATT
// a = Ausdruck (op, a1, a2);
// JETZT
// a zu Fuss belegen:
a.ak = new Ausdruck::Knoten(op, a1.ak, a2.ak);
a1.ak = 0; // <-- Destruktor "abschalten"
a2.ak = 0; // <-- Destruktor "abschalten"
...
}
Der hier aufgerufene Konstruktor kopiert flach. (Er kann nicht anders, seine Argumente haben ja keinen Klassen–
Typ.) Dadurch wird das sinnlose Duplizieren vermieden. Allerdings zeigen jetzt jeweils zwei Zeiger auf die selbe
Struktur: der neue erzeugte Operator–Knoten mit seinem l– und seinem r–Zeiger und a1.ak bzw. a2.ak (Siehe
Abbildung 66). Da a1 und a2 lokale Variablen sind, wird ihr Destruktor bald aktiv werden und die Knoten von a1
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
159
und a2 und damit auch die des neu erzugten Ausdrucks wegräumen. Um das zu vermeiden setzen wir die Zeiger
in a1 und a2 auf Null.
a1
ak
a2
ak
a
ak
Verweise löschen
neuer Operator−Knoten
Abbildung 66: Neuen Ausdruck ohne Kopie erzeugen
Programmierung II
160
5.8
Übungen
Aufgabe 1
Erweitern Sie folgende Definition von S um einem korrekten Kopierkonstruktor, Zuweisungsoperator und Destruktor. Alle Kopien sollen tief sein.
class S {
public:
S() : a(0), l(0) {}
S(int p_a, S * p_l) : a(p_a), l(p_l) {}
˜S()
{ ????? }
S (const S &)
{ ????? }
S & operator= (const S &)
{ ????? }
private:
int a;
S * l;
};
Aufgabe 2
Eine verkettete Datenstruktur, wie etwa ein Stapel, kann auf verschiedene Arten dargestellt werden. Üblicherweise
definiert man eine “Mutterklasse” Stapel und eine Hilfsklasse Knoten. Objekte der Klasse Stapel stellen
Stapel dar und benutzen intern Objekte der Klasse Knoten zur Speicherung der einzelen Elemente. Objekte der
Klasse Stapel können im Stack und im Heap angelegt werden. Knoten sind immer nur im Heap.
class Stapel {
public:
Stapel();
˜Stapel();
Stapel (const Stapel &);
Stapel & operator= (const Stapel &);
void push (int);
void pop ();
int top () const;
bool leer () const;
private:
struct Knoten {...};
Knoten * k;
};
Anfänger sind gelegentlich versucht alles in eine Klasse zu packen. Etwa wie folgt:
class Stapel {
public:
Stapel();
˜Stapel();
Stapel (const Stapel &);
Stapel & operator= (const Stapel &);
void push (int);
void pop ();
int top () const;
bool leer () const;
private:
int
a;
Stapel * s; // <<---- !!!
};
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
161
Definieren Sie die Methoden in den beiden Varianten. Erläutern Sie die Nachteile der zweiten Variante am Beispiel
des leeren Stapels. Wie wird ein leerer Stapel dargestellt. Was passiert, wenn auf einem leeren Stapel etwas abgelegt
wird, etc.
Aufgabe 3
Ein sehr verbreiteter Bestandteil von Programmen beschäftigt sich mit der Verwaltung von Daten mit folgenden
Operationen:
Einfügen,
Entfernen,
Suchen: Nachsehen ob ein Datum vorhanden ist.
Das zu speichernde Datum wird oft noch in ein Paar aus Schlüssel und Wert aufgeteilt. Der Schlüssel ist der Anteil,
nach dem gesucht wird und der Wert ist das, an dem man interessiert ist. Ein Telefonbuch stellt beispielsweise
eine solche Anwendung dar: Die Schlüssel sind die Namen und die Telefonnummern die Werte. Beide zusammen
bilden ein Datum das gespeichert, gesucht und gelöscht wird.
Man nennt eine solche Datenstruktur Abbildung (engl. Map): Sie stellt mathematisch eine partielle Abbildung der
Schlüsselmenge auf die Wertemenge dar. Die Abbildung ist partiell, weil typischerweise nur ein (geringer) Teil der
möglichen Schlüssel einen zugeordneten Wert hat.
Ein Wörterbuch (engl. Dictionary) ist eine Sonderform der Abbildung, bei der das gesamte zu speichernde Datum
Schlüssel ist.
1. Implementieren Sie einen konkreten Datentyp (unsortierte) Liste von Strings mit zugehörigem Iterator. Die
Liste soll das Einfügen und Entfernen von Listenelementen an beliebiger Position ermöglichen.
2. Nutzen Sie Ihre Liste zur Implementierung eines Wörterbuchs mit zugehörigem Iterator. Das Wörterbuch
ist sortiert, d.h. der Iterator durchläuft die Elemente in lexikographischer Ordnung. Die Liste wird vom
Wörterbuch benutzt:
class Dictionary {
public:
....
private:
Liste l;
...
};
3. Zeichnen Sie ein Klassendiagramm zur Darstellung der Beziehung Ihrer vier Klassen (Liste und Wörterbuch
jeweils mit ihren Iteratoren).
Aufgabe 4
Warum erzeugt der Konstruktor (vergleiche Beispiel im Skript)
Ausdruck::Ausdruck (char op, const Ausdruck & a1, const Ausdruck & a2)
: ak(new Knoten(op, new Knoten(*(a1.ak)), new Knoten(*(a2.ak)) )) {}
für Operator-Ausdrücke eine tiefe Kopie seiner Argumente, der Konstrutor
Ausdruck::Knoten::Knoten (char p_op, Knoten *p_l, Knoten *p_r)
: art(opAusdr),
wert(-999),
op(p_op), l(p_l), r(p_r) {}
für Operator-Knoten jedoch nicht? Warum wird ein Unterschied gemacht?
Programmierung II
162
Aufgabe 5
Schreiben Sie ein Programm das vollständig geklammerte Ausdrücke mit rationalen Zahlen (Brüchen) einliest und
einen entsprechenden Syntaxbaum als interne Datenstruktur vom Typ Ausdruck aufbaut. Der Typ Ausdruck
soll dabei wie folgt definiert werden:
class Ausdruck {
friend ostream & operator<< (ostream &, const Ausdruck &);
friend istream & operator>> (istream &, Ausdruck &);
public:
Ausdruck ();
Ausdruck (const Ausdruck &);
Ausdruck & operator=(const Ausdruck &);
˜Ausdruck ();
private:
class AusdrKnoten;
AusdrKnoten *
ak;
};
ostream & operator<< (ostream &, const Ausdruck &);
istream & operator>> (istream &, Ausdruck &);
operator>> liest Ausdrücke ein und baut die interne Datenstruktur auf. operator<< gibt die interne Darstellung in textueller Form wieder aus.
Die Grammatik Ausdrücke ist:
<Ausdruck>
<WertAusdruck>
<OpAusdruck>
<Operator>
::=
::=
::=
::=
<WertAusdruck> | <OpAusdruck>
<RationaleZahl>
’(’ <Ausdruck> <Operator> <Ausdruck> ’)’
’+’ | ’-’ | ’*’ | ’/’
Ausdrücke basieren auf rationalen Zahlen, deren textuelle Darstellung durch folgende Grammatik definiert wird:
<RationaleZahl>
<Vz>
<Zahl>
<Ziffer>
::=
::=
::=
::=
’[’ <Vz> <Zahl> ’/’ <Zahl> ’]’
’+’ | ’-’
<Ziffer> | <Ziffer><Zahl>
’0’ | ’1’ | ’2’ | ’3’ | ’4’ | ’5’ | ’6’ | ’7’ | ’8’ | ’9’
Achten Sie darauf, dass Ausdruck als konkreter Datentyp definiert wird. Definieren Sie auch einen Datentyp
Bruch zur Speicherung rationale Zahlen der die Verantwortung für Einlesen und Ausgeben der textuellen Darstellung von Brüchen übernimmt. Der Syntaxbaum soll nur die Struktur der Ausdrücke – nicht auch die der Brüche
darlegen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
6
163
Schablonen (Templates)
6.1
6.1.1
Funktionsschablonen
Funktionsschablone: Muster einer Funktion
Manche Programmstücke sind sich sehr ähnlich. Beispielsweise unterscheidet sich eine Funktion, die das Maximum von zwei float–Zahlen berechnet, lediglich im Typ der Parameter von einer, die das Maximum von zwei
ints berechnet. Eine Programmiererin, die beide Maximum–Funktionen realisieren will, wird oft erst die eine
Variante schreiben und die zweite durch Kopieren und Modifizieren aus der ersten erzeugen.
int max_i (int x, int y) {
if (x > y) return x;
else return y;
}
float max_f (float x, float y) {
if (x > y) return x;
else return y;
}
Diese Art der Programmerzeugung durch Kopieren und Modifizieren ist so elementar mechanisch, dass man sie
dem C++–Compiler überlassen kann: Man gibt eine Schablone (ein Muster, engl. Template) der Funktion an, aus
dieser kann der Compiler dann intern beliebig viele “sehr ähnliche” Programmstücke erzeugen. Nehmen wir als
Beispiel eine Schablone, das alle Maximumfunktionen zusammenfasst:
template <class T>
// Maximun-Schablone mit Typ-Parameter T
T max (T x, T y) {
if (x > y) return x;
else return y;
}
6.1.2
Struktur einer Schablone
Die Zeile template <class T> sagt, dass ein Template, also eine Schablone, folgt. Das Template hat einen
Parameter T der hier ein Typ sein muss, erkennbar am Schlüsselwort class vor T.
Der Rest des Beispiels ist das Funktionsmuster. Zusammen mit einem Typ kann es zu einer Funktion werden.
Ersetzt man in der Definition der Schablone den Typparameter T durch einen wirklichen Typ, dann entsteht aus
dem Funktionsmuster eine Funktion. So wie eine Funktion Werte (in runden Klammern) als Parameter hat, so hat
dieses Template einen Typ (in spitzen Klammern) als Parameter.
Das Template kann man als Kopier– / Editieranweisung an den Compiler verstehen: Kopiere es, ersetze T durch
int, oder float oder einen anderen Typ und übersetze schließlich die sich ergebende Funktionsdefinition.
In der Kopfzeile der Schablone steht zwar class T, aber der aktuelle Parameter für T muss nicht unbedingt eine
Klasse sein, jeder Typ ist erlaubt. Statt class ist hier auch das im Prinzip passendere Schlüsselwort typename
erlaubt. Manche Compiler verstehen das aber noch nicht und da außerdem class kürzer ist, bleiben wir bei ihm.
template <typename T> // Schablone mit Typ-Parameter T
T max (T x, T y) {...} // "typename" entspricht "class"
6.1.3
Schablone benutzen
Eine Funktions–Schablone kann sehr einfach benutzt werden:
template <class T>
T max (T x, T y) {
... wie oben ...
// Template Anfang
Programmierung II
164
}
// Template Ende
int main () {
int
a = 1, b = 2;
float m = 0.5, n = 0.4;
// Benutzung der int--Variante von max:
cout << "INT
MAX: " << max (a, b) << endl;
// Benutzung der float--Variante von max:
cout << "FLOAT MAX: " << max (m, n) << endl;
}
An den Typen der aktuellen Parameter erkennt der Compiler, dass eine int– und eine float–Version von max
benötigt werden. Er erzeugt sie und fügt den Aufruf der jeweiligen Variante ein. Der Vorteil liegt auf der Hand.
Statt zwei nahezu identischen Funktionen muss nur noch eine einzige erzeugt und gepflegt werden.
6.1.4
Instanzierung: Schablone + Parameter = Funktion
Man beachte, dass hier zwei völlig unterschiedliche Parameterübergaben stattfinden: Eine zur Übersetzungszeit
und eine zur Laufzeit:
Instanzierung der Schablone:
Während der Übersetzung werden Typen an das Template übergeben und Funktionen erzeugt.
Aufruf der Funktion:
Zur Laufzeit werden Werte an die Funktionen übergeben und andere Werte erzeugt.
Im Detail: Während der Übersetzung von max (a, b) erkennt der Compiler dass eine int–Version von max
benötigt wird. Er übergibt darum den Typ int an einen internen Mechanismus zur Generierung von Funktionen
aus Templates. Diesen Mechnismus hat der Programmierer mit dem Template beschrieben. Der Compiler führt ihn
jetzt mit dem aktuellen Parameter int aus und erzeugt so etwas wie (eine Übersetzung von):
int max (int x, int y) {
if (x > y) return x;
else return y;
}
Diese int–Version von max ist eine Instanz des Templates. Der Prozess ihrer Erzeugung wird Instanzierung (engl.
instantiation) genannt.
Zur Laufzeit werden dann in bekannter Art die Werte 1 und 2 an die Instanz (eine normale Funktion) übergeben.
... max (a, b) ...
|
+<-- int
|
| Compiler: Instanzierung
V
int-Variante (Instanz) von max
|
| Compiler: Codegenerierung
V
Maschinen-Code der int-Variante
|
+<-- 1, 2
|
| Prozessor: Code ausfuehren
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
165
V
Maximum von 1, 2
6.1.5
Parameter einer Schablone
Eine Schablone kann zwei Arten von Parametern haben:
Typ–Parameter werden durch das Schlüsselwort class bzw. typename gekennzeichnet;
Nicht–Typ–Parameter sehen wie die formalen Parameter einer Funktion aus.
Eine Schablone kann beliebig viele Parameter haben. Ein Beispiel mit beiden Arten von Parametern ist:
template<class T, int size>
void sort (T (&a)[size]) {
for (int i=0; i<size; ++i)
for (int j=i+1; j<size; ++j)
if (a[j] > a[i])
swap (a[i], a[j]);
}
Die Schablone hat zwei Parameter: einen Typ und einen int–Wert. Die Funktion sort hat einen Parameter, ein
Feld mit der Größe size. Die aktuellen Parameter für T und size werden zur Übersetzungszeit für jeden Aufruf
einzeln festgestellt:
template<class T>
void swap (T &x, T &y) { T t = x; x = y; y = t; }
template<class T, int size>
void sort (T (&a)[size]) {
.. wie oben ..
}
int main () {
int
a1[10]
float a2[5]
...
sort (a1); //
sort (a2); //
...
}
6.1.6
= {6,5,5,6,2,4,9,5,3,1};
= {0.1, 6.2, 9.5, 9.1, 0.9};
Uebersetzungszeit: T <- int,
size <- 10, Laufzeit: a <- a1
Uebersetzungszeit: T <- float, size <- 5, Laufzeit: a <- a2
Explizit angegebene Schablonenparameter
Die Parameter, die der Compiler bei der Erzeugung einer Instanz einsetzt, leitet er aus den Funktionsargumenten ab.
Er analysiert dazu die Aufrufstelle und versucht nach Möglichkeit einen passenden aktuellen Templateparameter
zu identifizieren.
Will oder kann man sich auf diese eingebaute Intelligenz des Compilers nicht verlassen, dann können Templateparameter auch explizit angegeben werden:
template <class T>
T max (T x, T y) {
... wie oben ...
}
int main () {
Programmierung II
166
int
a = 1, b = 2; float m = 0.5, n = 0.4;
cout << max<int>
(a, b) << endl; //expliziter Typparameter int an max
cout << max<float> (m, n) << endl; //expliziter Typparameter float an max
}
max<int> bezeichnet hier die int–Spezialisierung der Schablone max, die durch Angabe des Parameters int
in spitzen Klammern hinter max definiert wird.
Wenn die Funktionsparameter eine eindeutige Ableitung der Schablonenparameter nicht erlauben, dann müssen
sie explizit angegeben werden. Beispiel:
template<class T>
T max(T x, T y) { if (x>y) return x; else return y; }
int main () {
int i = 1;
float f = 1.0;
cout << max (i, f) << endl; // FEHLER: nicht uebersetzbar
}
Hier ist der Compiler nicht in der Lage selbständig die richtige Spezialisierung dee Schablone zu finden, sie muss
explizit angegeben werden:
template<class T>
T max(T x, T y) { if (x>y) return x; else return y; }
int main () {
int i = 1;
float f = 1.0;
cout << max<float> (i, f) << endl; // OK
}
6.1.7
Schablonen– und Funktionsparameter nicht verwechseln
Typen (Klassen) können nur als Schablonenparameter übergeben werden. Alles andere kann sowohl als
Schablonen–, als auch als Funktionsparameter übergeben werden. Vergleichen wir dazu die folgenden beiden
Sortierschablonen:
template<class T, int size>
void sort_1 (T (&a)[size]) { ... }
template<class T>
void sort_2 (T a[], int size) { ... }
Die Feldgröße ist bei sort 1 ein Schablonenparameter, bei sort 2 ist sie Funktionsparameter. Die unterschiedliche Art der Parameterübergabe erkennt man am Aufruf:
int
float
...
sort_1
sort_1
...
sort_2
sort_2
a1[10] = ...;
a2[5] = ...;
(a1);
(a2);
// implizite Uebergabe der Feldgroesse zur Uebersetzungszeit
(a1, 10);
(a2, 5);
// explizite Uebergabe der Feldgroesse zur Laufzeit
Bei sort 1 stellt der Compiler die aktuellen Schablonenparameter für T und size fest (int und 10, bzw.
float und 5), erzeugt die zwei Funktionen
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
167
sort 1<int, 10>
sort 1<float, 5>
die dann mit den Parametern a1 bzw. a2 aufgerufen werden.
Bei sort 2 stellt der Compiler den aktuellen Schablonenparameter für T fest fest (int, bzw. float), erzeugt
die zwei Funktionen
sort 1<int>
sort 1<float>
die dann mit den Parametern a1,10 bzw. a2,5 aufgerufen werden.
Die Parameterübergabe zur Laufzeit – d.h. Funktions– statt Schablonenparameter – ist stets etwas flexibler:
...
int * p = new int [n];
...
sort_1 (p);
// FEHLER: GEHT NICHT
sort_2 (p, n);
// OK
In diesem Beispiel ist die Feldgröße erst zur Laufzeit bekannt, sie kann darum kein Schablonenparameter sein.
6.1.8
Schablonen und die Referenzübergabe von Feldern
In
template<class T, int size> void sort 1 (T (&a)[size])..
ist size ein Schablonenparameter. In
template<class T> void sort 3 (T (&a)[size])..
ist size weder Schablonen– noch Funktionsparameter. Es ist entweder eine globale Konstante oder die Definition
ist fehlerhaft:
template<class T>
void sort_3 (T (&a)[size]) {
...
}
// FEHLER: size nicht definiert!
Wir können size natürlich definieren, erhalten aber dann eine sehr unflexible Funktionsschablone:
const int size = 10;
template<class T>
void sort_3 (T (&a)[size]) { ... }
int main () {
int
a1[10] = ... ;
float a2[5] = ... ;
sort_3 (a1); // OK
sort_3 (a2); // FEHLER, size stimmt nicht
}
6.1.9
Anwendung: Sortieren
Aktionen wie Suchen und Sortieren sind generell unabhängig von der genauen Art dessen, was da gesucht oder
sortiert wird. Elemente sehr vieler Typen kann man mit den gleichen Mechanismen suchen und sortieren. Sie
müssen nur miteinander verglichen werden können. Wenn die Elemente eines Typs T verglichen werden können,
dann können sie auch sortiert und gesucht werden. Das sind ideale Voraussetzungen zum Einsatz von Schablonen.
Programmierung II
168
Betrachten wir eine einfache Sortier–Schablone:
template<class T, int size>
void sort (T (&a)[size]) {
for (int i=0; i<size; ++i)
for (int j=i+1; j<size; ++j)
if (a[j] > a[i])
swap (a[i], a[j]);
}
Diese Schablone ist nur benutzbar, wenn Elemente des Typs T mit > verglichen werden können, und natürlich auch
nur wenn wir aufsteigend sortieren wollen.
6.1.10 Die Sortierrichtung als Parameter
Man kann die Vergleichsoperation zu einem weiteren Parameter machen, um so die Schablone sowohl zum auf–
wie zu absteigenden Sortieren benutzen zu können. Etwa wie folgt:
template<class T>
void swap (T &x, T &y) { T t = x; x = y; y = t; }
template<class T, int size, bool compare (T, T)>
void sort (T (&a)[size]) {
for (int i=0; i<size; ++i)
for (int j=i+1; j<size; ++j)
if (compare (a[j], a[i]))
swap (a[i], a[j]);
}
template<class T>
// Vergleich groesser
bool gt (T x, T y) { return x > y; }
template<class T>
// Vergleich kleiner
bool lt (T x, T y) { return x < y; }
int main () {
int
a1[10] = {6,5,5,6,2,4,9,5,3,1};
float a2[5] = {0.1, 6.2, 9.5, 9.1, 0.9};
sort<int, 10, gt<int> > (a1); // a1 absteigend sortieren
sort<float,5,lt<float> > (a2); // a2 aufsteigend sortieren
//
ˆ
// Achtung, Leerzeichen beachten !
...
}
sort muss hier explizit spezialisiert werden. Der Compiler kann die Sortierrichtung ja nicht erraten. Als Alternative bietet sich an, die Vergleichfunktion nicht an die Schablone sondern an deren Instanz – die erzeugte Funktion
– als Funktionsparameter (Zeiger auf eine Funktion) zu übergeben:
template<class T>
void swap (T &x, T &y) { T t = x; x = y; y = t; }
template<class T, int size>
void sort (T (&a)[size], bool (*compare) (T, T)) {
for (int i=0; i<size; ++i)
for (int j=i+1; j<size; ++j)
if (compare (a[j], a[i]))
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
169
swap (a[i], a[j]);
}
template<class T>
bool gt (T x, T y) { return x > y; }
template<class T>
bool lt (T x, T y) { return x < y; }
int main () {
int
a1[10] = {6,5,5,6,2,4,9,5,3,1};
float a2[5] = {0.1, 6.2, 9.5, 9.1, 0.9};
sort<int,10> (a1, &gt<int>);
// a1 absteigend sortieren
sort<float,5> (a2, &lt<float>); // a2 aufsteigend sortieren
...
}
In dieser Version werden die Vergleichsfunktionen wieder zur Übersetzungszeit als Instanzen der beiden Schablonen erzeugt, aber im Gegensatz zu oben erst zur Laufzeit an die Sortierfunktion übergeben.
Auf die explizite Spezialisierung von sort mit aktuellen Schablonenparametern kann hier verzichtet werden. Das
vereinfacht den Aufruf zu:
...
sort(a1, &gt<int>);
// a1 absteigend sortieren
sort(a2, &lt<float>); // a2 aufsteigend sortieren
...
6.1.11 Klassen als Schabloneparameter
Typparameter einer Schablone werden mit dem Schlüsselwort class gekennzeichnet. Trotz dieses Schlüsselworts
können die übergebenen Typen Klassen sein, sie müssen es aber nicht sein. In den Beispielen bisher ist sogar
nicht ein einziges Mal tatsächlich eine Klasse bei der Instanzierung einer Schablonen mit Typparameter eingesetzt
worden. Es spricht aber nichts dagegen beispielsweise Strings zu sortieren:
...
string a1[5] = {"abc","xyz","123","UVW","MAEH"};
sort(a1, &gt<string>);
...
6.1.12 Teilweise explizite Spezialisierung einer Schablone
Der Compiler muss entweder in der Lage sein die Schablonen–Argumente aus der Verwendungsstelle zu schließen,
oder die Programmiererin muss sie explizit angeben. In vielen Fällen kann ein Teil der Argumente vom Compiler
selbständig festgestellt werden, ein anderer nicht. Die Sortierung bietet wieder ein Beispiel:
...
template<int size, class T>
void sort (T a[]) {
...
}
int main () {
int
*p = new int[10];
...
sort
(p); // FEHLER Compiler kann den
//
Templateparameter nicht bestimmen
sort<10, int> (p); // OK, vollstaendige Spezialisierung
Programmierung II
170
sort<10>
...
(p); // OK, teilweise Spezialisierung
}
Hier kann der Typ int vom Compiler abgeleitet werden, die Feldgröße des aktuellen Parameters dagegen nicht. In
einer Spezialisierung können die Argumente der Schablone weggelassen werden, die der Compiler selbst feststellen
kann. Sie müssen allerdings den Rest der Liste ausmachen:
...
template<class T, int size>
// falsche Reihenfolge
void sort (T a[]) { ... }
...
sort<int, 10> (p); // OK, vollstaendige Spezialisierung
sort<10>
(p); // FEHLER: teilweise Spezialisierung nicht moeglich
...
T a[] ist genau wie T a[size] äquivalent zu T *a. Alle drei Typen beinhalten nicht die Feldgröße. Im Gegensatz dazu ist die Feldgröße Bestandteil des Parameters, wenn das Feld per Referenz übergeben wird. Der Compiler
kann dann aus dem aktuellen Parameter den Schablonenparameter size ableiten.
6.2
6.2.1
Klassenschablonen
Deklaration und Verwendung eines Klassenschablone
Schablonen können auch benutzt werden, um Muster von Klassendefinitionen zu erzeugen. Ein einfaches Beispiel
ist:
template<int size, class Elem>
class Stack {
public:
Stack();
void push (Elem e);
void pop (Elem &e);
private:
Elem space [size];
int index;
};
Diese Schablone definiert ein Muster für die Erzeugung von Stapeln mit unterschiedlichem Elementtyp und von
unterschiedlicher maximaler Länge.
Im Gegensatz zu Spezialisierungen von Funktionsschablonen, die der Compiler meist völlig selbständig erzeugen kann, müssen Spezialisierungen von Klassenschablone durch den Programmierer immer explizit angegeben
werden:
...
Stack<10,int>
int_stack;
// Stack mit maximal 10 ints
Stack<20,float>
float_stack; // Stack mit maximal 20 floats
...
int_stack.push
(1);
float_stack.push (2.6);
..
Hier bezeichnen Stack<10,int> und Stack<20,float> jeweils einen Typ. Es sind Instanzierungen der
Schablone Stack mit den aktuellen Parametern 10 und int bzw. 20 und float.
Die Zeile
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
171
Stack<10,int> int stack;
ist eine Variablendefinition und int stack ist eine Variable mit einem anonymen (namenlosen) Typ. Man hätte
ihm auch einen Namen geben können, z.B. IntStack:
...
// IntStack = Typ der Stacks mit maximal 10 ints:
typedef Stack<10,int> IntStack;
// eine Variable vom Typ IntStack:
IntStack
int_stack;
...
int_stack.push
(1);
..
6.2.2
Definition der Methoden einer Klassenschablone
Die Methodendefinitionen sehen – wenig überraschend – den Funktionsschablonen sehr ähnlich:
template<int size, class Elem>
//
Stack<size, Elem>::Stack (): index(-1){}
//
//
Konstruktor
// "Stack<size, Elem>" bezeichnet
die Klasse zu der der Konstruktor
mit Namen "Stack" gehoert
template<int size, class Elem>
// Einfuegen
void Stack<size, Elem>::push (Elem e){
if (index < size){
index += 1;
space[index] = e;
}
}
template<int size, class Elem>
// Entnehmen
void Stack<size, Elem>::pop (Elem &e){
if (index >= 0){
e = space[index];
index -= 1;
}
}
6.2.3
Eine Vektorschablone
Als weiteres Beispiel für ein Klassentemplate definieren wir eine Vektorschablone mit der Vektoren mit bliebigen
Elementtypen und beliebiger Größe (Dimension) erzeugt werden können:
//-Deklaration------------------template <class T, int dim>
class Vektor {
public:
Vektor ();
Vektor (T (&x)[dim]);
Vektor operator+ (Vektor);
private:
T a[dim];
};
//-Verwendung-------------------int main () {
Programmierung II
172
int
x[3] = {1, 2, 3}; int y[3]
= {-1, -2, -3};
float u[2] = {1.5, 2.5}; float t[2] = {-1.2, -2.2};
Vektor<int,3>
v1(x), // 3 int-Vektoren der Laenge 3
v2(y), // jweils durch ihren Konstruktor
v3;
// initialisiert
Vektor<float,2> w1(u),
w2(t),
w3;
v3 = v1 + v2;
w3 = w1 + w2;
}
//-Definition-der-Methoden------template<class T, int dim>
Vektor<T, dim>::Vektor () {
for (int i=0; i<dim; i++) a[i] = 0;
}
template<class T, int dim>
Vektor<T, dim>::Vektor (T (&x)[dim]) {
for (int i=0; i<dim; i++) a[i] = x[i];
}
template<class T, int dim>
Vektor<T, dim> Vektor<T, dim>::operator+ (Vektor v) {
Vektor res;
for (int i=0; i<dim; i++) res.a[i] = a[i] + v.a[i];
return res;
}
6.2.4
Eine einfache Listenschablone
Ein Kennzeichen für Behälterklassen ist die weitgehende Unabhängigkeit ihrer Methoden von den enthaltenen
Komponenten. Eine int–Liste unterscheidet sich kaum von einer float oder string–Liste. Schablonen sind
darum bestens geeignet Behälterklassen, wie Listen, Mengen, Abbildungen etc. zu realisieren.
Schablonen sollte man generell zuerst in einer Nicht–Schablonen–Version entwickeln und testen und sie danach
dann in eine Schablone umsetzen. Im Wesentlichen besteht diese Umsetzung darin, den konkreten Typ der Beispielspezialisierung durch den Typparameter der Schablone zu ersetzen.
Mit dieser Methode ist eine einfache Listenschablone schnell aus einem int–Beispiel erzeugt. Zuerst die Klassendefinition:
template <class T>
class List {
public:
List ();
˜List ();
List (const List &);
List & operator=
void insert
(T
void erase
(T
void eraseAll (T
int isIn
(T
private:
class Node {
public:
Node ();
(const List &);
i);
i);
i);
i);
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
173
Node (T, Node *);
˜Node ();
T
v;
Node * next;
};
Node *head;
void erase_r (T i, Node * &);
};
Die Definition der Methoden bietet ebenfalls keine besonderen Probleme oder Überraschungen:
template<class T> List<T>::Node::Node ()
: next(0){}
template<class T> List<T>::Node::Node (T i, Node * n) : v(i), next(n){}
template<class T> List<T>::Node::˜Node ()
{ delete next; }
template<class T> List<T>::List () : head (0) {}
template<class T> List<T>::˜List ()
{ delete head; }
template <class T>
List<T>::List (const List &p_l) {
Node **l = &head, *n;
for(Node * p = p_l.head; p != 0; p = p->next){
n = new Node (p->v, 0);
*l = n;
l = &(n->next);
}
}
template <class T>
List<T> & List<T>::operator= (const List &p_l) {
if (head != p_l.head) {
delete head;
head = 0;
Node **l = &head, *n;
for(Node * p = p_l.head; p != 0; p = p->next){
n = new Node (p->v, 0);
*l = n;
l = &(n->next);
}
}
return *this;
}
... etc. ...
Man beachte, dass die Schablone hier eine geschachtelte Klasse Node enthält sowie die Syntax, mit der die Methoden der geschachtelten Klasse definiert werden. In
template<class T> List<T>::Node::Node () : next(0)
bezeichnet beispielsweise List<T>::Node den Typ Node innerhalb der Liste List<T>.
6.2.5
Initialisierung in Schablonen
Die Initialisierung von Objekten mit einem Parametertyp ist mit Vorsicht zu behandeln. Wird beispielseweise eine
Integer–Version der Liste mit folgendem Konstruktor
List::Node::Node () : v(0), next(0)
//OK
erfolgreich getestet und man wandelt das ganze dann in eine Schablone um:
template<class T> List<T>::Node::Node (): v(0), next(0)
//SCHLECHT
Programmierung II
174
werden sich unter Umständen schwer zu lokalisierende Fehler einstellen. Der Grund ist die explizite Initialisierung
der v–Komponente mit 0. Bei einem Integer macht das Sinn. Für beliebige andere Typen ist dieser Konstruktoraufruf nicht unbedingt sinnvoll. Man überläßt die Initialisierung darum besser dem Default–Konstruktor:
template<class T> List<T>::Node::Node (): next(0)
6.2.6
//OK
Schablone in UML
Eine Klassenschablone Stack mit Parameter T und size
template>class T, int size>
class Stack { ... };
wird in UML änhnlich wie eine Klasse dargestellt (siehe Abbildung 67)
T
size:int
Stack
Abbildung 67: Ein Template in UML
Das Schablonen– (Template–) Symbol kann wie bei einer Klasse mit weiteren Informationen über Attribute und
Methoden ausgestattet werden.
6.3
6.3.1
Schablone mit Freunden
Arten von Schablone–Freunden
Bei der Freundschaft in Zusammenhang mit Schablone gilt stets, dass Freundschaft immer eine Beziehung zwischen Klassen oder zwischen Klassen und Funktionen und nicht zwischen Schablonen ist. Wird in einer Klassenschablone eine Freundschaft zu einer Klasse oder einer anderen Schablone erklärt, dann bezieht sich dies letztlich
immer auf Instanzen der Schablone.
Schablonen können auf verschiedene Arten Freundschaften erklären:
Freunde die selbst keine Schablone sind:
Eine Klasse ist ist Freund jeder Instanz.
Gebundene Schablonen–Freundschaft:
Instanzen einer Klassenschablone sind befreundet, wenn sie mit den gleichen Parametern instanziiert wurden.
Ungebundene Schablonen–Freundschaft:
Instanzen einer Klassenschablone sind befreundet, egal mit welchen Parametern sie instanziiert wurden.
Die in der Praxis wichtigste Art der Freundschaft ist die gebundene Freundschaft von Schablonen.
Neben Klassen können auch Funktionen Freunde einer Klasse sein. Dabei gelten die gleichen Regeln. Wir erörtern
darum nur die Klassenschablonen als Freunde ausführlich.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
6.3.2
175
Freunde die keine Schablonen sind
Im einfachsten Fall erklärt die Schablone einer bestimmten Klasse oder Funktion die Freundschaft. Der Freund
einer Schablone selbst keine Schablone. Betrachten wir dazu ein Beispiel:
template <class T>
class Vektor {
friend class C; // ein Nicht-Template Freund, die Klasse C
public:
Vektor ();
Vektor (T, T);
private:
T
x, y;
int z;
// nicht vom Template-Parameter abhaengig
};
typedef Vektor<int>
VI; // Zwei Instanzen
typedef Vektor<float> VF; // C ist ein Freund jeder Instanz
VI vgi (0, 0);
VF vgf;
// Variablen vom Typ
// der Instanzen
struct C {
static void spion ();
};
// Der Freund
// aller Vektoren
void C::spion () {
cout << vgi.z << endl;
cout << vgf.z << endl;
}
// C hat Zugriff auf jede Instanz
// kann aber mit x und y nichts anfangen
// da deren Typ Templateparamenter ist.
Die Klasse C ist ein Freund aller Instanzen von Vektor. Sie hat Zugriff auf alle Komponenten aller Instanzen.
Das sieht auf den ersten Blick wie ein großer Vorteil aus, bringt aber tatsächlich nicht sehr viel. Die Klasse C kann
nichts von Vektor benutzen, das irgendwie vom Parameter T abhängt. Die Zahl der sinnvollen Anwendungen
dieser Art von Freundschaft ist darum auch eher begrenzt.
Als Beispiel für den Einsatz der Nicht-Schablonen–Freundschaft könnte man eine Klasse definieren, die die Zahl
der Objekte von verschiedenen Instanzen einer Klassenschablone zählt. Dies ist auch gleichzeitig ein Beispiel für
statische Mitglieder einer Schablone:
template<class T>
class Vektor {
friend class F;
// F ist Freund all meiner Instanzen!
public:
Vektor ();
T x,y;
private:
static int vZaehler;
};
template<class T>
Vektor<T>::Vektor () { vZaehler++; }
template<class T>
int Vektor<T>::vZaehler = 0;
typedef Vektor<int>
VI;
typedef Vektor<float> VF;
Programmierung II
176
class F { // Der Freund der Vektoren
public:
// stellt fest wie viele es gibt
void vektoren ();
};
void F::vektoren() {
cout << "int
Vektoren: " << VI::vZaehler << endl;
cout << "float Vektoren: " << VF::vZaehler << endl;
}
int main () {
VI vi_1, vi_2, vi_3, vi_4, vi_5;
VF vf_1, vf_2, vf_3, vf_4;
F f;
f.vektoren();
}
Jede Instanz der Klassenschablone Vektor hat ihr eigenes Exemplar der statischen Datenkomponente
vZaehler. F ist ein Freund und kann darum jede dieser statischen Komponenten lesen. Das Programm erzeugt
darum die Ausgabe:
int
Vektoren: 5
float Vektoren: 4
6.3.3
Gebundene Schablonenfreunde
Freunde von Schablonen sind in der Regel selbst Schablonen. Im Fall eines gebundenen Schablonen–Freundes ist
jeder Instanz der Schablone eine Instanz des Freundes zugeordnet. Betrachten wir ein Beispiel:
#include <iostream>
template<class TT>class C; // Deklaration von C, dem Freund
template <class T>
class Vektor {
friend class C<T>;
// C muss deklariert sein!
public:
// Jede Instanz von C mit dem gleichen T
Vektor ();
// ist mein Freund!
Vektor (T, T);
private:
T x, y;
};
//------------------------------template<class T>
// Definition von C,
struct C {
// dem Freund-Template
void spion (Vektor<T>);
};
//------------------------------template<class T>
void C<T>::spion (Vektor<T> v) {
cout << v.x << endl;
// Der Freund hat
cout << v.y << endl;
// Zugriff auf Komponenten vom Typ T
}
//------------------------------int main () {
typedef Vektor<int> V_I;
typedef C<int>
C_I; // ein Freund von VI
typedef C<float>
C_F; // KEIN Freund von VI
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
V_I
C_I
ci.spion(vi);
177
vi;
ci;
}
//------------------------------template<class T>
Vektor<T>::Vektor () : x(0), y(0) {}
template<class T>
Vektor<T>::Vektor (T p1, T p2) : x(p1), y(p2) {}
Hier sind nur “passende” Instanzen befreundet, d.h solche die mit den gleichen Argumenten erzeugt wurden. Man
erkennt dies daran, dass innerhalb von
template<class T> class Vektor
friend class C<T>; ...
;
mit:
friend class C<T>;
gesagt wird, dass (nur) die Spezialisierung von C mit dem aktuellen T ein Freund ist. Diese Art der Schablonen–
Freundschaft ist sicherlich die im praktischen Einsatz wichtigste Variante.
6.3.4
Ungebundene Schablonenfreunde
Bei der ungebundenen Schablonenfreundschaft werden alle Instanzen einer anderen Schablone zu Freunden aller
Instanzen dieses Schablone erklärt. Beispiel:
#include <iostream>
template <class T>
class Vektor {
template<class TT> friend class C;
public:
Vektor ();
Vektor (T, T);
private:
T x, y;
};
// Alle Instanzen von C
// sind Freunde all meiner Instanzen!
//------------------------------typedef Vektor<int> V_I; // Zwei Instanzen
typedef Vektor<char> V_C;
V_I
vi;
V_C
vc;
//------------------------------template<class T>
// Alle Instanzen von C sind Freunde
struct C {
// aller Instanzen von Vektor
void spion ();
};
template<class T>
// Zugriff auf alle Sorten von Vektoren
void C<T>::spion () {
cout << vi.x << endl;
cout << vi.y << endl;
cout << vc.x << endl;
cout << vc.y << endl;
}
typedef C<int>
C_I; // C_I ist
ein Freund von V_I und V_C
typedef C<float>
C_F; // C_F ist AUCH ein Freund von V_I und V_C !
178
Programmierung II
//------------------------------int main () {
C_I ci;
C_F cf;
ci.spion();
cf.spion();
}
//------------------------------template<class T>
Vektor<T>::Vektor () : x(0), y(0) {}
template<class T>
Vektor<T>::Vektor (T p1, T p2) : x(p1), y(p2) {}
Hier sind alle Instanzen von C Freunde aller Instanzen von Vektor. An der etwas bemühten Konstruktion sieht
man, dass sich die praktische Bedeutung dieser Art der Freundschaft in Grenzen hält.
Der Schablonen–Parameter in C wird gar nicht benutzt, das Beispiel wird damit zu einer Variante der Freundschaft
mit einer Nicht–Template–Klasse. Im Endeffekt müssen die Schablonen–Argumente von Vektor und C entweder
ohne Bezug oder gleich sein. Hier sind sie ohne Bezug. Wenn sie gleich sind, wie in folgendem Beispiel:
#include <iostream>
template <class T>
class Vektor {
template<class TT> friend class C;
public:
Vektor ();
Vektor (T, T);
private:
T x, y;
};
//------------------------------template<class T>
// Definition von C,
struct C {
// dem Freund-Template
void spion (Vektor<T>); // alle Instanzen sind Freunde
};
// aller Instanzen von Vektor
//------------------------------template<class T>
void C<T>::spion (Vektor<T> v) {
cout << v.x << endl;
cout << v.y << endl;
}
//------------------------------int main () {
typedef Vektor<int> V_I;
typedef C<int>
C_I; //
ein Freund von VI
typedef C<float>
C_F; // AUCH ein Freund von VI
V_I
vi;
C_I
ci;
C_F
cf;
ci.spion(vi);
// OK
cf.spion(vi);
// NICHT OK:
// cf ist zwar ein Exemplar einer befreundeten
// Klasse, kann aber nicht aktiv werden, da
// die Parameter-Typen NICHT PASSEN !
}
//------------------------------template<class T>
Vektor<T>::Vektor () : x(0), y(0) {}
template<class T>
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
179
Vektor<T>::Vektor (T p1, T p2) : x(p1), y(p2) {}
dann verwendet man besser die einfachere gebundene Version der Schablonen–Freundschaft. So wie wir es oben
getan haben.
6.3.5
Funktionsschablone als Freunde einer Klassenschablone
Klassenschablonen können auch Funktionsschablonen als Freunde haben. Hier gibt es die gleichen Varianten mit
den gleichen Regeln wie bei der Freundschaft von Klassenschablonen. Der Standardfall ist auch hier die gebundene
Freundschaft von Schablonen. Beispiel:
template<class T>
class Vektor {
friend Vektor operator+ <T> (const Vektor &, const Vektor &);
public:
Vektor ();
Vektor (T, T);
...
private:
T x, y;
};
...
template<class T>
Vektor<T> operator+ (const Vektor<T> &v1, const Vektor<T> &v2) {
return Vektor<T> (v1.x+v2.x, v1.y+v2.y);
}
Oder, als weiters Beispiel, eine Listenschablone mit Ausgabefunktion:
template <class T>
class List {
friend ostream & operator<< <T> (ostream &, const List<T> &);
public:
List ();
...
private:
class Node {
public:
...
T
v;
Node * next;
};
Node *head;
};
...
template <class T>
ostream & operator<< (ostream &os, const List<T> &l) {
for(List<T>::Node * p = l.head; p != 0; p = p->next)
os << p->v << ", ";
return os;
}
6.4
6.4.1
Datenstrukturen und Algorithmen mit Schablonen
Sortieren mit Quicksort
Übliche Algorithmen können leicht in Funktionsschablonen umgewandelt werden. Beispielsweise Quicksort:
Programmierung II
180
template<class T>
void swap (T &x, T &y) { T t = x; x = y; y = t; }
// Quicksort
// sortiert Elemente vom Typ E mit Schluessel key
//
template<class E>
void quicksort (E a[], int l, int r) {
int i, j;
E x;
if (l>=r) return;
i=l; j=r; x=a [(l+r)/2];
while (i<=j) {
while ( a[i].key < x.key ) ++i;
while ( a[j].key > x.key ) --j;
if (i<=j) {
swap (a[i], a[j]); ++i; --j;
}
}
quicksort (a, l, j);
quicksort (a, i, r);
}
struct Elem { // ein Beispiel zum Test
int key;
int val;
};
int main () {
Elem array[10] = {{6,6},{5,5},{4,4},{1,1},{2,2},{0,0},{6,6},{9,9},{7,7},{6,6}};
quicksort (array, 0, 9);
...
}
Der Algorithmus sortiert ein Feld von Elementen, die aus einem Schlüssel und einem Wertfeld bestehen. Der Typ
des Schlüssels ist nur insofern relevant, als dass auf ihm die Vergleichsoperationen definiert sein müssen. Der Typ
der Werte ist völlig irrelevant.
6.4.2
Klassendefinition der Listenschablone
Die Listenschablone hat einen Parameter, den Typ der Elemente. Ihre Freunde sind die beiden Iteratoren.
template<class E> class ListenIterator;
// gebundene Templatefreunde
template<class E> class ListenConstIterator; // muessen Voraus deklariert
// werden
template<class E>
class Liste {
friend class ListenIterator<E>;
friend class ListenConstIterator<E>;
public:
Liste ();
// Default-Konstruktor (leere Liste)
˜Liste ();
Liste (const Liste &);
// Kopier-Konstruktor
Liste & operator= (const Liste &);
Liste
operator+ (const Liste &);
// Zuweisung
// Verkettung
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
void
void
void
void
einAnfang
einEnde
einHier
loescheHier
ListenIterator<E>
ListenIterator<E>
(E);
//
(E);
//
(ListenIterator<E> &, E);
(ListenIterator<E> &); //
181
am
am
//
An
Anfang einfuegen
Ende einfuegen
Vor dem Iterator einfuegen
Iteratorposition loeschen
anfang ();
ende ();
ListenConstIterator<E>
ListenConstIterator<E>
constAnfang () const;
constEnde
() const;
private:
class Knoten {
public:
Knoten ();
Knoten (E, Knoten *, Knoten *);
˜Knoten ();
Knoten * n; // naechster Knoten
Knoten * v; // vorheriger Knoten
E
wert;
};
Knoten *erster;
Knoten *letzter;
};
Die beiden Iteratorklassen sind gebundene Freunde da nur solche Iteratoren sinnvoll mit der Liste zusammenarbeiten können, die mit dem gleichen Parameter instanziert wurden.
6.4.3
Die Iteratoren
Die Klassendefinition der beiden Iteratoren ist:
template<class E>
class ListenIterator {
friend class Liste<E>;
public:
ListenIterator (Liste<E> &, Liste<E>::Knoten *);
E &
operator* ();
ListenIterator<E> & operator++ ();
bool
operator== (const ListenIterator &) const;
bool
operator!= (const ListenIterator &) const;
private:
Liste<E> &l;
Liste<E>::Knoten *pos;
};
// Liste
// aktuelle Position in der Liste
template<class E>
class ListenConstIterator {
friend class Liste<E>;
public:
ListenConstIterator (const Liste<E> &, const Liste<E>::Knoten *);
E
operator* ();
ListenConstIterator & operator++ ();
bool
operator== (const ListenConstIterator &) const;
bool
operator!= (const ListenConstIterator &) const;
Programmierung II
182
private:
const Liste<E> &l;
const Liste<E>::Knoten *pos;
};
// Liste
// aktuelle Position in der Liste
Die Definitionen der Schablonen wurden durch einfaches Umformen aus den Klassendefinitionen erzeugt.
6.4.4
Liste Sortieren
Eine Liste nach dieser Definition kann sortiert werden:
template<class T>
void swap (T &x, T &y) { T t = x; x = y; y = t; }
// Sortiertemplate,
// Die Faehigkeiten die von L und L_ITER gefordert sind,
// erkennt man nur durch Lesen der Definition von sort.
//
template<class L, class L_ITER>
void sort (L &l) {
for (L_ITER i=l.anfang(); i!=l.ende(); ++i)
for (L_ITER j=i; j!=l.ende(); ++j)
if (*j > *i)
swap (*i, *j);
}
int main () {
Liste<int> l;
....
// Liste sortieren:
sort<Liste<int>, ListenIterator<int> > (l);
//Liste ausgeben:
for (ListenConstIterator<int> i = l.constAnfang(); i != l.constEnde(); ++i) {
cout << *i<<",";
}
...
}
Die Sortierschablone hat den Typ der Liste und den Listeniterator als Parameter. Die Liste und ihr Iterator, die
als aktuelle Schablonenparameter verwendet werden, müssen natürlich zueinander und zum Sortieralgorithmus
passen. Die Liste muss beispielsweise den Dereferenzierungsoperator unterstützen, eine Methode anfang bieten,
etc.
Der Sortieralgorithmus und die Liste sind also eng gekoppelt. Diese Kopplung kommt in den formalen Schablonenparametern class L und class L ITER nur informal in den Namen zum Ausdruck.
6.4.5
Liste und ihre Sortierung enger koppeln
Der Sortieralgorithmus ist auf exakt die Fähigkeiten des Listentemplates und seines Iterators zugeschnitten. Es ist
besser, wenn dies auch formal zum Ausdruck kommt. Etwa indem Liste und Listeniterator explizit im
Sortieralgorithmus auftreten:
template<class T>
void swap (T &x, T &y) { T t = x; x = y; y = t; }
// sort ist offensichtlich auf Liste und Listeniterator
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
183
// zugeschnitten, E ist ein beliebiger Typ von dem nur
// die Unterstuetzung von > verlangt wird.
//
// E ist Parameter; Liste und ListenIterator
// werden fuer alle sichtbar benutzt
//
template<class E>
void sort (Liste<E> &l) {
for (ListenIterator<E> i=l.anfang(); i!=l.ende(); ++i)
for (ListenIterator<E> j=i; j!=l.ende(); ++j)
if (*j > *i)
swap (*i, *j);
}
int main () {
Liste<int> l;
...
sort<int> (l);
...
}
// Sortieren
Der Parameter E in dieser Variante von sort kann zwar ebenfalls nicht durch jeden beliebigen Typ ersetzt werden,
die Anforderungen an einen aktuellen Parameter sind aber im Vergleich zur vorherigen Variante sehr einfach: E
muss lediglich die Vergleichsoperation > anbieten.
6.5
6.5.1
Standard Template Library STL
Die C++ Standardbibliothek
Zu C++ gehört nicht nur die reine Sprachdefinition sondern auch die sogenannte Standardbiblithek, eine Sammlung
nützlicher Funktions— und Typdefinitionen, die mit jeder C++–Implemetierung ausgeliefert werden müssen. Ein–
/Ausgabe–Typen, Strings und diverse andere nützliche Dinge sind Bestandteil der Standardbibliothek.
Die Standardbibliothek ist eine wichtige Komponente der Sprache deren Kenntnis manche Programmierarbeit
einsparen kann.
6.5.2
STL, die Standard Template Library
Schablonen sind ideal geeignet zur Formulierung von Datenstrukturen und Algorithmen. Listen und andere
Behälterklassen und die entsprechenden Algorithmen auf ihnen sind Bestandteil fast jeden Programms. Es ist
darum nicht verwunderlich, dass die Standardbibliothek in Form der Standard Template Library – STL – eine
Kollektion an Funktions– und Klassenschablonen enthält.
Die STL besteht aus drei wesentlichen Komponenten:
Behälter (engl. Container): Klassentemplates für Listen, Vektoren, und andere Behälterklassen
Iteratoren mit denen Behälter und andere Kollektionen von Objekten durchwandert werden können.
Algorithmen mit den Kollektionen von Objekten durchsucht, sortiert oder auf andere Art durchlaufen und
manipuliert werden können.
Alle Bestandteile der STL sind natürlich Schablonen.
6.5.3
Listen
Als Beispiel formulieren wir einen Sortieralgorithmus auf STL–Listen:
Programmierung II
184
#include <utility>
#include <list>
#include <iostream>
// STL swap-Template
// STL Listen-Template list
using namespace std;
int main () {
list<int> l;
// Liste einlesen
int x;
do {
cin >> x;
if ( x == 0 ) break;
l.push_back(x);
}
while (true);
// Liste definieren
// an Liste anhaengen;
// push_front setzt ein Element an den Anfang
// Liste sortieren:
list<int>::iterator i, // STL Listen-Iteratoren
j;
for ( i=l.begin(); i!=l.end(); ++i )
for ( j=i; j!=l.end(); ++j)
if (*j > *i)
swap (*i, *j);
// swap ist ein Funktionstemplate aus utility
// Liste ausgeben:
for ( i=l.begin(); i!=l.end(); ++i )
cout << *i << endl;
}
STL–Listen sind intern als doppelt verkettete Listen implementiert. Sie sind für alle Anwendungen geeignet, bei
denen Elemente wahllos eingefügt oder gelöscht werden.
6.5.4
Vektoren
STL–Vektoren sind die bessere Alternative zu Listen, wenn die verwaltete Kollektion der Werte zwar beliebig
wachsen kann, aber wahllose Einfüge– oder Lösch–Operationen nicht benötigt werden. Unser Sortierbeispiel ist
von dieser Art:
#include <vector>
#include <algorithm>
#include <iostream>
// STL-vector
// enthaelt sort
using namespace std;
int main () {
vector<int> v;
// Vektor einlesen
int x;
do {
cin >> x;
if ( x == 0 ) break;
v.push_back(x);
} while (true);
// Vektor sortieren:
// an Vektor anhaengen
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
185
sort (v.begin(), v.end()); // sort-Template
// v.begin() und v.end() sind Iteratoren
// Vektor ausgeben:
for ( unsigned int i=0; i<l.size(); ++i )
cout << v[i] << endl;
}
Vektoren sind im Gegensatz zu Listen auf schnellen Zugriff optimiert. Sie erlauben den wahlfreien Zugriff mit
einem Index und können mit dem Sortier–Template sort der STL bearbeitet werden. sort sortiert den Bereich,
der mit zwei Iteratoren angegeben wird.
6.5.5
Weitere Behälterklassen
Vektoren und Listen sind nur zwei der Klassentemplates aus der STL. Es gibt
Mengen (set) und Multimengen (multiset),
Warteschlangen (deque, double ended queue), sowie
Abbildungen (map und multimap) in denen Schlüssel–Wert–Paare (Tabellen) gespeichert werden. In einer
multimap kann einem Schlüssel mehr als ein Wert zugeordnet sein.
Über die Implementierung der Behälter sagt der Standard nichts, das ist Sache des jeweiligen Produkts. Mengen
und Abbildungen können beispielsweise intern als Binärbäume oder auch als Hash–Tabellen implementiert sein.
In jedem Fall kann man aber davon ausgehen, dass es beträchtlicher Anstrengungen bedarf, um eine eigene Klasse
von entsprechender Qualität zu entwickeln.
Ein einfaches Beispiel für die Verwendung einer Abbildung zum Sortieren nach einem Schlüssel ist:
#include <map>
#include <string>
#include <iostream>
using namespace std;
int main () {
map<string, string> m;
string k, v;
for (int i=0; i<10; ++i) {
cout << "K: "; cin >> k;
cout << "V: "; cin >> v;
m[k] = v;
}
// Paare aus Schluessel und Wert einlesen
// und (sortiert) in m abspeichern
// Nach dem Schluessel sortiert ausgeben:
// i ist ein Iterator ueber m
//
for ( map<string, string>::iterator i = m.begin();
i != m.end();
++i )
cout << i->first << ", " << i->second << endl;
}
6.5.6
Algorithmen: Permutationen
Mit Hilfe der STL ist es sehr einfach alle Permutationen der Elemente einer Liste zu berechnen:
Programmierung II
186
#include <list>
#include <algorithm>
#include <iostream>
list<int> l;
using namespace std;
int main() {
for (int i=0; i<5; ++i) // Liste mit den ersten 5 ganzen Zahlen fuellen
l.push_back(i);
do {
for ( list<int>::iterator j = l.begin(); // Eine Permutation ausdrucken
j != l.end();
++j)
cout << *j << ",";
cout << endl;
} while ( next_permutation(l.begin(), l.end()) ); // Schleife ueber alle
}
// Permutationen der Liste
Die Funktion next permutation erzeugt solange neue Permutation einer Kollektion, die durch zwei Iteratoren
begrenzt wird, bis diese sortiert ist. Genauer gesagt liefert next permutation dann false, wenn das Ergebnis
seiner Aktivität sortiert ist. Beginnt man mit einer sortierten Folge, dann zerstört der erste Aufruf die Sortierung
und alle Permutationen werden solange durchlaufen, bis sie wieder hergestellt ist.
6.5.7
Algorithmen: Problem des Handlungsreisenden
Um einen Eindruck von der Mächtigkeit der Algorithmen von STL zu bekommen, wollen wir möglichst einfach
das Problem des Handlungsreisenden lösen. Ein Handlungsreisender hat eine Reihe von Städten zu besuchen und
sucht die Rundreise, die durch jede Stadt führt und dabei möglichst kurz ist. Das Problem ist berühmt, weil es eine
außerordentliche praktische Bedeutung18 hat, aber nur mit hohem rechnerischem Aufwand gelöst werden kann.
Wir nehmen an, dass unser Handlungsreisender mit einem Flugzeug reist und die Länge des Wegs sich aus der
Entfernung der Punkte ergibt. Wir lösen das Problem einfach dadurch, dass wir die Länge aller möglichen Wege
bestimmen und den Weg mit geringsten Länge ausgeben.
#include
#include
#include
#include
#include
<list>
<utility>
<cmath>
<algorithm>
<iostream>
// Typdefinitionen Stadt und Weg
//
typedef pair<float, float> Stadt;
typedef list<Stadt>
Weg;
// eine Stadt mit ihren Koordinaten
// ein Weg ist eine Liste von Staedten
//----------------------------------------------------------------------// Hilfsfunktionen
// Distanz zweier Staedte und Laenge eines Wegs
// Vergleich von zwei Stadten
//
inline float dist ( Stadt s1, Stadt s2 ){
// Distanz zweier Staedte
if (s1 == s2) return 0;
return std::sqrt ((s1.first-s2.first)*(s1.first-s2.first)
+ (s1.second-s2.second)*(s1.second-s2.second));
18 Nicht
nur für Handlungsreisende, in vielen Systemen müssen Dinge mit möglichst geringem Aufwand verbunden werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
187
}
float wegLaenge (Weg w) { // Laenge eines Wegs
if ( w.size() < 2 ) return 0; // Weniger als 2 Stationen -> Laenge = 0
float l;
Weg::const_iterator v = w.end();
// Vorgaenger
for (Weg::const_iterator i = w.begin(); // einzelne Wege addieren
i != w.end();
// i ist die aktuelle Station
++i) {
if ( v == w.end() )
// erste Station
l = 0;
else
l = l + dist(*v,*i); // Entfernung vom letzten zu diesem
}
return l;
}
bool operator< (Stadt s1, Stadt s2) {
if (s1.first < s2.first) return true;
if (s1.first == s2.first)
return (s1.second < s2.second);
return false;
}
// Vergleich von Staedten
// Wird zum Sortieren gebraucht
//----------------------------------------------------------------------//
int main() {
Weg staedte;
// Stadte-Liste einlesen;
for (int i=0; i<4; ++i) {
float x, y; // Koordinaten;
std::cin >> x >> y;
staedte.push_back(pair<float,float>(x,y));
}
Stadt start = *staedte.begin();
// Alle Wegpermutationen
// Die kuerzeste in kWeg
//
Weg kWeg;
//
float l=1000000000.0; //
berechen
speichern
bisher gefundener kuerzester Weg
Laenge dieses Wegs
Weg rest = staedte;
rest.pop_front();
// alle Staedte ausser Startpunkt
rest.sort();
// sortieren damit wir permutieren koennen
do { // Alle moeglichen Permutationen von rest berechnen
// daraus und mit start einen Weg bestimmen und messen
//
Weg weg = rest;
// ein Weg: start->Permutation(rest)->start
weg.push_front(start);
weg.push_back(start);
float nl = wegLaenge(weg);
if (nl < l) { l = nl; kWeg = weg; }
} while ( next_permutation(rest.begin(), rest.end()) );
// Gefundene Loesung ausgeben
Programmierung II
188
for (Weg::const_iterator i = kWeg.begin();
i != kWeg.end();
++i )
cout << "("<<i->first<<","<<i->second<<") ";
}
In diesem Programm werden alle möglichen Wege als Permutationen der Städteliste berechnet und die jeweilige
Weglänge bestimmt. Die Permutation mit der kürzesten Weglänge ist die gesuchte Lösung.
Diese Beispiele sollten gezeigt haben, dass die STL mächtige und elegante Konstrukte zur Verfügung stellt. Die
Fülle der Möglichkeiten wurde dabei nur angedeutet. Zur Standardbibliothek von C++ im Allgemeinen und der
STL im Besonderen verweisen wir auf die Literatur.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
6.6
189
Übungen
Aufgabe 1
Die Nullstelle einer stetigen Funktion
werden:
double nullStelle ( double
double
double
double
im Intervall von bis kann mit folgender Funktion gefunden
(*f)(double),
u,
// untere Intervallgrenze
o,
// obere Intervallgrenze
eps ) { // Genauigkeit: minimale Intervallgroesse
// existiert eine Nullstelle
assert (f(u)*f(o) < 0);
while ( eps < (o-u) ) {
double m = (o+u)/2.0;
if ( f(m)*f(o) < 0 )
u = m;
else
o = m;
}
return (u+o)/2.0;
// Mitte
// f(m) und f(o) haben unterschiedliche Vorzeichen
// wir suchen weiter im obereren Intervall
// wir suchen weiter im unteren Intervall
}
Wandeln Sie die Funktion nullStelle in ein Funktionstemplate um, bei dem die Funktion Templateparameter
ist.
Aufgabe 2
Gegeben sei folgende Definition zweidimensionaler Integer–Vektoren:
class Vektor {
friend ostream & operator<< (ostream &, const Vektor &);
friend istream & operator>> (istream &, Vektor &);
public:
Vektor ();
Vektor (int, int);
int & x ();
// x--Koordinate
int & y ();
// y--Koordinate
Vektor operator+ (const Vektor &) const;
// Vektoraddition
private:
int x_, y_;
};
1. Wandeln Sie diese Definition in ein Klassentemplate um (der Typ der Vektorkomponenten ist der Template–
Parameter).
2. Geben Sie eine Implementierung aller Methoden des Klassentemplates an.
3. Geben Sie eine Implementierung der Ein–/Ausgabeoperatoren für das Klassentemplate an.
Aufgabe 3
Schreiben Sie ein Template sort zum Sortieren eines Feldes in folgenden Varianten:
Programmierung II
190
1. Alle Instanzen von sort sortieren aufsteigend, die Feldgröße wird beim Aufruf einer Instanz angegeben
(die Feldgröße ist also kein Templateparameter sondern Funktionsparameter). Jede Instanz kann Felder beliebiger Größe sortieren.
2. Wie oben, aber ausserdem ist die Sortierrichtung (auf– oder abwärts) Templateparameter.
3. Wie oben, aber jetzt ist die Sortierrichtung Funktionsparameter und die Feldgröße Templateparameter.
Geben Sie jeweils ein Beispiel für die Verwendung Ihres Templates an.
Aufgabe 4
Ein erster Versuch, einen Datentyp für (N,M)–Matrizen mit beliebigen Elementen zu definieren, hat zu folgendem
Template geführt:
#include <vector>
#include <iostream>
template<int N, int M, class T>
class Matrix {
public:
Matrix () : mat(N) {
for ( int i=0; i<N; ++i)
mat[i].resize(M);
}
vector<T> operator[] ( unsigned int i ) { return mat[i]; }
private:
vector< vector<T> > mat;
};
Die Größe eines Vektors kann mit einem Konstruktor und mit resize gesetzt werden:
vector<int> v(5);
v.resize (10);
// v hat 5 Elemente
// v hat jetzt 10 Elemente
Leider gibt das kleine Testprogramm
int main () {
Matrix<3,4,int> matrix;
for ( int i=0; i<3; ++i )
for ( int j=0; j<4; ++j )
matrix[i][j] = 10*i+j;
for ( int i=0; i<3; ++i ) {
for ( int j=0; j<4; ++j ) {
std::cout.width(4);
std::cout << matrix[i][j];
}
std::cout << std::endl;
}
}
statt der erwarteten Werte
0
10
20
1
11
21
2
12
22
3
13
23
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
191
Folgendes aus:
0
0
0
0
0
0
0
0
0
0
0
0
Analysieren Sie die Template–Klasse, identifizieren Sie den Fehler und beseitigen Sie ihn! Sind die Instanzen des
Templates konkrete Datentypen?
Aufgabe 5
Eine dünn besetzte Matrix ist eine Matrix, bei der fast alle Elemente den Wert Null haben. Definieren Sie ein
Klassen–Template zur Implementierung von 2–dimensionalen dünn besetzten Matrizen. Überlegen Sie was Templateparameter ist und was Argument des Konstruktors ist. Ihre Klasse soll möglicht wenig Speicherplatz benötigen, möglichst einfach zu benutzen sein und so weit wie möglich und sinnvoll die Definitionen STL benutzen.
Selbstverständlich müssen die Instanzen Ihres Templates konkrete Datentypen sein.
Aufgabe 6
Die Determinante einer Matrix
werden (vergleiche Mathematikvorlesung):
wobei
streicht.
die kann rekursiv durch Entwicklung nach der ersten Spalte berechnet
Matrix ist, die aus
entsteht, wenn man die –te Zeile und die –te Spalte
Schreiben Sie ein Klassen–Template mit der Berechnung der Determinanten und der Untermatrizen als als Methoden und der Größe der Matrix als Templateparamter:
template<unsigned int n>
class Matrix {
friend istream & operator>> <n> (istream &, Matrix<n> &);
friend ostream & operator<< <n> (ostream &, const Matrix<n> &);
private:
...
Matrix<n-1> coMatrix ( unsigned int x, unsigned int y );
...
public:
...
float det ();
...
};
Hinweis: Benutzen Sie partielle Instanziierung um die Template-Rekursion abzubrechen.
Programmierung II
192
7
Vererbung
7.1
Basisklasse und Abgeleitete Klasse
7.1.1
Basisklasse und Abgeleitete Klasse: Art und Unterart
Die Unterscheidung zwischen Objekt und Klasse teilt die Welt auf in konkrete Objekte (die Kuh Elsa) und Klassen
(Arten, Typen) (die Klasse Kuh). Mit Konzepten der Vererbung können Klassen miteinander in Beziehung gesetzt
werden. Alle Kühe sind Säugetiere, die Kuh Elsa ist darum auch ein Säugetier. Man sagt Säugetier ist die Basisklasse und Kuh die abgeleitete Klasse, oder auch Kuh ist eine Ableitung von Säugetier. Klasse und abgeleitete
Klasse entsprechen Art und Unterart. Statt von “abgeleiteter Klasse” spricht man darum gelegentlich auch von
“Unterklasse”.
Mit einer Art will man das Gemeinsame ihrer Unteraten zum Ausdruck bringen. Kühe, Katzen, Menschen und
Affen haben Gemeinsamkeiten, die durch das Konzept “Säugetier” zum Ausdruck gebracht werden. Umgekehrt
unterscheiden sie sich aber trotzdem noch so weit, dass es sinnvoll ist weiterhin von Kühen, Katzen, Menschen und
Affen zu sprechen. Es geht also um die Unterschiede und die Gemeinsamkeiten von Objekten unterschiedlicher
Klassen, wenn diese als Basis– und abgeleitete Klasse in Beziehung gesetzt werden.
7.1.2
Geomtrische Objekte: Basisklasse und Abgeleitete Klassen
Die geometrischen Objekte der Klassen Vektor, Punkt und Gerade haben eine Gemeinsamkeit, es sind geometrische Objekte. Jeder Vektor oder Punkt und jede Gerade ist auch ein geometrisches Objekt. Dies kann in C++
mit einer Klasse Geo explizit definiert werden:
class Geo { ... };
// Basisklasse definieren
class Vektor : public Geo { ... };
class Punkt : public Geo { ... };
class Gerade : public Geo { ... };
// abgeleitete Klasse definieren
// ein Vektor, ein Punkt, eine Gerade
// ist jeweils (auch) ein Geo
Die Klasse Geo ist die Basisklasse. Vektor, Punkt und Gerade sind abgeleitete Klassen. Damit wird gesagt,
dass jedes Objekt der Klassen Vektor, Punkt und Gerade auch ein Objekt der Klasse Geo ist.
Das Schlüsselwort public in der Definition der abgeleiteten Klassen bringt die Beziehung der Klassen zum
Ausdruck.
7.1.3
Ableitung in UML
Die Ableitungsbeziehung wird in UML durch einen Pfeil von der abgeleiteten zur Basisklasse dargestellt (siehe
Abbildung 68)
Geo
Geo
oder
Gerade
Punkt
Vektor
Gerade
Punkt
Vektor
Abbildung 68: Ableitung in UML
7.1.4
Basisklasse: reines Konzept oder Typ realer Objekte
Es gibt kein Objekt das nur ein geometrisches Objekt ist, ohne gleichzeitig ein Punkt, eine Gerade oder ein Vektor
zu sein, so wie es auch kein Insekt gibt, das nur ein Insekt ist, ohne gleichzeitig ein Käfer, Schmetterling, oder
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
193
ähnliches zu sein. Die Basisklassen Geometrisches–Objekt und Insekt sind also reine Konzepte.
Basisklassen und ihre Ableitungen sind jedoch nicht auf die Konstellation beschränkt, bei der Ableitungen in einer Basisklasse als übergeordnetem abstrakten Konzept zusammengefasst werden. Die Basisklasse kann durchaus
auch der Typ realer Objekte sein. Beispielsweise kann Angestellte eine Klasse mit vielen realen Objekten sein:
Fräulein Meier, Frau Schulze, etc. sind Angestellte. Manche Angestellte gehören zum Management und sind darum Angestellte und Manager. Damit haben wir eine Basisklasse Angestellte und eine abgeleitete Klasse, die beide
der Typ realer Objekte sind.
7.1.5
Verallgemeinerung und Spezialisierung
Geo ist die Basisklasse, von ihr sind die Klassen Vektor, Punkt und Gerade abgeleitet. Die Beziehung zwischen den Klassen kann man in zwei Richtungen sehen:
Verallgemeinerung: Die Basisklasse Geo ist eine Verallgemeinerung ihrer Ableitungen Vektor, Punkt
und Gerade.
Spezialisierung: Die Ableitungen Vektor, Punkt und Gerade sind Spezialisierungen der Basisklasse.
7.1.6
Basisklassen flexibilisieren das Typsystem
Ein geometrisches Objekt kann ein Vektor, ein Punkt oder eine Gerade sein. Dies gilt auch für Variablen mit diesen
Typen:
class Geo { ... };
class Vektor : public Geo { ... };
class Punkt : public Geo { ... };
int main () {
Geo geo; Vektor vec; Punkt
geo = vec; // OK:
ein
geo = pun; // OK:
ein
pun = geo; // FEHLER: ein
vec = pun; // FEHLER: ein
vec = geo; // FEHLER: ein
}
pun;
Vektor ist auch ein Geo
Punkt ist auch ein Geo
Geo
ist kein Punkt
Punkt ist kein Vektor
Geo
ist kein Vektor
Man kann also eine Variable vom Basistyp Geo definieren und sie mit Werten eines abgeleiteten Typs belegen;
aber nicht umgekehrt. Das ist einsichtig: Immer wenn ein Insekt benötigt wird, kann man einen Käfer verwenden,
aber wenn ein Käfer verlangt wird, kann man nicht mit einem x–beliebigen Insekt kommen.
Bei der Parameterübergabe gilt das Gleiche. An einen formalen Parameter vom Basistyp kann ein Objekt mit einem
abgeleiteten Typ übergeben werden. Das gilt für Wertparameter, Referenzen und Zeiger. Beispiel:
class Geo { ... };
class Vektor : public Geo { ... };
class Punkt : public Geo { ... };
void f1 (Geo g)
{ ... }
void f2 (Geo & g) { ... }
void f3 (Geo * g) { ... }
void h (Punkt p);
int main () {
Geo geo; Vektor
f1(vec); f1(pun);
vec;
// OK
Punkt
pun;
Programmierung II
194
f2(vec); f2(pun); // OK
f3(&vec);f3(&pun); // OK
h(geo);
// FEHLER: ein Punkt ist ein Geo, aber ein Geo ist kein Punkt
}
Es gibt allerdings Unterschiede zwischen der Übergabe und Zuweisung von Objekten im Gegensatz zu der Übergabe von Referenzen und Zeigern. Darauf werden wir später zurückkommen.
7.2
7.2.1
Vererbung
Vererbung: Übernahme der Eigenschaften vom Basistyp
Statt von Ableitung spricht man oft auch von Vererbung. Der Grund für den – in diesem Zusammenhang – etwas seltsamen Begriff liegt im Verständnis der Beziehung der Basisklasse zu ihren Ableitungen. Die Basisklasse
stellt das Allgemeine dar, die Ableitung das Spezielle. Das Spezielle hat alle Eigenschaften des Allgemeinen und
darüberhinaus noch eigene spezielle Eigenschaften.
Definiert man eine Klasse B als Ableitung einer anderen Klasse A, dann übernimmt – erbt (!) – B alle Eigenschaften
von A. Die Eigenschaften, das sind alle Komponenten, Daten oder Methoden. Beispiel:
class A {
public:
void f(int);
int i;
};
// Ableitung
class B : public A {
float x;
};
Entspricht
//
//
//
//
//
class B {
void f(int);
int i;
float x;
};
// von A geerbt
// von A geerbt
Die abgeleitete Klasse B hat alles was die Basisklasse A hat. Das spart Definitionsarbeit bei B und macht die
Klassendefinition kompakter.
7.2.2
Vererbung: Objekte der abgeleiteten Klasse können an Stelle von Objekten der Basisklasse auftreten
Genauso wichtig wie die kompaktere Definition einer abgeleiteten Klasse ist aber, dass – wegen der gemeinsamen
Komponenten – mit einem B–Objekt all das möglich ist, was mit einem A–Objekt gemacht werden kann. Beispiel:
class A {
public:
void f(int p) { cout << p+i << endl; }
private:
int i;
};
class B : public A {
private:
float x;
};
int main () {
A a;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B b;
a.f(1);
b.f(2);
a = a;
a = b;
//
//
//
//
f
f
a
b
195
ist Komponente von a
ist auch Komponente von b
kann an a zugewiesen werden
kann auch an a zugewiesen werden.
}
Da mit einem Objekt einer abgeleiteten Klasse all das gemacht werden kann, was mit einem Objekt der Basisklasse
möglich ist, kann es an jeder Stelle verwendet werden, an der ein Objekt der Basisklasse auftreten darf.
i
x
b
class B : public A
a
Konversion B −> A
i
class A
Abbildung 69: a=b; Zuweisung an eine Basisklasse
7.2.3
Zuweisung von Objekten
Bei der Zuweisung eines Objekts mit abgeleitetem Typ an eine Variable vom Basistyp müssen wir unterscheiden,
ob Objekte oder Zeiger bzw. Referenzen im Spiel sind.
Bei einer direkten Zuweisung eines Objekts wird ein –Objekt in ein –Objekt verwandelt. Bei einer Zuweisung
eines Zeigers oder einer Referenz entfällt die Umwandlung. Beispiel:
class A { .. wie oben .. };
class B : public A { .. wie oben .. };
int main () {
A a; A *pa = new A;
B b; B *pb = new B;
a = b;
// b wird in ein A verwandelt
// a enthaelt ein A-Objekt
pa = pb;
// keine Umwandlung
// pa zeigt auf ein B-Objekt
}
Bei einer Zuweisung eines Objekts b der abgeleiteten Klasse B an eine Variable a der Basisklasse wird das Objekt
der abgeleiteten Klasse in die Basisklasse konvertiert, d.h. es wird auf seine Basiskomponenten reduziert (siehe
Abbildung 69).
Die gleiche Konversion findet bei der Wertübergabe statt:
class A { .. wie oben .. };
class B : public A { .. wie oben .. };
void f (A p) { ... }
int main() {
A a; B b;
f(a); // OK
f(b); // OK aber b wird auf ein A reduziert
}
Programmierung II
196
Es ist klar, dass eine entsprechende Konversion stattfinden muss. Die Variable mit dem Basistyp A ist einfach nicht
groß genug, um einen Wert mit mehr Komponenten aufzunehmen.
7.2.4
Zuweisung von Referenzen und Zeigern
Bei Zeigern und Referenzen stellen sich diese Platzprobleme nicht. Bei einer Zuweisung findet keine Konversion
statt (siehe Abbildung 70).
b
i
x
pb
class B : public A
Konversion A* −> B*
(keine Modifikation des Werts)
pa
a
i
class A
Abbildung 70: pa=pb; Zuweisung an einen Zeiger auf die Basisklasse
class A { .. wie oben .. };
class B : public A { .. wie oben .. };
int main() {
A * pa = new A;
B * pb = new B;
pa = pb;
// keine Konversion: pa zeigt auf ein B-Objekt
pa->x = 0.0;
// Fehler: pa->x existiert ist aber nicht zugreifbar
}
Die Komponenten, die zur Ableitung gehören, werden nicht “wegkonvertiert”. Sie sind aber trotzdem nicht – über
pa – zugreifbar: Zwar zeigt pa auf ein B–Objekt, aber pa hat immer noch den Typ A* und damit gibt es kein
pa->x.
7.2.5
Vererbung: Spezialisierung und Verallgemeinerung
Das Konzept der Vererbung kann in der Praxis auf zwei Arten eingesetzt werden:
Zur Spezialisierung: Ausgehend von einer Basisklasse werden Spezialisierungen definiert.
Zur Verallgemeinerung: Das Gemeinsame einer Reihe verwandter Klassendefinitionen wird gesucht und in
einer Basisklasse konzentriert.
7.2.6
Vererbung: Spezialisierung
Betrachten wir zunächst ein Beispiel der Spezialisierung. Angenommen man hat eine Klasse zur Darstellung der
Angestellten einer Firma:
class Angestellter {
public:
Angestellter (string pname) : name(pname) {}
....
private:
string
name;
};
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
197
Irgendwann stellt sich heraus, dass zur Verwaltung der Manager noch weitere spezielle Informationen benötigt
werden. Beispielsweise erhalten Manager jetzt als Leistungsanreiz nicht mehr, so wie normale Mitarbeiter, nur
Aktienoptionen, sie können darüberhinaus das Recht erwerben, den Waschraum des Vorstands zu benutzen. Wenn
alles andere gleich ist, dann wird der Manager einfach definiert als:
class Manager : public Angestellter {
public:
Manager(string pname)
: Angestellter(name), vsWaschraumRecht(false); {}
bool
verdienterManager() const { return vsWaschraumRecht; }
void
setzeVerdienst ( bool b) { vsWaschraumRecht = b; }
private:
bool
vsWaschraumRecht;
};
Ein Manager ist eine Variante eines Angestellten mit mehr Methoden und Datenkomponenten. Er wird zuerst wie
ein Angestellter initialisiert, dann werden seine speziellen Eigenschaften mit Werten belegt.
7.2.7
Vererbung: Verallgemeinerung
Umgekehrt kommt man vom Speziellen zum Allgemeinen, wenn die Komponenten, die in mehreren Klassen vorkommen, in einer Basisklasse konzentriert werden. Nehmen wir an, wir haben die Klassen der Studenten und
Dozenten:
class Student {
public:
Student (string s, unsigned int i)
: name(s),
matrikelNr(i) {}
...
private:
string
name;
unsigned int matrikelNr;
};
class Dozent {
public:
enum Fach {Physik, Chemie, Informatik};
Dozent (string s, Fach f)
: name(s),
fach(f) {}
...
private:
string
name;
Fach
fach;
};
int main () {
Student mueller ("Mueller", 4711);
Dozent meier ("Meier", Dozent::Physik);
}
Studenten und Dozenten haben jeweils einen Namen. Diese Gemeinsamkeit kann in einer Basisklasse
HochschulMitglied zusammengefasst werden:
class HochschulMitglied {
public:
HochschulMitglied (string s)
Programmierung II
198
Hochschulmitglied
name
Mueller
matrikelNr
4711
mueller : Student
−name
name
Meier
fach
Physik
meier : Dozent
name
Dozent
Student
−matrikelNr
Amsel
−fach
praesident : Hochschulmitglied
Klassen
Objkete
Abbildung 71: Ableitung: Klassen und Objekte
: name(s) {}
...
private:
string name;
};
class Student : public HochschulMitglied {
public:
Student (string s, unsigned int i)
: HochschulMitglied(s), // name in der Basisklasse belegen
matrikelNr (i) {}
// eigene Komponente belegen
...
private:
unsigned int matrikelNr;
};
class Dozent : public HochschulMitglied {
public:
enum Fach {Physik, Chemie, Informatik};
Dozent (string s, Fach f)
: HochschulMitglied (s),
fach (f) {}
...
private:
Fach
fach;
};
int main () {
Student
mueller (
"Mueller", 4711 );
Dozent
meier (
"Meier",
Dozent::Physik );
HochschulMitglied praesident ( "Amsel" );
}
Die Klassen Student und Dozent haben die gemeinsame Komponente name. Sie definieren sie nicht jeweils
selbst, sondern erben sie von Hochschulmitglied. Die Objekte der drei Klassen haben aber stets eine eigene
Namenskomponente (siehe Abbildung 71).
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
7.3
7.3.1
199
Sichtbarkeit und Vererbung: public, protected und private
Sichtbarkeit in abgeleiteten Klassen: public–Komponenten
Abgeleitete Klassen übernehmen alle Komponenten ihrer Basisklasse. Die Sichtbarkeitsregeln werden aber nicht
in jedem Fall einfach übertragen.
Eine öffentliche Komponente der Basisklasse ist auch öffentlich in jeder Ableitung. Das ist der einfachste Fall:
class Basis {
public:
int i;
};
class Ab : public Basis {
void f () {
i = 5;
// OK: ich kann i direkt verwenden
}
};
int main() {
Ab ab;
ab.i = 6;
}
// OK: i ist oeffentlich in Basis und darum auch oeffentlich in Ab
Sichtbarkeit vererbter public–Komponenten:
Alle öffentlichen Komponenten einer Basisklasse sind öffentliche Komponenten ihrer
Ableitungen.
7.3.2
Sichtbarkeit in abgeleiteten Klassen: private–Komponenten
Eine private Komponente der Basisklasse ist aber nicht einfach auch eine private Komponente in den Ableitungen!
Sie bleibt private Komponente in der Basisklasse.
Private Komponenten sind nur in ihrer Klasse zugreifbar (= sichtbar), auch Ableitungen haben keinen Zugriff.
Man verwechsle aber nicht die Zugreifbarkeit mit der Existenz. Manche Dinge kann man nicht greifen, obwohl sie
existieren! Beispiel:
class Basis {
private:
int i;
};
class Ab : public Basis {
void f () {
i = 5;
// FEHLER: i existiert zwar in jeden Ab-Objekt,
}
//
ist aber fuer Ab-Methoden NICHT zugreifbar
};
int main() {
Ab ab;
ab.i = 6;
}
// FEHLER i ist natuerlich von aussen auch nicht zugreifbar
Hier hat ab zwar eine i–Komponente, kann diese aber nicht selbst – in einer eigenen Methode – verwenden. Sie
gehört zu ab, ist aber noch geheimer als eine private Komponente: so geheim, dass sie sie nicht einmal selbst lesen
kann.
Sichtbarkeit vererbter private–Komponenten:
Die privaten Komponenten einer Basisklasse sind in ihren Ableitungen nicht zugreifbar, obwohl sie in jedem Objekt einer abgeleiteten Klasse vorhanden sind.
Programmierung II
200
7.3.3
Die Philosophie von public, private und Vererbung
Die Sichtbarkeitsregeln in Zusammenhang mit Vererbung versteht man am besten über ihren beabsichtigten Einsatz. Eine Klasse hat private und öffentliche Komponenten. Die öffentlichen sind ihre Schnittstelle, die privaten
stellen die Implementierung dar. Ist die Klasse eine Basisklasse, dann werden die öffentlichen Komponenten von
allen Ableitungen übernommen.
Die öffentlichen Komponenten der Basisklasse stellen jetzt die gemeinsame Schnittstelle der Klasse und all
ihrer Ableitungen dar.
Die privaten Komponenten der Basisklasse bleiben aber weiterhin die Implementierung nur der Basisklasse
Daraus folgt dass Öffentliches in der Ableitung auch öffentlich ist, Privates aber Privates der Basisklasse bleibt:
class BasisKlasse {
public:
-- gemeinsame Schnittstelle von BasisKlasse und --- alle ihren Ableitungen
-private:
-- Implementierung von BasisKlasse
-};
7.3.4
Zugriff auf Privates der Basisklasse
Die Sichtbarkeit operiert auf Klassenebene. Die Komponenten existieren als Bestandteil der Objekte. Ein Objekt
einer abgeleiteten Klasse kann auf seine “Geheimkomponente” – eine private Komponente der Basisklasse – nur
indirekt über eine öffentliche Methode der Basisklasse zugreifen. Beispiel:
class Basis {
public:
void fbasis () { ++x; }
private:
int x;
// ist Komponente aller Ableitungen
};
class Ab: public Basis {
public:
void fab ()
{ fbasis(); }
// indirekter Zugriff auf das eigene x
void gab (Basis &rb) { rb.fbasis(); } // indirekter Zugriff auf rb.x
};
int main () {
Ab
ab1, // hat ein "ganz privates" x, nur ueber Basis::fbasis zugreifbar
ab2;
ab1.fab();
// OK: erhoeht ab1.x
ab1.fbasis();// OK: erhoeht ab1.x
ab1.gab(ab2);// OK: erhoeht ab2.x
}
Die privaten Komponenten einer Klasse sind nur aus dieser Klasse heraus zugreifbar. Alle anderen Klassen haben
keinen Zugriff. Das gilt auch für abgeleitete Klassen:
class Basis {
private:
int x; // x ist Komponente aller Ableitungen, aber dort nicht zugreifbar
};
class Ab: public Basis {
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
201
// Jedes Ab-Objekt enthaelt ein (ererbtes) unzugreifbares
// int x
public:
void f () { ++x; } // FEHLER Basis::x ist privat, in Ab kein Zugriff
};
7.3.5
protected: Komponenten für interne Zwecke in der Ableitungskette
Private Komponenten einer Basisklasse sind nur in ihr zugreifbar, öffentliche sind überall zugreifbar. Es fehlt etwas
dazwischen: Eine Beschränkung der Sichtbarbeit auf die Klasse und ihre Ableitungen. Mit protected wird diese
Lücke geschlossen:
class Basis {
protected:
int x;
};
class Ab: public Basis {
public:
void f () { ++x; } // OK, eine Ableitung darf zugreifen
};
int main () {
Ab ab;
ab.f(); // OK
++ab.x; // FEHLER: x ist protected, also nur fuer Basis und Ab zugreifbar
}
In main kann auf ab.x nicht zugegriffen werden. Es hat nach aussen die gleiche Sichtbarkeit wie eine private
Komponenten. In der abgeleiten Klasse kann es im Gegensatz zu einer privaten Komponente aber verwendet
werden.
Sichtbarkeit vererbter protected–Komponenten:
Die geschützten Komponenten einer Basisklasse sind in ihren Ableitungen zugreifbar,
nicht jedoch in Klassen, die nicht von der Basisklasse abgeleitet sind.
Komponenten mit der Sichtbarkeit protected sind in der Klasse selbst und in allen Ableitungen zugreifbar.
Ansonsten sind sie so geschützt wie private. protected wird eingesetzt um die Sichtbarkeit von Komponenten
zu bestimmen, die zur Implementierung ihrer Klasse und deren Ableitungen gehören:
class BasisKlasse {
public:
-- Das duerfen alle sehen:
--- gemeinsame Schnittstelle von BasisKlasse und --- aller Ableitungen nach aussen
-protected:
-- Das duerfen Nachkommen sehen:
--- Schnittstelle von BasisKlasse zu ihren
--- Ableitungen.
-private:
-- Das geht keinen was an:
--- Implementierung NUR von BasisKlasse
-};
Eine Klasse zeigt ihre public–Elemente allen anderen Klassen, ihre protected–Elemente zeigt sie all ihren
Ableitungen. Die Schlüsselworte public und protected definieren also Schnittstellen der Klasse zu einem
jeweils unterschiedlichen Publikum. Unabhängig von der Sichtbarkeit hat jedes Objekt jeder Ableitung jede in
BasisKlasse definierte Komponente, was immer auch ihre Sichtbarkeit sein mag.
Programmierung II
202
7.3.6
Bei Zugriff über Klassengrenzen operiert protected auf Objektebene
Mit protected wird der Zugang auf ansonsten unzugängliche Komponenten der eigenen Basisklasse geöffnet.
Diese Öffnung hat aber eine Beschränkung: sie gilt nur für das gleiche Objekt. Auf Komponenten der Basisklasse mit Sichtbarkeit protected darf zwar in allen Ableitungen zugriffen werden, aber nur dann wenn sie zum
gleichen Objekt gehören.
class Basis {
protected:
int x;
// ist Komponente aller Ableitungen und dort zugreifbar
};
Basis b;
class Ab: public Basis {
public:
Ab::m() { x
= 0;
// OK:
Zugriff auf MEIN
Basis::x
b.x = 0;
// FEHLER: Zugriff auf FREMDES Basis::x
}
};
Das gilt natürlich nur bein Zugriff über Klassengrenzen. Innerhalb der gleichen Klasse gibt es keine Sichtbarkeitsbeschränkungen. Ein etwas ausführlichers Beispiel ist:
class Basis {
protected:
int x;
// ist Komponente aller Ableitungen und dort zugreifbar
};
class Ab: public Basis {
public:
void fab ()
{ ++x; }
void gab (Ab
& rab) { ++rab.x; }
void gb (Basis & rb) { ++rb.x; }
};
// OK:
// OK:
// FEHLER:
//
eigenes Basis::x zugreifbar
fremdes Ab::x
zugreifbar
fremdes Basis::x
NICHT zugreifbar
Die Sichtbarkeit von x in den verschiedenen Methoden erklärt sich wie folgt:
fab: andere Klasse, gleiches Objekt: OK!
Die Methode fab greift auf “ihr” x zu. Das x gehört zur Basisklasse B, darf aber zugegriffen werden, da es
nur potected und nicht private ist.
gab: gleiche Klasse, anderes Objekt: OK!
Die Methode fab greift auf das x eines anderen Objekts zu. Dieses Objekt gehört aber zur gleichen Klasse.
Der Zugriff geht nicht über Klassengrenzen und ist darum erlaubt.
gb: andere Klasse, anderes Objekt: Kein Zugriff!
gb operiert auf dem x eines fremden Objekts mit einem anderen Typ. Das ist nicht erlaubt: Andere Klasse,
anderes Objekt, also nur Zugriff auf öffentliche Komponenten.
Komponenten mit Sichtbarkeit protected haben also insgesamt einen ähnlichen aber etwas schwächeren Schutz
als private Komponenten:
So wie bei privaten kann innerhalb der Klasse unbeschränkt auf sie zugegriffen werden.
So wie bei privaten ist der Zugriff über Klassengrenzen verboten,
es sei denn – und das ist das Besondere – er erfolgt innerhalb eines Objekts.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
7.4
7.4.1
203
Vererbungstypen
Öffentliche Vererbung ist nur eine Variante der Vererbung
Die Art der Übernahme der Sichtbarkeitsregeln kann gesteuert werden. Bei der bisher stets betrachteten öffentlichen Vererbung herrscht keine Symmetrie. Öffentliche Komponenten bleiben öffentlich in allen Ableitungen.
Private Komponenten werden in der Ableitung so privat, dass ein Objekt sie selbst nicht mehr lesen kann. Öffentliche Vererbung wird durch das Schlüsselwort public vor der Basisklasse angezeigt
class Ab : public Basis
... ; // oeffentliche Vererbung
und bedeutet, dass die öffentlichen Komponenten öffentlich bleiben: Public bleibt public! Ein Objekt einer Ableitung kann darum in jedem Kontext genutzt werden, in dem ein Objekt der Basisklasse auftreten kann. Die
öffentliche Vererbung entspricht damit dem natürlichen Verständnis von Vererbung als einer Beziehung zwischen
Art und Unterart.
7.4.2
Private Vererbung: Alles öffentliche wird privat
Ableitungen können auch privat sein. Private Vererbung wird mit private gekennzeichnet. Sie bewirkt, dass
alle Komponenten der Basisklasse in den Ableitungen privat sind.
class Basis {
public:
int x;
// ist Komponente aller Ableitungen
};
class Ab: private Basis {
public:
void f ()
void g (Ab &ab)
void h (B &b)
};
// <<--- PRIVATE Vererbung !
// Alles in Basis wird privat in Ab
// (also intern zugreifbar)
{ ++x; }
// OK: eigenes Basis::x zugreifbar
{ ++ab.x; } // OK: fremdes Ab::x zugreifbar
{ ++b.x; }
// OK: fremdes Basis::x zugreifbar
int main () {
Basis b;
Ab
ab;
++b.x;
// OK
Basis::x zugreifbar
++ab.x;
// FEHLER Ab::x nicht zugreifbar
}
Man beachte, dass die private Vererbung Fremden zwar weniger, der abgeleiteten Klasse aber mehr Zugriffsrechte
gibt als die öffentliche. Alles von Basis wird zum privaten von Ab, auch das private Basis::x ist ganz normal
privat in Ab und nicht so “super–privat” wie bei der öffentlichen Vererbung.
Die private Vererbung wird häufig auch Implementierungsvererbung genannt. Sie entspricht nicht dem intuitiven
Verständnis von Vererbung. Ihr Einsatz wird darum bestenfalls fortgeschrittenen Entwicklern empfohlen, die ihre
Entscheidung gut begründen können.
7.4.3
Geschützte Vererbung: Alles öffentliche wird protected
Beim dritten Typ der Vererbung werden alle öffentlichen Komponenten der Basisklasse zu geschützten
(protected) Komponenten der Ableitung. Sie können damit weiter unten in der Ableitungskette verwendet
werden.
.. ;
class Basis
class Ab : protected Basis
... ;
Programmierung II
204
Auch diese Form der Vererbung ist eine fortgeschrittene Technik und sollte auf spezielle Anforderungen beschränkt
bleiben.
7.4.4
Mehrfachvererbung
Eine Klasse kann von mehr als einer Basisklasse abgeleitet werden. Man bezeichnet dies als Mehrfachvererbung
(engl. multiple inheritance). Beispiel:
class Messer { ... };
class Schere { ... };
class Feile { ... };
class SchweizerMesser : public Messer,
public Schere,
public Feile {
...
};
Die abgeleitete Klasse erbt von allen Basisklassen. Ein Objekt hat damit alle Eigenschaften aller Basisklassen. Die
Art der Vererbung kann für jede Basisklasse unterschiedlich sein:
class Abgeleitet : public
Basis1,
protected Basis2,
private
Basis3 { ... };
Mehrfachvererbung führt leicht zu Komplikationen und wird nur für fortgeschrittene Anwendungen empfohlen.
7.4.5
Zusammenfassung Sichtbarkeit und Vererbung
Es gibt drei Arten der Sichtbarkeit von Komponenten und drei Arten der Vererbung. Bei jeder Art der Vererbung
werden alle Komponenten der Basisklasse zu Komponenten der abgeleiteten Klasse. Ihre Sichtbarkeit hängt von
der Sichtbarkeit in der Basisklasse und der Art der Vererbung ab.
Die Standardmethode der Vererbung ist die öffentliche Vererbung. Bei ihr werden öffentliche Komponenten der Basisklasse als öffentliche Komponenten der Ableitung übernommen. Private Komponenten werden zu ganz privaten
und solche mit geschützter Sichtbarkeit werden (quasi) zu privaten Komponenten der Ableitung. Alle Ableitungen
außer der öffentlichen sollten nur in besonderen und eher seltenen Fällen verwendet werden.
7.5
7.5.1
Konstruktoren und Destruktoren
Vererbung und Konstruktoren
Werden Variablen mit einer Klasse als Typ angelegt, wird zuerst ausreichend Platz geschaffen und dann wird
der Defaultkonstruktor bzw. Initialisierer aller Komponenten aktiviert und schließlich wird der Konstruktor des
Objekts selbst ausgeführt.
Hat das Objekt als Typ eine abgeleitete Klasse, dann wird die Initialisierung der Basisklasse in diesen Prozess
eingefügt:
1. Platz für das gesamte Objekt wird geschaffen.
2. Die ererbten Komponenten, also die die zur Basisklasse gehören, werden mit ihrem jeweiligen Initialisierer
oder Defaultkonstruktor initialisiert.
3. Der Defaultkonstruktor der Basisklasse schließt die Initialisierung der Komponenten der Basisklasse ab.
4. Die Komponenten der Ableitung werden durch einen Initialisierer oder ihren Defaultkonstruktor initialisiert.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
205
5. Der Konstruktor der abgeleiteten Klasse wird ausgeführt.
Die Aktionen und ihre Reihenfolge entsprechen damit genau dem was man erwartet. Beispiel:
class A {
public:
A () : a(0) {}
A (int p) : a(p) {}
int a;
};
class B : public A {
public:
B () : b(1) {}
int b;
};
int main () {
B b;
}
Bei der Initialisierung von b ist die Reihenfolge im Detail
1. A–Komponenten initialisieren:
(a) b.a wird vom Initialisierer mit belegt.
(b) Defaultkonstruktor von A wird aktiviert.
2. B–Komponenten initialisieren:
(a) b.b wird vom Initialisierer mit belegt.
(b) Defaultkonstruktor von B wird aktiviert.
7.5.2
Konstruktor der Basisklasse aufrufen
Bei der Initialisierung eines Objekts der Klasse C
class C {
pubblic: C() {} // ruft D::D()
D d;
};
kann der Aufruf des Defaultkonstruktors für die Komponente d mit einen Initialisierer durch einen expliziten
Konstruktoraufruf ersetzt werden:
class C {
pubblic: C() :d(z) {} // Initialisierer: Aufruf von D::D(Z) statt D::D()
D d;
};
Genauso kann bei abgeleiteten Klassen der Aufruf des Defaultkonstruktors für die Basisklasse mit einen Initialisierer durch einen anderen Konstruktor ersetzt werden. Beispiel:
class A {
// Basisklasse
public:
A () : a1(0) {}
A (int p) : a(p) {}
int a;
};
Programmierung II
206
class B : public A { // Ableitung
public:
B () : A(5),
// <- Initialisierer fuer A, ersetzt Aufruf von A::A()
b(1) {} //
durch Aufruf von A::A(int)
int b;
};
int main () {
B b;
}
In diesem Beispiel wird der ererbte Anteil nicht mit dem Defaultkonstruktor sondern mit dem explizit angegebenen
alternativen Konstruktor A::A(int) initialisiert. Der Konstruktor der Basisklasse muss vor die Initialisierer der
B–Komponenten platziert werden, da er die Initialisierung von A abschließt und diese immer der Initialisierung
von B und seiner Komponenten vorangeht.
Initialisierung von Objekten abgeleiteter Klassen:
Die Basisklasse wird bei der Initialisierung wie eine Komponente behandelt: sie wird
von ihrem Defaultkonstruktor initialisiert, es sei denn, es gibt einen Initialisierer für
die Basisklasse, der die Aktivierung eines anderen Konstruktors verlangt.
7.5.3
Destruktoren
Destruktoren werden in umgekehrter Reihenfolge der Konstruktoren aktiviert:
1. Der Destruktor der abgeleiteten Klasse.
2. Der Destruktor der Komponenten der abgeleiteten Klasse.
3. Der Destruktor der Basisklasse.
4. Der Destruktor der Komponenten der Basisklasse.
Beispiel:
class A {
public:
˜A(){...}
..
Z z;
};
class B : public A {
public:
˜B () { delete p; }
X * p;
Y
y;
};
Die Reihenfolge ist hier:
1. Destruktor von B mit delete p; (löst selbst eventuell weitere Destruktoraufrufe aus).
2. Destruktor von Y für B::y (falls Y eine Klasse mit Destruktor ist).
3. Destruktor von A.
4. Destruktor von Z für A::z (falls Z eine Klasse mit Destruktor ist).
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
207
Zerstörung von Objekten abgeleiteter Klassen:
Die Basisklasse wird bei der Zerstörung wie eine Komponente behandelt: vor der
Zerstörung des Objekts wird der Destruktor ihrer Basiskalsse aktiviert.
7.5.4
Kopierkonstruktor einer abgeleiteten Klasse
Der Kopierkonstruktor einer abgeleiteten Kasse verhält sich wie die anderen Konstruktoren der Ableitung: Wenn
im Programmcode nichts anderes gesagt wird, dann wird die Basisklasse und alle Komponenten mit dem zuständigen Defaultkonstruktor initialisiert. Beispiel:
class Basis {
...
Basis() : b(0) {}
Basis(Basis & p_basis) : b(p_basis.b) {}
int b;
...
};
class Abgeleitet : public Basis {
...
// Kopierkonstruktor, Version 1 (meist NICHT KORREKT)
//
Abgeleitet(Abgeleitet &p_ab)
: a(p_ab.a)
// kein Wort ueber b : b.Basis() wird aktiviert
{}
// Kopierkonstruktor, Version 2 (meist KORREKT)
//
Abgeleitet(Abgeleitet &p_ab)
: Basis(p_ab)
// Basis-Anteil initialisieren (mit Kopierkonstr.)
: a(p_ab.a)
// Abgeleitet-Anteil initialisieren
{}
int a;
...
}
Der Kopierkonstruktor der abgeleiteten Klasse sagt in der ersten Version nichts über die Belegung der Basis–
Anteile beim Kopieren. Sie werden darum in der üblichen Art mit dem Defaultkonstruktor von Basis belegt.
In der zweiten Version wird der Basisanteil explizit über den Kopierkonstruktor initialisierit. In den den meisten
Fällen ist die zweite Version das, was man eigentlich will.
Die Tatsache, dass der Kopierkonstruktor der abgeleiteten Klasse implizit den Defaultkonstruktor der Basisklasse
aufruft, um seine Komponenten zu initialisieren – und nicht etwa den Kopierkonstruktor – kann zu Überaschungen
führen:
class Basis {
public:
Basis()
: x(0)
{}
Basis(int pb) : x(pb)
{}
Basis(Basis &b): x(b.x) {}
int x;
};
class Abgeleitet : public Basis {
public:
Abgeleitet() {}
Abgeleitet(int y) : Basis(y) {}
Abgeleitet(Abgeleitet &a)
{} // Kopierkonstruktor ACHTUNG
Programmierung II
208
};
// ruft Basis::Basis()
int main() {
Abgeleitet a1(4711);
Abgeleitet a2(a1);
// Kopierkonstruktor
cout << "a1.x: " << a1.x
<< " a2.x: " << a2.x
<< endl;
// Ausgabe a1.x: 4711
// Ausgabe a2-x: 0 <--- erwartet 4711
}
Hier wird a2.x mit belegt. Der Kopierkonstruktor von Abgeleitet ruft den Defaultkonstruktor zur Initialisierung seines Basis–Anteils. Das ist sicher nicht das was gewollt war. Wenn aber der Kopierkonstruktor der
abgeleiteten Klasse den Kopierkonstruktor der Basisklasse rufen soll, dann muss man es ihm explizit sagen:
class Basis { ... wie oben ... }
class Abgeleitet : public Basis {
public:
Abgeleitet () {}
Abgeleitet (int y) : Basis(y) {}
Abgeleitet (const Abgeleitet &a)
: Basis(a)
// <-- OK: KOPIER-Konstruktor aktivieren
{}
};
int main() {
Abgeleitet a1(4711);
Abgeleitet a2(a1);
cout << "a1.x: " << a1.x
<< " a2.x: " << a2.x
<< endl;
// Ausgabe a1.x: 4711
// Ausgabe a2-x: 4711 <<-- OK
}
Dieses Verhalten mag auf den ersten Blick verwunderlich erscheinen. Es ist aber letzlich schlüssig: Durch die
Definition eines Kopierkonstruktors gibt der Programmierer bekannt, dass er die Erzeugung von Kopien selbst
steuern will.19 Der Compiler bereitet ihm mit dem Defaultkonstruktor ein korrekt konstruiertes “leeres” Objekt
vor, das er dann nach eigenem Geschmack und auf eigene Verantwortung mit der Kopie füllen kann. Wenn er auf
die Füllung verzichtet, bleibt es “leer”.
7.5.5
Automatisch erzeugter Kopierkonstruktor einer abgeleiteten Klasse
In C++ gibt es viele Fälle, in denen es besser ist, das Programmieren dem Compiler zu überlassen. Ein solcher
Fall kann der Kopierkonstruktor für abgeleitete Klassen sein. Kopierkonstruktoren gehören zu den Dingen die der
Compiler generiert, wenn sie nicht explizit definiert werden. Der Compiler–erzeugte Code macht in vielen Fällen
das was man von ihm erwartet. So ist es auch mit dem erzeugten Kopierkonstruktor einer abgeleiteten Klasse: Er
kopiert meist korrekt.
Fehlt der Kopierkonstruktor einer abgeleiteten Klasse, erzeugt der Compiler einen. Der erzeugte Kopierkonstruktor ruft den Kopierkonstruktor aller Datenkomponenten der Klasse und den Kopierkonstruktor der Basisklasse.
Beispiel:
class Basis { ... wie oben ... }
class Abgeleitet : public Basis
public:
19 Und es
dann auch können sollte!
{
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
209
Abgeleitet() {}
Abgeleitet(int y) : Basis(y) {}
// OK: KEINEN Kopier-Konstruktor definieren
// Compiler erzeugt den richtigen
};
int main() {
Abgeleitet a1(4711);
Abgeleitet a2(a1);
cout << "a1.x: " << a1.x
<< " a2.x: " << a2.x
<< endl;
// Ausgabe a1.x: 4711
// Ausgabe a2-x: 4711 <<-- OK
}
Ein fehlender Kopierkonstruktor wird also nicht von der Basisklasse ererbt sondern automatisch generiert. Der
automatisch generierte entspricht dem in folgender Definition:
class Basis { ... wie oben ... }
class Abgeleitet : public Basis {
public:
...
Abgeleitet(const Abgeleitet & a) // Kopierkonstruktor entsprechend
: Basis(a) {}
//
dem generierten
};
In der abgeleiteten Klasse sollte ein Kopierkonstruktor nur dann definiert werden, wenn der Kopierkonstruktor der
Basisklasse zusammen mit den Kopierkonstruktoren der Komponenten nicht geeignet ist, Objekte der abgeleiteten
Klasse zu initialisieren. In dem Fall muss die gesamte Kopieraktion selbst definiert werden.
Kopierkonstruktor abgeleiteter Klassen:
Selbst definiert: Die Basisanteile der abgeleiteten Klassen werden bei einem selbst
definierten Kopierkonstruktor nur dann vom Kopierkonstruktor der Basisklasse belegt,
wenn dies explizit so programmiert wurde.
Automatisch erzeugt: Die Basisanteile der abgeleiteten Klassen werden von einem
vom Compiler generierten Kopierkonstruktor durch den Kopierkonstruktor der Basisklasse belegt.
7.5.6
Zuweisungsoperator einer abgeleiteten Klasse
Eine ähnliche Argumentation wie für den Kopierkonstruktor gilt auch für den Zuweisungsoperator. Fehlt er in der
abgeleiteten Klasse, dann wird er automatisch generiert. Der erzeugte Zuweisungsoperator macht dabei das was
man von ihm erwartet: er ruft den Zuweisungsoperator der Komponenten und der Basisklasse:
class Basis { ... wie oben ... }
class Abgeleitet : public Basis {
public:
Abgeleitet() {}
Abgeleitet(int y) : Basis(y) {}
// OK: keinen Kopier-Konstruktor definieren
// OK: KEINEN Zuweisungsoperator definieren !!
};
int main() {
Abgeleitet a1(4711);
Programmierung II
210
Abgeleitet a2;
a2 = a1;
cout << "a1.x: " << a1.x
<< " a2.x: " << a2.x
<< endl;
// Ausgabe a1.x: 4711
// Ausgabe a2-x: 4711 <<-- OK
}
Der automatisch erzeugte Zuweisungsoperator entspricht in unserem Beispiel dem in folgender Definition:
class Basis { ... wie oben ... }
class Abgeleitet : public Basis {
public:
...
// Zuweisungsoperator so wie vordefiniert
//
Abgeleitete & operator= (const Abgeleitet & a) {
if ( this != &a )
this->Basis::operator=(a); // Zuweisung der Basisklasse
return *this;
}
};
Weiter oben wurde im Kopierkonstruktor der abgeleiteten Klasse, als Initialisierer, der Kopierkonstruktor der Basisklasse aktiviert. Im Zuweisungsoperator hier sind Initialisierer nicht erlaubt, wir rufen darum explizit den Zuweisungsoperator der Basisklasse auf (Basis::operator=).
Wird in einem selbst definierten Zuweisungsopertor nichts explizit über die Initialisierung der Basis–Anteile gesagt, dann werden sie auch nicht verändert!
Zuweisungsoperator abgeleiteter Klassen:
Selbst definiert: Die Basisanteile der abgeleiteten Klassen werden bei einem selbst
definierten Zuweisungsoperator nur dann vom Zuweisungsoperator der Basisklasse
belegt, wenn dies explizit so programmiert wurde.
Automatisch erzeugt: Die Basisanteile der abgeleiteten Klassen werden von einem
vom Compiler generierten Zuweisungsoperator durch den Zuweisungsoperator der
Basisklasse belegt.
7.5.7
Muster für Kopierkonstruktor und Zuweisungsoperator abgeleiteter Klassen
Wenn, wie üblich und anders als in unserem Beispiel oben, die abgeleitete Klasse eigene Komponenten hat, dann
müssen sie natürlich auch in einem definierten Kopierkonstruktor und Zuweisungsoperator kopiert werden.
class Basis { ... irgend etwas ... };
// Muster zur Kopie abgeleiteter Klassen
//
class Abgeleitet : public Basis {
public:
...
// MUSTER KOPIER-Konstruktor einer Ableitung ---------------//
Abgeleitet(const Abgeleitet & a)
: Basis(a),
// Basisanteil kopieren
x(a.x)
// eigene Komponente kopieren
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
211
{}
// MUSTER ZUWEISUNGS-Operator einer Ableitung --------------//
Abgeleitet & operator= (const Abgeleitet & a) {
if ( this != &a ) {
this->Basis::operator=(a);
// Basisanteil kopieren
x = a.x;
// eigene Komponente kopieren
}
return *this;
}
private:
X x;
// eigener Anteil
};
Man beachte wie hier mit dem Bereichsoperator (Basis::) auf eine verdeckte Methode der Basisklasse zugegriffen wird.
7.5.8
Fehlender Defaultkonstruktor in einer abgeleiteten Klasse
Defaultkonstruktoren werden nicht automatisch erzeugt. Von dieser Regel gibt es eine Ausnahme: Wenn eine Komponente einer Klasse eine Klasse mit Defaultkonstruktor hat, dann wird dieser auch dann automatisch aktiviert,
wenn die Klasse selbst keinen Defaultkonstruktor hat. Beispiel:
class K {
public:
K () : x(0) {}
int x;
};
class C {
public:
K k;
};
int main() {
C c;
}
// ohne jeden Konstruktor
// Komponente mit Defaultkonstruktor
// aktiviert K::K()
Diese Regel wird auf abgeleitete Klassen übertragen: Hat die Basisklasse einer abgeleiteten Klasse einen Defaultkonstruktor, die abgegeleitete Klasse selbst aber keinen, dann wird trotzdem der Defaultkonstruktor der Basisklasse
aktiviert:
class Basis {
public:
Basis () : x(0) {}
int x;
};
class Abgeleitet : public Basis {
// Kein Konstruktor
...
};
int main() {
Abgeleitet a;
}
// aktiviert Basis::Basis()
Programmierung II
212
7.6
7.6.1
Beispiel Sortierte Liste: Abgeleitete Klasse als konkreter Datentyp
Eine Liste
Eine einfache Cursor-basierte Liste als konkreter Datentyp sieht etwa wie folgt aus:
class List {
public:
List () : head (0) {}
˜List () { delete head; }
List (const List &p_l) { ... tiefe Kopie ... }
List & operator= (const List &p_l) { ... }
void insert (int i) { head=new Node(i, head); }
void reset
() { cursor=0; }
bool forward () {
if (cursor == 0) cursor=head;
else cursor=cursor->next;
return cursor != 0;
}
int & current () { return cursor->v; }
private:
class Node {
public:
Node () : v(0), next(0){}
Node (int i, Node * n) : v(i), next(n){}
˜Node () { delete next; }
int v;
Node * next;
};
Node * head;
Node * cursor;
};
Die Klasse definiert Konstruktor, Kopierkonstruktor, Destruktor und Zuweisungsoperator. Sie ist damit in all ihrer
Bescheidenheit eine vorbildliche Klasse, ein konkreter Datentyp.
7.6.2
Eine sortierte Liste ist eine Liste
Listen und sortierte Listen sind sich sehr ähnlich. Der einzige Unterschied besteht darin, dass die sortierte Liste
ihre Elemente sortiert einfügt. Eine sortierte Liste kann also als Ableitung der Liste mit überschriebener Einfügeoperation definiert werden.
class SortedList : public List {
...
void insert(int i) { ... sortiert einfuegen ... };
...
}
7.6.3
protected: Implementierungsdetails verfügbar machen
Die sortierte Liste muss beim Einfügen nach einem passenden Platz suchen und das neue Element einfügen. Dazu
muss auf die Knotenstruktur der Implementierung von List zugegriffen werden. Der einfachste Weg ihr den
Zugriff zu ermöglichen ist, der abgeleiteten Klasse den vollen Zugriff auf die Implementierung zu geben. Aus
private wird protected und List hat gegenüber ihrer Ableitung keine Geheimnisse mehr:
class List {
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
213
... wie oben ...
protected: // statt private
... wie oben ...
};
class SortedList : public List {
public:
...
void insert(int i) {
Node ** p;
for( p = &head; *p != 0; p = &((*p)->next) ){
if ((*p)->v >= i) break;
}
*p = new Node(i,*p);
}
};
Die Klasse List teilt sich ihre Implementierungsdetails mit all ihren Ableitungen. In dem Fall, dass nur eine
Ableitung definiert wird, mag das akzeptabel sein. Die Basisklasse und ihre Ableitung haben dabei aber eine
bedenklich hohe Kopplung.
7.6.4
protected: Ableitungsschnittstelle definieren
Der politisch korrekte Weg, abgeleiteten Klassen Zugriff auf die eigene Implementierung zu geben, besteht darin,
mit protected eine echte Schnittstelle zu den Ableitungen zu definieren. Etwa wie folgt:
class List {
public: //-------------------------- Das duerfen alle Klassen benutzen
List () : head (0) {}
˜List () { delete head; }
List (const List &p_l) { ... }
List & operator= (const List &p_l) { ... }
void insert (int i) { ... }
void reset
()
{ ... }
bool forward ()
{ ... }
int & current ()
{ ... }
protected: //----------------------- Das duerfen Ableitungen benutzen
void insert_sorted (int i) {
Node ** p;
for( p = &head; *p != 0; p = &((*p)->next) ){
if ((*p)->v >= i) break;
}
*p = new Node(i,*p);
}
private: //------------------------- Das darf niemand sonst benutzen
... wie oben ...
};
class SortedList : public List {
public:
...
void insert(int i) {
insert_sorted (i);
}
};
Programmierung II
214
Zugegeben, in diesem Beispiel ist die Trennung von private– und protceted–Schnittstelle vielleicht ein
wenig übertrieben korrekt. Das Beispiel ist allerdings auch ein wenig übertrieben einfach.
7.6.5
Konstruktor, Destruktor und Zuweisung
Für die abgeleitete Klasse muss außer der neuen Einfügeoperation nichts definiert werden:
Der Compiler übernimmt es für uns, Kopierkonstruktor und Zuweisungsoperator zu generieren. Wenn es
reicht alle Komponenten zu kopieren und die Basisklasse einen korrekten Kopierkonstruktor und Zuweisungsoperator hat, dann ist der erzeugte Kopierkonstruktor und Zuweisungsoperator korrekt und ausreichend. Das trifft hier zu: Die sortierte Liste hat keine eigenen Komponenten und die Basisklasse ist mit
korrekten Kopieraktionen ausgestattet.
Wenn alle Konstruktoren fehlen, dann beschwert sich der Compiler auch nicht über einen fehlenden Defaultkonstruktor.
Fehlt der Defaultkonstruktor, dann werden trotzdem alle Komponenten mit einer Klasse als Typ mit deren
Defaultkonstruktor initialisiert. Das trifft hier nicht zu: Die sortierte Liste hat keine eigenen Komponenten.
Ebenso werden Objekte einer abgeleiteten Klasse ohne Defaultkonstruktor, deren Basisklasse einen Defaultkonstruktor hat, mit dem Defaultkonstruktor der Basisklasse initialisiert. Das trifft hier zu: List hat einen
Defaultkonstruktor.
Für ein Objekt mit einer abgeleiteten Klasse wird der Destruktor einer Basisklasse auch dann aktiviert, wenn
die abgeleitete Klasse keinen Destruktor hat. Das trifft hier zu: die basisklasse hat einen korrekten Destruktor.
Die abgeleitete Klasse ist damit einfach:
class SortedList : public List {
public:
// Defaultkonstruktor muss nicht definiert werden, Basisklasse wird trotzdem
//
initialisiert: sie hat einen Defaultkonstruktor.
// Kopierkonstruktor wird erzeugt, muss nicht definiert werden:
//
Die Basisklasse hat einen geeigneten Kopierkonstruktor.
// Zuweisungsoperator wird erzeugt, muss nicht definiert werden:
//
Die Basisklasse hat einen geeigneten Zuweisungsoperator.
// Destruktor muss nicht definiert werden: Der Destruktor der Basisklasse
//
wird aktiviert, auch ohne Destruktor in der Ableitung.
void insert(int i) { insert_sorted (i); }
};
7.7
7.7.1
Beispiel Syntaxbaum: Ableitung um Typvarianten zu ermöglichen
Ohne Vererbung: Vereinigung von Varianten in einem Typ
Vererbung wird typischerweise eingesetzt um Typinformationen vom Compiler verwalten zu lassen. Betrachten wir
dazu als Beispiel die arithmetischen Ausdrücke die wir weiter oben in einer Variante ohne Vererbung betrachtet
haben (siehe Abschnitt 5.7).
Die Knoten eines Syntaxbaums waren dort entweder Wertknoten mit einer Zahl als Wert, oder Operatorknoten mit
einem Operator und zwei Verweisen auf die Knoten der Unterausdrücke. Beide Varianten eines Knotens waren in
einer Klasse Ausdruck::Knoten zusammengefasst:
class Ausdruck::Knoten {
public:
... diverse Methoden ...
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
215
private:
enum Art {wertAusdr, opAusdr};
Art
art;
// welche Variante von Knoten liegt vor
Zahl
wert;
// Wert eines Wertknotens
char
op;
// Operator eines Operatorknotens
Knoten *l, *r;
// Verweise eines Operatorknotens
};
Da ein Zeiger immer nur auf Knoten eines bestimmten festen Typs zeigen kann, musste in verschwenderischer
Weiese in jedem Knoten Platz für jede Variante freigehalten werden, auch wenn jeweils nur ein Teil des Speicherplatzes zur Speicherung der Daten genutzt wurde. Ein explizites Typfeld art diente dazu, über die aktuelle
Variante des Knotens zu informieren.
7.7.2
Ableitungen als Varianten
Das Konzept der Vererbung erlaubt es uns die Varianten der Knoten durch entsprechende Typen darzustellen. Die Zeiger zeigen formal auf den Basistyp Knoten, aktuell werden aber Objekte der abgeleiteten Typen
WertKnoten und OpKnoten erzeugt:
class Ausdruck::Knoten {
...
};
// Basisklasse
// Variante 1: Wert-Knoten
//
class Ausdruck::WertKnoten : public Ausdruck::Knoten {
public:
...
private:
Zahl
wert;
};
// Variante 2: Operator-Knoten
//
class Ausdruck::OpKnoten : public Ausdruck::Knoten {
public:
...
private:
char
op;
Knoten
*l, *r; // Zeiger auf Basisklasse
};
Mit diesen Definitionen können unterschiedliche Arten von Knoten erzeugt werden. Ein Leseoperator, der entsprechende Syntaxbäume aufbaut, könnte folgende Struktur haben:
class Ausdruck {
friend std::istream & operator>> (std::istream &, Ausdruck &);
...
Ausdruck (char p_op, const Ausdruck & p_a1, const Ausdruck & p_a2)
: ak(new OpKnoten(p_op, p_a1.ak, p_a2.ak)) {}
...
Knoten * ak;
};
...
std::istream & operator>> (std::istream &is, Ausdruck &a) {
char
z;
Programmierung II
216
Ausdruck a1, a2;
char
op;
do {
if (!(is >> z)) return is;
switch (z){
...
case ’(’ : {
is >> a1;
is >> op;
is >> a2
a = Ausdruck(op, a1, a2);
break;
}
...
}
}
Der einzige Unterschied zu der weiter oben besprochenen Variante ohne Vererbung besteht darin, dass Unterknoten
vom jeweils richtigen abgeleiteten Typ erzeugt werden.
7.7.3
Problem: Aktuelle Typinformation zur Laufzeit ist nicht zugreifbar
Das Beispiel soll hier nicht weiter vertieft werden, denn es enthält einen schwerwiegenden Mangel: Wir können
jetzt zwar unterschiedliche Arten von Knoten erzeugen, aber es gibt noch keine Möglichkeit auf die Information
darüber zuzugreifen, um welche Art von Knoten es sich handelt. l und r zeigen auf Objekte mit einem abgeleiteten
Typ. Da die Zeiger selbst aber vom Typ Knoten* sind, können sie nur mit der beschränkten Funktionalität der
Basisklasse Knoten benutzt werden – sie sind damit wertlos:
class Ausdruck {
friend ostream & operator<< (ostream &, const Ausdruck &);
...
public:
...
private:
class Knoten;
Knoten *
ak;
};
// Basisklasse, hat nichts zum ausgeben !!
//
class Ausdruck::Knoten {
public:
...
ostream & write (ostream &os ) const {}
};
class Ausdruck::WertKnoten : public Ausdruck::Knoten {
public:
...
ostream & write (ostream &os) const { // Wertknoten ausgeben
cout << wert;
}
private:
Zahl
wert;
};
class Ausdruck::OpKnoten : public Ausdruck::Knoten {
public:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
217
...
ostream & write (ostream &) const { // OpKnoten ausgeben
cout << "(";
l->write(); cout << op; r->write();
cout << ")";
}
private:
char
op;
Knoten *l, *r;
};
// WERTLOSER Ausgabeoperator fuer Ausdruecke !!!
//
ostream & operator<< (ostream &os, const Ausdruck &a) {
return a.ak->write(os); // <<<----- IMMER Aufruf von Knoten::write
}
//
unabhaengig vom wahren Typ von *ak !!
Das Konzept der Vererbung erlaubt es also Varianten von Knoten als Objekte einer entsprechenden Unterklasse
anzulegen. Es ist mit den bisher vorgestellten Mitteln nicht möglich zur Laufzeit automatisch die Ausgabefunktion
aufzurufen, die zu dem wahren Typ des jeweiligen Knotens gehört.
Man könnte sich mit einem Typfeld behelfen, würde dann aber im Wesentlichen wieder bei der ersten Variante
ohne Verebung laden.
Um die Varianten als Ableitungen eines Basistyps nicht nur aufbauen, sondern auch nutzen zu können, benötigen
wir ein Verfahren, das es erlaubt zur Laufzeit auf die aktuelle Typinformation eines Objekts zugreifen zu können,
beispielsweise um die richtige Ausgabefunktion zu aktivieren:
std::ostream & operator<< (std::ostream &os, const Ausdruck &a)
{
... Aufruf: ak->OpKnoten::write()
falls *(a.ak) vom Typ OpKnoten
...
... Aufruf: ak->WertKnoten::write() falls *(a.ak) vom Typ WertKnoten ...
Der nächste Abschnitt führt mit dem Polymorphismus einen entsprechenden Mechanismen ein.
Programmierung II
218
7.8
Übungen
Aufgabe 1
Welche Ausgabe erzeugt:
class Basis {
public:
Basis () : x(10) {}
void f () { ++x; }
int x;
};
class Abgeleitet : public Basis {
public:
Abgeleitet () {}
void f () { --x; }
};
int main() {
Abgeleitet * pa = new Abgeleitet;
Basis
* pb = pa;
pa->f();
pb->f();
cout << pa->x << endl;
cout << pb->x << endl;
}
Aufgabe 2
Betrachten Sie folgende Klassendefinitionen:
class Queue {
public:
Queue () {}
Queue (const Queue &q) : l(q.l) {}
int
getFirst ()
{ return l.front(); }
void
put
(int i) { l.push_back(i); }
protected:
list<int> l;
};
class Deque : public Queue {
public:
int getLast () { return l.back(); }
};
1. Was ist mit dem Defaultkonstruktor von Deque, darf er wirklich fehlen?
2. Hat die abgeleitete Klasse einen korrekten Kopierkonstruktor und Zuweisungsoperator? Wenn ja: definieren
Sie den Kopierkonstruktor und den Zuweisungsoperator, der jeweils dem automatisch erzeugten entspricht.
Wenn nein: Definieren Sie einen korrekten Kopierkonstruktor und Zuweisungsoperator!
Aufgabe 3
Ist folgendes Programm korrekt? Wenn ja, welche Ausgabe erzeugt es? Wenn nein, warum nicht?
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
#include <iostream>
using namespace std;
class Basis {
public:
Basis ()
: x(1), y(2), z(3) {}
Basis (int px, int py, int pz)
: x(px), y(py), z(pz) {}
void print() {
cout << x << ", " << y << ", " << z << endl;
}
int x;
protected:
int y;
private:
int z;
};
class Abgeleitet : public Basis {
public:
Abgeleitet ()
: Basis (111, 112, 113),
x(Basis::x+1000), y(Basis::y+1000) {}
void print() {
cout << x << ", " << y << endl;
}
int x;
private:
int y;
};
int main () {
Basis
* pa;
Abgeleitet a;
pa = &a;
a.print();
pa->print();
}
Aufgabe 4
Ist der Kopierkonstruktor von Basis und Abgeleitet korrekt:
class Basis {
public:
Basis (int x) : _x(x) {}
Basis () : _x(-1) {}
Basis (const Basis & b) : _x(b._x) {}
private:
int _x;
};
class Abgeleitet : public Basis {
public:
219
220
Programmierung II
Abgeleitet (int x, int y) : Basis(x), _y(y) {}
Abgeleitet() {}
Abgeleitet(const Abgeleitet & a) : _y(a._y) {}
private:
int _y;
};
Wenn nein koorigieren Sie ihn! Schreiben Sie für beide Klassen korrekte Zuweisungsoperatoren! Müssen für diese
Klassen Zuweisungsoperator und Kopierkonstruktor überhaupt definiert werden?
Aufgabe 5
Das Programm
#include <iostream>
using namespace std;
class Basis {
public:
Basis()
: b(0)
{}
Basis(int p_b): b(p_b) {}
Basis & operator= (const Basis & p_basis) {
if ( this != &p_basis )
b = p_basis.b;
return *this;
}
void schreib () {
cout << " b = " << b << endl;
}
private:
int b;
};
class Ab : public Basis {
public:
Ab()
: a(0)
{}
Ab(int p_a) : Basis(p_a), a(p_a) {}
Ab & operator= (const Ab & p_ab) {
if ( this != &p_ab )
a = p_ab.a;
return *this;
}
void schreib () {
Basis::schreib();
cout << " a = " << a << endl;
}
private:
int a;
};
int main () {
Ab ab1(7), ab2(13);
ab1 = ab2;
cout << "ab1: \n";
ab1.schreib();
cout << "ab2: \n";
ab2.schreib();
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
221
erzeugt die Ausgabe:
ab1:
b =
a =
ab2:
b =
a =
7
13
13
13
Das ist nicht unbedingt das, was man als Ergebnis einer Zuweisung erwartet. Erklären Sie die Ausgabe und korrigieren Sie den Zuweisungsoperator! Hätte ein automatisch generierter Zuweisungsoperator das Richtige getan?
Programmierung II
222
8
Polymorphismus
8.1
8.1.1
Virtuelle Methoden: Das Gleiche auf unterschiedliche Arten tun
Eine Basisklasse für Klassen mit nur ideellen Gemeinsamkeiten
Egal ob man vom Allgemeinen zum Speziellen geht oder umgekehrt, beim Entwurf einer Klassenhierarchie gilt es
die Gemeinsamkeiten und das Trennende von Klassen festzustellen und in einem entsprechenden Ableitungssystem festzuhalten. In den Beispielen weiter oben haben wir es meist mit eindeutigen gemeinsamen Komponenten
zu tun, z.B. der Name bei Angestellten (Basisklasse) und Managern (Ableitung). Die Vererbungsrelation kann
aber auch auf Klassen ausgedehnt werden, die keine konkreten gemeinsamen Komponenten haben, aber trotzdem
irgendwie “von gleicher Art” sind und auch gemeinsam behandelt werden sollen.
Bei unseren geometrischen Objekten haben wir das Spezielle in Form der Punkten, Vektoren und Geraden. Wir
suchen also das Allgemeine in Form des Gemeinsamen mehrerer gegebener Definitionen.
Zunächst müssen wir feststellen, dass Punkte, Vektoren und Geraden keine gemeinsamen Datenkomponenten haben. Das Gemeinsame aller geometrischen Klassen ist “ideeller” Natur. Worauf beruht die Idee der Gemeinsamkeit? Wir möchten geometrische Objekte verarbeiten können, ohne Rücksicht darauf, um welche Art es sich jeweils
genau handelt. Uns schweben also Programme folgender Art vor:
int main () {
Geo *g;
// g zeigt auf ein geo.-Objekt irgendeiner Unterart
cin >> *g;
// ein geo.-Objekt irgendeiner Unterart einlesen
.. geometrisches Objekt verarbeiten ..
cout << *g;
}
Wir wollen ein geometrisches Objekt irgendeiner Unterart einlesen und verarbeiten. Aus der Diskussion über den
Unterschied zwischen der Manipulation von Objekten einer Basisklasse und Zeigern auf die Basisklasse wissen
wir, dass mit Zeigern gearbeitet werden muss. Es soll ja vermieden werden, dass geometrische Objekte bei jeder
Zuweisung auf ihren Geo–Kern reduziert werden.
8.1.2
Ein–/Ausgabe für die Basisklasse und ihre Ableitungen
Die erste Gemeinsamkeit die wir anstreben ist die Ein–/Ausgabe. Damit sie gemeinsam in allen Klassen genutzt
werden kann, muss die Basisklasse sie definieren und an all ihre Ableitungen vererben. Etwa in folgender Art:
class Geo {
public:
...
// PROBLEMATISCH:
istream & read (istream &)
{ ..??.. } // Eingabe geometrisches Objekt
ostream & write (ostream &) const { ..??.. } // Ausgabe geometrisches Objekt
...
};
istream & operator>> (istream &is, Geo &go) { return go.read(is); }
ostream & operator<< (ostream &os, const Geo &go) { return go.write(os); }
class Vektor : public Geo { ... };
class Punkt : public Geo { ... };
class Gerade : public Geo { ... };
int main () {
... Geo-Objekte lesen, verarbeiten, ausgeben ...
}
Das Problem scheint fast gelöst: Wir definieren Lese– und Schreiboperationen in der Basisklasse Geo und benutzen
sie in allen Ableitungen. Ein kleines Problem bleibt noch: die Lese– und Schreiboperationen in der Basisklasse
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
223
müssen noch definiert werden. Leider ist es unlösbar. Lesen und Schreiben hängt davon ab, um was für eine (Unter–
) Art von Objekt es sich handelt. Es kann also gar nicht in der Basisklasse definiert werden. Um die Art “ideeller
Gemeinsamkeit” der geometrischen Klassen in einer Basisklasse konzentrieren zu können, muss der Mechanismus
erweitert werden.
8.1.3
Basis und Ableitungen: Das Gleiche auf unterschiedliche Art tun
Bei der Vererbung übernimmt eine Ableitung alle Daten–Komponenten und Methoden ihrer Basisklasse. Damit
kann eine gemeinsame Funktionalität in der Basisklasse konzentriert werden. Datenkomponenten und Methoden
sind in der Basisklasse und der Ableitung exakt gleich.
Manchmal wünscht man sich Gemeinsamkeiten, ohne dass alles gleich identisch ist. Beispielsweise können Punkte, Vektoren und Geraden gelesen und geschrieben werden. Es ist darum sinnvoll read und write in der Basisklasse zu definieren und an alle Ableitungen zu vererben. Die Art, in der eine Ableitung liest und schreibt kann
nicht gleich sein. Sie muss jeweils gesondert für Punkte, Vektoren und Geraden definiert werden:
Die Gemeisamkeit der geometrischen Objekte ist die Tatsache, dass sie jeweils Lese– und Schreiboperationen zur Verfügung stellen. Die Lese– und Schreiboperationen selbst stellen aber keine Gemeinsamkeit dar, sie sind unterschiedlich.
Die Lese– und Schreiboperation in der Basisklasse Geo sagen, dass alle geometrischen Objekte gelesen und geschrieben werden können. Die entsprechenden Methoden in den Ableitungen definieren wie das jeweils genau
geht. Die Methoden in der Basisklasse sind dazu da um umdefiniert zu werden (siehe Abbildung 72).
Geo
+read
+write
alle Geo−Objekte können
gelesen und geschrieben werden
Vektor
Punkt
Gerade
+read
+write
+read
+write
+read
+write
spezielle Lese− Schreiboperationen
für die speziellen Unterarten von Geo
Abbildung 72: Unterschiedliche Implementierung von Methoden in Basisklasse und Ableitung
8.1.4
Virtuelle Methode: in der Ableitung umdefinierte Methode
Virtuelle Methoden sind zur Redefinition vorbereitete Methoden. Sie werden in der Basisklasse definiert und stehen darum in jeder Ableitung zur Verfügung. Die Ableitungen können die Methoden nach eigenen Bedürfnissen
umdefinieren.
class Geo {
...
virtual istream & read (istream &)
{ } // Eingabe fuer Geo-Objekte, leer
virtual ostream & write (ostream &) const { } // Ausgabe fuer Geo-Objekte, leer
...
};
class Vektor : public Geo {
...
istream & read (istream &)
{ ... }
ostream & write (ostream &) const { ... }
// Eingabe fuer Vektor-Objekte
// Ausgabe fuer Vektor-Objekte
Programmierung II
224
...
};
.. entspreched Punkt und Gerade ...
Auf allen geometrischen Objekte sind jetzt mit read und write les– und schreibbar. Das Schlüsselwort
virtual zeigt an, dass die entsprechende Methode in einer Ableitung neu definiert werden kann. Geo definiert
read und write. Beide werden an alle Ableitungen vererbt, da sie virtuell sind, können sie dort umdefiniert
werden. Sie müssen allerdings nicht umdefiniert werden.
Der Begriff “virtual” ist hier eigentlich mit “redefinierbar” zu übersetzen. Wir sprechen zwar von “virtuellen
Methoden”, an ihnen ist aber zunächst einmal ganz und gar nichts virtuelles. 20
8.2
8.2.1
Polymorphismus: Typanalyse zur Laufzeit
Polymorphismus: der Typ des Objekts entscheidet zur Laufzeit über die aufgerufene Methode
Bei virtuellen Methoden entscheidet der Typ des Objekts über die aufgerufene Methode. Das Phänomen, dass die
gleichen Aufrufschnittstelle zu verschiedenen Methoden führt, wird dann Polymorphismus 21 genannt22, wenn die
Entscheidung, welche Methode aktiv wird, zur Laufzeit erfolgt.
class A {
virtual void f (X p) { ... } // A::f polymorph
...
};
class B public A {
void f (X p) { ... }
// POLYMORPHISMUS: A::f wird ersetzt
...
};
A *a;
B *b;
X x;
a = new A;
a->f(x);
// ruft A::f
b = new B;
b->f(x);
// ruft B::f
a = new B;
a->f(x);
// ruft B::f
//
<<--- OBWOHL a den Typ A* hat
!!!!
und WEIL *a JETZT den Typ B hat !!!!
Hier wird mit a->f(x) die Methode B::f aktiviert, obwohl a den Typ A* hat. Da B::f virtuell ist, wird in dem
Augenblick, in dem die Methode aufgerufen wird – also zur Laufzeit! – geprüft, welchen Typ das Objekt *a jetzt
gerade hat und dann wird zur entsprechenden Methode verzweigt.
8.2.2
Redefinition ist kein Polymorphismus
Das Schlüsselwort virtual zeigt an, dass die Entscheidung über die aufgerufene Methode zur Laufzeit, auf
Basis des aktuellen tatsächlichen Typs des Objekts, erfolgt. Lassen wir im Beispiel oben etwa das virtual weg,
dann wird aus dem Polymorphismus eine einfache Redefinition:
class A {
void f (X p) { ... } // A::f
20 In der Informatik muss aus irgendeinem unerfindlichen Grund alles “virtuell”, “transparent” oder “abstrakt” sein. Meist passen diese
Begriffe auch, in diesem Fall allerdings passt “virtuell” aber nicht.
21 Vielgestaltigkeit, von vielerlei Gestalt
22 In C++ werden polymorphe Methoden also “virtual” genannt! “Polymorph” ist der passende Begiff, “virtuell” ist C++–Slang.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
225
...
};
class B public A {
void f (X p) { ... } // REDEFINITION: B::f ueberschreibt A::f
...
};
A *a; B *b; X x;
a = new A;
a->f(x);
// ruft A::f
b = new B;
b->f(x);
// ruft B::f, A::f ist ueberdeckt
a = new B;
a->f(x);
// ruft A::f <<--- WEIL a den Typ A* hat !!!!
Hier wird mit a->f(x) die Methode A::f aufgerufen. Der Compiler stellt – zur Übersetzungszeit! – fest dass a
den Typ A* hat und entscheidet, dass mit a->f() die Methode A::f() zu aktivieren ist. Der aktuelle Wert von
a ist irrelevant.
8.2.3
Überladung ist kein Polymorphismus
Polymorphismus und Redefinition einer Methode werden gelegentlich auch mit Überladung verwechselt. Virtuelle und redefinierte Methoden sind keine überladenen Methoden. Bei der Überladung entscheidet der Typ der
Parameter darüber, welche Funktion oder Methode genau aufgerufen wird.
class C {
void f (X p) { ... }
void f (Y p) { ... }
...
};
C
X
c;
x;
// f Nr-1
// f Nr-2
ueberladen
ueberladen
C * pc = new C;
Y
y;
// Ueberladung
//
c.f(x);
// f
c.f(y);
// f
pc->f(x) // f
pc->f(y) // f
aufloesen durch Typ des Parameters (unabhaengig von Zeigern)
Nr-1
Nr-2
Nr.-1
Nr.-2
Zusammengefasst sind die wesentlichen Eigenschaften der drei Arten, verschiedene Methoden über einen Namen
anzusprechen:
Überladung:
Der Typ der Argumente entscheidet über die aufgerufene Funktion/Methode. Überladung wird immer zur
Übersetzungszeit aufgelöst.
Redefinition einer Methode in der Ableitung:
Der Typ der Objekts entscheidet – zur Übersetzungszeit – über die aufgerufene Methode.
Polymorphismus (Ersatzdefinition in einer Ableitung):
Der Typ des Objekts entscheidet – zur Laufzeit – über die aufgerufene Methode.
Programmierung II
226
8.2.4
Polymorphismus benötigt Zeiger oder Referenzen
Polymorphismus entfaltet seine Wirkung nur in Kombination mit Zeigern oder Referenzen. Ein Zeiger oder eine
Referenz vom Typ der Basisklasse kann auch auf ein Objekt der abgeleiteten Klasse zeigen. In dem Fall wird die
Methode aktiviert, die tatsächlich zu dem Objekt gehört:
class A {
... virtual
class B :public A { ...
A a; B b; X x;
A* pa;
a = b;
a.f(x);
// A::f
pa = &b; pa->f(x); // B::f
void f (X p) { ... } ... };
void f (X p) { ... } ... };
kein Zeiger -> f der Basisklasse
Zeiger
-> f der abgeleiteten Klasse
Bei der Parameterübergabe gilt entsprechend, dass bei Wertparametern die Methode aufgerufen wird, die zum
formalen Parameter gehört, bei Referenzparametern dagegen die Methode die zum tatsächlichen Objekt gehört:
class A {
... virtual
class B :public A { ...
void f (X p) { ... } ... };
void f (X p) { ... } ... };
X x; A a; B b;
void gw (A a) {
a.f(x);
}
// immer A::f
void gr (A &a) {
a.f(x);
// A::f oder B::f,
}
je nach aktuellem Parameter
gw(a);
gw(b);
// A::f
// A::f Wertparameter -> f der Basisklasse
gr(a);
gr(b);
// A::f
// B::f Referenzparameter -> f des aktuellen Objekts
8.2.5
Einsatz von Polymorphismus
Welchen Sinn machen virtuelle Methoden? Dadurch, dass Entscheidungen zur Laufzeit getroffen werden, macht
Polymorphismus Programmstücke flexibler und vielseitiger einsetzbar. Er ergänzt in dieser Hinsicht den Vererbungsmechanismus in ganz wesentlicher Art.
Vererbung bringt Flexibilität in die Programme: Zeiger auf Objekte können gespeichert werden ohne dass
man sich vorher auf einen genauen Typ festlegen muss.
Polymorphismus erweitert diese Flexbilität: Eine Methode wird aufgerufen, ohne dass man an der Aufrufstelle genau wissen muss, welche das sein wird.
Polymorphismus macht damit Programme folgender Art möglich:
int main (){
vector<Geo *> v;
// Vererbung: v speichert alle Sorten
v.push_back( new Vektor(1,2) );
// von geometrischen Objekten
v.push_back( new Punkt(3,2) );
v.push_back( new Gerade ( Punkt(1,1), Vektor(1,1) ) );
...
for (unsigned int i = 0; i<v.size(); ++i) { // Polymorphismus:
v[i]->write(cout);
// Ausgabe ohne zu wissen
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
cout << endl;
227
// welchen Typ das Objekt genau hat
}
}
In der Ausgabeschleife wird Vektor::write, oder Punkt::write, oder Gerade::write aufgerufen,
je nach dem welchen Typ v[i] jeweils hat. Ohne Polymorphismus (virtuelle Methoden) würde immer nur
Geo::write aufgerufen.
8.2.6
Virtuelle Methoden werden dynamisch gebunden
Polymorphismus bringt dynamische Bindung in C++–Programme. Nicht wie sonst üblich entscheidet der Compiler
zur Übersetzungszeit welche Methode aktiviert wird (statische Bindung). Zur Laufzeit wird festgestellt welchen
Typ das Objekt hat und dann wird die entsprechende Methode aufgerufen. Statische Bindung bedeutet, dass die
Bindung vom Namen einer Funktion oder Methode zur deren Definition statisch, d.h. zur Übersetzungszeit erfolgt.
Dynamische Bindung bedeutet, dass der Bezug vom Namen zur Definition (die Bindung des Namens) dynamisch,
d.h. zur Laufzeit, erfolgt.
Es ist klar, dass die Flexibilität der dynamischen Bindung mit komplexerem Maschinencode und leicht erhöhter
Laufzeit bezahlt werden muss. Der Compiler generiert Code, der den aktuellen Typ eines Objekts feststellt und
dann in die entsprechende Methode verzweigt.
8.3
Konstruktoren, Destruktoren, Zuweisungen und Polymorphismus
8.3.1
Virtuelle Destruktoren, Nicht–virtuelle Konstruktoren
Für Konstruktoren und Destruktoren gilt die Regel:
Destruktoren können bei Bedarf virtuell sein.
Konstruktoren können niemals virtuell sein.
Ein Konstruktor dient dazu ein Objekt mit bekanntem Typ zu initialisieren. Der Typ ist dabei immer statisch – zur
Übersetzungszeit – bekannt. Objekte werden ja nur auf Veranlassung des Programms erzeugt und dabei wird der
exakte Typ angegeben.
Bei der Vernichtung von Objekten ist die Lage etwas anders. Räumt man Objekte mit delete weg, dann erfolgt
der Zugriff über einen Zeiger. Der Zeiger kann dabei formal auf ein Objekt der Basisklasse, tatsächlich aber auf
ein Objekt einer abgeleiteten Klasse zeigen. Zur Übersetzungszeit ist im Allgemeinen nicht bekannt, auf was er
tatsächlich zeigt.
class Basis { ... };
class Ab1 : public Basis { ... };
class Ab1 : public Basis { ... };
Basis *b;
b = new Basis;
b = new Ab1;
b = new Ab2;
...
delete b;
8.3.2
// -> Konstruktor von Basis
// -> Konstruktor von Ab1
// -> Konstruktor von Ab2
// -> Destruktor von Basis, Ab1 oder Ab2 ??
Zeiger auf Basisklasse: Virtueller Destruktor in der Basisklasse
Ein delete auf einen Zeiger aktiviert den Destruktor. Hat der Zeiger den Typ “Zeiger auf eine Basisklasse”, dann
kann nicht einfach der Destruktor der basisklasse aufgerufen werden. Beispiel:
Programmierung II
228
class Geo { ...
˜Geo(){}
// Destruktor
... };
Geo * pg;
...
pg = new Vektor (2, 4);
...
delete pg; // PROBLEM: ruft den Destruktor der Basisklasse Geo
Hier wird der Destruktor von Geo auf ein Objekt angewendet, das tatsächlich vom Typ Vektor ist. Nicht nur,
dass der Destruktor eventuell das Falsche tut, er gibt möglicherweise auch zu wenig Speicherplatz zurück. Objekte
vom Basistyp benötigen ja in der Regel weniger Platz als Objekte mit einem abgeleiteten Typ.
Die Kur ist einfach: Wenn immer Objekte abgeleiteter Klassen über Zeiger auf ihre Basisklasse manipuliert werden, sollte der Destruktor der Basisklasse virtuell sein:
class Geo { ...
virtual ˜Geo(){}
// virtueller Destruktor
... };
...
Geo * pg;
...
pg = new Vektor (2, 4);
...
delete pg; // OK: ruft den Destruktor von Vektor
Man beachte dass ein implizit durch den Compiler erzeugter Destruktor niemals virtuell ist. Virtuelle Destruktoren
sind darum auch dann sinnvoll, wenn sie, wie in unserem Beispiel, nichts tun.
8.3.3
Polymorphismus und Zuweisungen
Ein Zuweisungsoperator kann zwar virtuell definiert werden, die Zuweisung kann aber trotzdem nicht polymorph
sein, da polymorphe Funktionen immer die gleichen Argumenttypen haben müssen. Entgegen dem ersten Eindruck
ist darum in:
class B {
public:
virtual B & operator= (const B &b) { // ACHTUNG: nicht so virtuell wie gedacht
return *this;
}
};
class A : public B {
public:
A() : x(0) {}
A & operator= (const A &a) { // anderer Argument-Typ als B::operator=
x = a.x;
return *this;
}
int x;
};
der Zuweisungsoperator nicht so polymorph wie man es sich wünschen mag! Als Folge wird in
int main() {
B *b;
A a1, a2;
a1.x = 4711;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
b
= &a2;
229
// b zeigt auf a2
// a2 mit dem Wert von a1 belegen:
//
*b = a1;
//
//
cout << a2.x << endl;
//
Funktioniert nicht wie gedacht
aktiviert B::operator=
NICHT A::operator= (trotz virtual in B)
Ausgabe: 0, NICHT 4711
}
der Zuweisungsoperator der Basisklasse B::operator= aufgerufen. Der Zuweisungsoperator in B hat als Argument eine konstante Referenz auf ein B–Objekt. Der Zuweisungsoperator in A hat einen anderen Typ. Stimmen die
Typen der Argumente nicht überein, dann sind die Methoden ohne Bezug. Im Beispiel oben ist B::operator=
zwar virtuell, aber in A wird diese Methode nicht redefiniert: A::operator=(const B&) hat nichts mit
B::operator(const A&) zu tun.
8.4
8.4.1
Abstrakte Basisklassen
Geometrische Objekte mit Ausgabe
Mit dem Einsatz virtueller Funktionen können wir jetzt geometrische Objekte mit Ein– und Ausgabe definieren:
class Geo {
public:
virtual istream & read (istream &) {} // nichts tun
virtual ostream & write (ostream &) {} // nichts tun
};
class Vektor : public Geo {
public:
Vektor ();
...
istream & read (istream &is) {
return os << "V(" << x_ << ", "
}
ostream & write (ostream &os) const
char c;
is >> c;
if (c != ’(’) {cerr << "FEHLER: (
is >> x_;
is >> c;
if (c != ’,’) {cerr << "FEHLER: ,
is >> y_;
is >> c;
if (c != ’)’) {cerr << "FEHLER: )
return is;
}
private:
...
};
// Vektoren lesen
<< y_ << ")";
{
// Vektoren schreiben
erwartet !\n"; return is;}
erwartet !\n"; return is;}
erwartet !\n"; return is;}
... Punkt und Gerade entsprechend ..
// Aufruf der virtuellen Methoden
istream & operator>> (istream is&, Geo &go)
{ return go.read(is); }
ostream & operator<< (ostream &os, const Geo &go) { return go.write(os); }
int main (){
vector<Geo *> v;
Programmierung II
230
v.push_back( new Vektor(1,2) );
v.push_back( new Punkt(3,2) );
v.push_back( new Gerade ( Punkt(1,1), Vektor(1,1) ) );
for (unsigned int i = 0; i<v.size(); ++i) {
cout << *(v[i]) << endl;
}
}
In der Basisklasse Geo sind read und write leere Funktionen die nichts tun. Ein Objekt vom Typ Geo stellt ein
geometrisches Objekt in voller Allgemeinheit dar. Wie sollte man so etwas lesen oder schreiben?
Mit einem Aufruf wie
cout << *geo;
// geo vom Typ Geo *
wird über den Ausgabeoperator operator<< die Methode geo->write aufgerufen. Diese Methode ist virtuell,
also wird abhängig davon, auf was geo gerade zeigt, wird die richtige Ausgabemethode aufgerufen.
8.4.2
Rein virtuelle Methoden
Geometrische Objekte gibt es nicht als solche, sie existieren nur in den Varianten Punkt, Vektor und so weiter.
Sie sollten darum keine Ein–/Ausgabe–Methoden mit leerem Körper haben. Sie sollten überhaupt keine haben.
Gleichzeitig soll aber auch ausgedrückt werden, dass alle Ableitungen – die konkreten Klassen Vektor, etc. – sehr
wohl alle ein read und write anbieten.
Rein virtuelle Methoden drücken genau dies aus: “Für mich gibt es diese Methode nicht, aber meine Ableitungen
werden sie definieren”.
class Geo {
...
virtual istream & read (istream &) = 0; // rein virtuelle Methode
virtual ostream & write (ostream &) = 0; // gibt es fuer diese Klasse nicht
};
class Vektor : public Geo {
public:
...
istream & read (istream &is) { .. Vektor einlesen .. }
ostream & write (ostream &os) const { .. Vektor ausgeben .. }
...
};
Mit “=0” statt einer Methodendefinition wird eine virtuelle Methode zu einer rein virtuellen Methode ohne Implementierung.
8.4.3
Abstrakte Basisklasse und Schnittstelle
Klassen, die eine rein virtuelle Methode enthalten, können nur noch als Basisklassen genutzt werden. Es ist nicht
möglich Objekte von diesem Typ anzulegen:
Geo g;
Geo *pg;
// FEHLER Geo ist abstrakt
// OK
void f(Geo g) { .. } // FEHLER Geo ist abstrakt
void f(Geo &g){ .. } // OK
Klassen mit rein virtuellen Funktionen werden abstrakte Basisklassen genannt. Das trifft genau die Intention unserer Geo–Klasse. Es gibt keine Objekte mit Typ Geo, es gibt nur solche, deren Typ von Geo abgeleitet ist. Klassen,
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
231
die nichts als rein virtuelle Methoden enthalten, bezeichnet man häufig auch als Schnittstelle (engl. Interface). In
diesem Sinn ist Geo eine reine Schnittstelle: Sie definiert das was benuzt werden kann, ohne eine Implementierung
anzugeben.
8.4.4
Abstrakte Basisklassen in UML
In UML werden abstrakte Klassen und rein virtuelle Methoden durch Kursivschrift dargestellt (siehe Abbildung
73):
Geo
read
write
Vektor
Punkt
Gerade
Abbildung 73: Abstrakte Basisklasse in UML
8.5
8.5.1
Umschlagklassen
Speichern und Schreiben
Bei den geometrischen Objekten sind wir fast am Ziel. Sie können in allgemeiner Form gespeichert und ausgegeben
werden. Es muss nur darauf geachtet werden, dass sie immer über Zeiger gespeichert und manipuliert werden,
damit der Polymorphie–Mechanismus aktiv werden kann:
int main() {
vector<Geo *> v;
...
v.push_back( new Vektor(1,2) );
cout << *(v[i]) << endl;
...
}
Die Zeiger haben zwar den Typ Geo*, sie zeigen aber immer auf ein Objekt mit einem konkreten Typ Punkt,
Vektor oder Gerade.
8.5.2
Probleme beim Einlesen
Beim Einlesen geometrischer Objekte gibt es aber immer noch ein Problem. Wir können nicht einfach schreiben:
Geo *g;
g->read(cin);
// Wert von g undefiniert
// g->read ist rein virtuell
Die Variable g ist nicht initialisiert und kann darum nicht dereferenziert werden. Andere Lösungen fallen leider
ebenso weg:
Geo *g = new Geo; // geht nicht Geo ist abstrakt
g->read(cin);
// geht nicht Geo::read ist rein virtuell
Die read–Methode ist rein virtuell und kann nicht aktiviert werden.
read und write könnte man von rein virtuellen wieder zu nur virtuellen Methode machen. Geo wäre nicht mehr
abstrakt und so könnte dann g mit einem Verweis auf ein Geo–Objekt belegt werden:
Programmierung II
232
Geo *g = new Geo;
g->read(cin);
//
//
//
//
Geo nicht abstrakt
Aufruf moeglich, bringt uns aber nicht weiter:
*g bleibt aber fuer immer ein Geo,
auch wenn ein Vektor gelesen wird
Jetzt können wir zwar ein Geo–Objekt lesen, aber es müsste sich selbst dann in einen Vektor, Punkt oder eine
Gerade verwandeln. Das ist nicht möglich.
8.5.3
Einlesen
Eine Möglichkeit des Einlesens, die das bisher Erreichte an Flexibilität nicht wieder über Bord wirft, besteht darin,
zuerst zu entscheiden, welche Art von Objekt denn angelegt werden soll und es mit einer spezialisierten Methode
tatsächlich zu lesen:
Geo *g;
char c;
cin >> c; // erstes Zeichen als Hinweis welche Unterart von Geo folgt
switch (c) {
case ’V’: g = new Vektor; break;
case ’P’: g = new Punkt; break;
case ’G’: g = new Gerade; break;
default: .. FEHLER ..
}
g->read(cin);
// *g kann sich jetzt selbst einlesen
8.5.4
Eine Umschlagklasse
Diese Lösung funktioniert. Sie ist aber nicht besonders elegant. Die Leseoperation sollte innerhalb der geometrischen Objekte gekapselt werden. Wir definieren dazu ein Klasse GeoObjekt als Umschlag um die “polymorphen
Zeiger” vom Typ Geo und starten von ihr aus die Leseaktion:
// Umschlagklasse fuer Geo*
//
class GeoObjekt {
friend istream & operator>> (istream &, GeoObjekt &);
friend ostream & operator<< (ostream &, const GeoObjekt &);
private:
Geo * g;
};
istream & operator>> (istream &is, GeoObjekt &go) {
char c;
is >> c;
switch (c) {
// nachsehen was da kommt
case ’V’: case ’v’:
go.g = new Vektor; break;
case ’P’: case ’p’:
go.g = new Punkt;
break;
case ’G’: case ’g’:
go.g = new Gerade; break;
default: exit(1);
}
return go.g->read(is); // die richtige Leseoperation aufrufen
}
ostream & operator<< (ostream &is, const GeoObjekt &go) {
return go.g->write(is);
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
233
}
...
int main (){
GeoObjekt g;
cin >> g;
cout << g << endl;
}
8.6
8.6.1
// beliebiges geometrisches Objekt einlesen
// und ausgeben
Geklonte Objekte
Geometrische Objekte mit Konstruktoren und Destruktoren
Unsere geometrischen Objekte können jetzt mit Konstruktoren, Destruktoren und Zuweisungsoperatoren zu konkreten Datentypen komplettiert werden. Eigentlich benötigt nur Geo einen virtuellen Destruktur, der Rest der
Maschinerie wird vom Compiler für alle außer GeoObjekt automatisch korrekt erzeugt.
Die Umschlagklasse GeoObjekt bildet eine Ausnahme, da sie eine Zeigerkomponente enthält. Für sie muss ein
Destruktor definiert werden, der den Zeigerwert wegräumt. Das ist kein Problem:
class Geo
public:
virtual
virtual
virtual
};
{
˜Geo() {} // der Destruktor von Basisklassen sollte virtuell sein
istream & read (istream &) = 0;
ostream & write (ostream &) const = 0;
class GeoObjekt {
friend istream & operator>> (istream &, GeoObjekt &);
friend ostream & operator<< (ostream &, const GeoObjekt &);
public:
GeoObjekt () : g(0) {}
˜GeoObjekt ()
{ delete g; }
// Kopie erzeugen, noch zu definieren
//
GeoObjekt (const GeoObjekt &go)
{ ??? }
GeoObjekt & operator= (const GeoObjekt &go) { ??? }
private:
Geo * g;
};
Kopierkonstruktor und Zuweisungsoperator müssen selbst definiert werden, da die automatisch erzeugten nur flach
kopieren, also nur den Zeiger kopieren und nicht die Datenstruktur, auf die der Zeiger zeigt.
8.6.2
Problem: Wie kopiert man Objekte mit unbekanntem Typ
Es bleibt also noch das Problem den Kopierkonstruktor und Zuweisungsoperator zu definieren. Das sieht auf den
ersten Blick einfach aus. Nehmen wir den Kopierkonstruktor. Ein GeoObjekt hat als Komponente einen Zeiger
auf ein Geo. Ein Geo selbst hat überhaupt keine Komponenten. Wir legen einfach ein neues Objekt an, lassen die
Kopie darauf zeigen und haben damit die tiefe Kopie:
class GeoObjekt {
public:
...
// Kopierkonstruktor NICHT OK !
//
Programmierung II
234
GeoObjekt (const GeoObjekt & go) : g(new Geo) {}
...
private:
Geo * g;
// g zeigt auf eine Ableitung von Geo
};
So einfach kann es nicht sein. Der Zeiger g in GeoObjekt zeigt nicht auf ein Geo sondern auf einen Punkt,
Vektor oder eine Gerade. Kopiert man ein GeoObjekt, dann soll sicher nicht ein sinnloses Objekt der Basisklasse erzeugt werden. Das Objekt, auf das g tatsächlich zeigt, soll kopiert werden. Leider ist in GeoObjekt
aber nicht bekannt, auf was g tatsächlich zeigt. Der new–Operator kann darum hier nicht aufgerufen werden.
8.6.3
Objekte die sich selbst klonen können
GeoObjekt kann das, auf das g zeigt, nicht kopieren, da es seinen genauen Typ nicht kennt. Das Objekt *g
kennt sich aber selbst und kann darum auch eine korrekte Kopie seiner selbst erzeugen. Wir definieren dazu eine
Methode clone in jeder Ableitung und natürlich auch virtuell in der Basisklasse:
class Geo {
public:
...
virtual Geo * clone () const = 0; // Geo-s koennen sich klonen
..
};
class Vektor : public Geo {
public:
...
Vektor * clone () const {
// ich klone mich selbst
return new Vektor(*this);
// mit meinem Kopierkonstruktor!
}
...
};
.. Punkt, Gerade entsprechend ..
Damit können jetzt Kopierkonstruktor und Zuweisungsoperator für GeoObjekt definiert werden:
class GeoObjekt {
public:
GeoObjekt (const GeoObjekt &go)
: g(go.g == 0 ? 0 : go.g->clone()) {}
GeoObjekt & operator= (const GeoObjekt & go) {
if (&go != this) { // Zuweisung an mich ausschliessen
delete g;
if ( go.g==0 ) g=0;
else g = go.g->clone();
}
return *this;
}
...
private:
Geo * g;
}
Geo::clone ist eine rein virtuelle Methode. Mit g->clone() wird immer die Methode aktiviert, die zum Typ
des Objekts gehört, auf das g gerade zeigt.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
8.6.4
235
Übersicht: Geometrische Objekte
Die Definition unserer geometrischen Objekte ist jetzt komplett. Zur Übersicht listen wir noch einmal alle Klassen
auf:
// Abstrakte Basisklasse
//
class Geo {
public:
virtual ˜Geo() {}
virtual Geo * clone () const = 0;
virtual istream & read (istream &) = 0;
virtual ostream & write (ostream &) const = 0;
};
//----------------------------------------------------------------------// Umschlagklasse (fuer die Benutzer)
//
class GeoObjekt {
friend istream & operator>> (istream &, GeoObjekt &);
friend ostream & operator<< (ostream &, const GeoObjekt &);
public:
GeoObjekt ();
˜GeoObjekt ();
GeoObjekt (const GeoObjekt &);
GeoObjekt & operator= (const GeoObjekt &);
private:
Geo * g;
};
//----------------------------------------------------------------------//
class Vektor : public Geo {
public:
Vektor ();
// Nullvektor
Vektor (float, float);
Vektor (const Vektor &); // Kopierkonstruktor
Vektor * clone () const;
Vektor & operator=
Vektor
operator+
Vektor
operatorbool
operator==
(const
(const
(const
(const
Vektor
Vektor
Vektor
Vektor
&);
&) const;
&) const;
&) const;
//
//
//
//
Zuweisung
Vektoraddition
Vektorsubtraktion
Vergleich
float x () const; // x-Koordinate
float y () const; // y-Koordinate
static bool kollinear
(const Vektor &, const Vektor &);
static float determinante (const Vektor &, const Vektor &);
istream & read (istream &);
ostream & write (ostream &) const;
private:
float x_;
float y_;
};
Vektor operator* (float s, const Vektor &); // Skalarmultiplikation
Programmierung II
236
//----------------------------------------------------------------------//
class Punkt : public Geo {
public:
Punkt ();
Punkt (const Vektor &);
Punkt (float, float);
Punkt (const Punkt &);
Vektor pos () const;
// Position des Punkts
Punkt * clone () const;
istream & read (istream &);
ostream & write (ostream &) const;
private:
Vektor ov_;
};
// ein Punkt ist durch seinen Ortsvektor definiert
//----------------------------------------------------------------------//
class Gerade : public Geo {
public:
Gerade ();
Gerade (const Vektor &, const Vektor &);
Gerade (const Punkt &, const Vektor &);
Gerade (const Gerade &);
Gerade * clone () const;
Gerade & operator= (const Gerade
bool
operator== (const Gerade
bool
operator|| (const Gerade
Punkt
operator* (const Gerade
&);
&) const;
&) const;
&) const;
//
//
//
//
Zuweisung
Vergleich
Parallel
Schnittpunkt
Vektor p () const; // Punkt
Vektor a () const; // Richtung
static const Gerade x_Achse;
static const Gerade y_Achse;
istream & read (istream &);
ostream & write (ostream &) const;
private:
Vektor
Vektor
};
p_;
a_;
Mit diesen Definitionen sind jetzt Anwendungen folgender Art möglich:
int main (){
vector<GeoObjekt>
GeoObjekt g;
cin >> g;
v.push_back(g);
cout << g << endl;
}
v;
// Behaelterklasse ohne Zeigerelemente
// Einlesen von Objekten abgeleiteter Typen
// Kopieren von Objekten abgeleiteter Typen
// Ausgabe von Objekten abgeleiteter Typen
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
237
Einlesen, Ausgeben, Kopieren sind jetzt an den Polymorphismus angepasst und die für den Polymorphismus erforderlichen Zeiger in GeoObjekt versteckt. Wir haben also insgesamt aus der Basisklasse und ihren Ableitungen
anständige Mitglieder der Typfamilie gemacht.
8.7
Beispiel: Tupel
Die am Beispiel der geometrischen Objekte zusammengetragenen Probleme und Lösungstechniken wollen wir
hier an einem kleinen Beispiel noch einmal kompakt darstellen.
8.7.1
Tupel: Paare oder Tripel
Ein Tupel ist entweder ein Paar mit zwei Elementen oder ein Tripel mit drei Elementen. Wir modellieren dies
mit einer Ableitungshierarchie mit der Basisklasse NTupel und ihren Ableitungen Paar und Tripel und einer
Umschlagklasse Tupel
class NTupel {..};
// Basisklasse
class Paar
: public NTupel {
...
private:
int i, j;
};
class Tripel : public NTupel {
...
private:
int i, j, k;
};
class Tupel {
...
private:
NTupel * b;
};
// 1. Ableitung
// 2. Ableitung
// Umschlagklasse
Die Umschlagklasse Tupel wird zur Benutzung freigegeben (Inhalt der H-Datei), alle anderen Klassen sind Bestandteil der Implementierung von Tupel (Inhalt der C–Datei):
//Tupel.h:
class NTupel; //Vorausdekl.
class Tupel {....};
8.7.2
//Tuplel.cc
... Definition aller Klassen ...
... ausser Tupel
...
Verwendung und Definition der Tupel
Der Einfachheit halber beschränken wir uns darauf Tupel definieren, eingeben und ausgeben zu können:
// Basisklasse
//
class NTupel {
public:
virtual ˜NTupel () {}
virtual NTupel * clone()
= 0;
virtual ostream & schreib (ostream &) = 0;
};
// Ableitung 1
//
class Paar : public NTupel {
Programmierung II
238
public:
Paar (int p_i, int p_j) : i(p_i), j(p_j) {}
NTupel * clone() {
return new Paar(*this); // Aufruf generierter Kopierkonstr.
}
ostream & schreib (ostream & os) {
return os << "<" << i << "," << j << ">";
}
private:
int i, j;
};
// Ableitung 2
//
class Tripel : public NTupel {
public:
Tripel (int p_i, int p_j, int p_k) : i(p_i), j(p_j), k(p_k) {}
NTupel * clone() {
return new Tripel(*this); // Aufruf generierter Kopierkonstr.
}
ostream & schreib (ostream & os) {
return os << "<" << i << "," << j << "," << k << ">";
}
private:
int i, j, k;
};
//Umschlagklasse
//
class Tupel {
friend istream & operator>> (istream &, Tupel &);
friend ostream & operator<< (ostream &, const Tupel &);
public:
Tupel(int i, int j)
: b(new Paar(i,j))
{}
Tupel(int i, int j, int k) : b(new Tripel(i,j,k)) {}
˜Tupel() { delete b; }
Tupel(const Tupel &p_tupel) : b(p_tupel.b->clone()) {}
Tupel & operator= (const Tupel & p_tupel) {
if ( this != &p_tupel ) {
delete (b);
if ( p_tupel.b == 0 ) b = 0;
else b = p_tupel.b->clone();
}
return *this;
}
private:
NTupel * b;
};
istream & operator>> (istream & is, Tupel & t) {
... Details unterdrueckt ...
t.b = new Paar(...);
.. oder ...
t.b = new Tripel(...);
return is;
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
239
ostream & operator<< (ostream & is, const Tupel & t) {
return t.b->schreib(is);
}
Die Definition der Klassen wird in diesem Beispiel dadurch stark vereinfacht, dass Paare und Tripel keine Zeigerkomponenten haben und dadurch ihre vom Compiler generierten Kopierkonstruktoren und Zuweisungsoperatoren
korrekt und ausreichend sind.
Paare und Tripel haben hier zwar jeweils Schreibmethoden aber keine eigenen Lesemethoden. Ihre Basisklasse hat
darum zwar eine virtuelle Schreib– aber keine virtuelle Lesemethode. Paare und Tupel können bei der Eingabe
nicht an ihrem ersten Zeichen unterschieden werden können. Beide beginnen mit einem <–Zeichen. Die Eingabe
kann darum nicht nach dem Lesen des ersten Zeichens an eine spezialisierte Eingaberoutine für Paare bzw. Tupel
delegiert werden.
8.8
8.8.1
Beispiel: Syntaxbaum, Implementierung mit Polymorphismus
Eine polymorphe Ausgabefunktion
Mit Hilfe des Polymorphismus kann jetzt das im Kapitel Vererbung bereits begonnene Beispiel der Syntaxbäume
vervollständigt werden. Damit jeder Knoten entsprechend seiner Art ausgegeben wird, muss die Ausgabefunktion
virtuell sein:
class Ausdruck {
friend std::ostream & operator<< (std::ostream &, const Ausdruck &);
...
private:
...
Knoten *
ak;
};
class Ausdruck::Knoten {
public:
virtual std::ostream & write (std::ostream &) const = 0;
...
};
class Ausdruck::WertKnoten : public Ausdruck::Knoten {
public:
...
std::ostream & write (std::ostream &os ) const { return os << wert}
private:
Wert wert;
};
class Ausdruck::OpKnoten : public Ausdruck::Knoten {
public:
...
std::ostream & write (std::ostream &) const {
return os << "(" << l->write() << op << r->write() << ")";
}
private:
char
op;
Knoten *l, *r;
};
ostream & operator<< (std::ostream &os, const Ausdruck &a) {
return a.ak->write(os);
}
Programmierung II
240
Äquivalent zu dieser einfachen Ausgabefunktion kann jede beliebige andere Verarbeitungsfunktion für Syntaxbäume definiert werden.
8.8.2
Syntaxbäume klonen
Konstruktoren, insbesondere der Kopierkonstruktor, können nicht polymorh sein. Wollen wir Objekte, auf die
Zeiger der Bsasisklasse zeigen, kopieren, dann werden Clone–Funktionen benötigt. Das ist in Beispiel der Syntaxbäume der Fall: Objekte vom Typ Ausdruck oder Ausdruck::OpKnoten enthalten Zeiger auf die Basisklasse Ausdruck::Knoten.
Eine entsprechende Erweiterung der Syntaxbäume ist schnell definiert. Das vollständige Beispiel ist:
class Ausdruck {
friend ostream & operator<< (ostream &, const Ausdruck &);
friend istream & operator>> (istream &, Ausdruck &);
public:
Ausdruck ()
: ak(0) {}
Ausdruck (int i) : ak(new WertKnoten(i)) {}
Ausdruck (char op, const Ausdruck &a1, const Ausdruck &a2);
Ausdruck (const Ausdruck &);
Ausdruck & operator=(const Ausdruck &);
˜Ausdruck ();
int berechne(); // Wert des Ausdrucks berechnen
private:
class Knoten;
class WertKnoten;
class OpKnoten;
Knoten *
ak;
};
class Ausdruck::Knoten {
public:
virtual ˜Knoten () {}
virtual ostream & write (ostream &) const = 0;
virtual Knoten * clone () const = 0;
virtual int berechne () const = 0;
};
// Kopierkonstruktor und Zuweisungsoperator werden
// korrekt erzeugt
//
class Ausdruck::WertKnoten : public Ausdruck::Knoten {
public:
WertKnoten (int p_wert) : wert(p_wert) {}
ostream & write (ostream &os) const {
return os << wert;
}
WertKnoten * clone () const {
return new WertKnoten(wert);
}
int berechne () { return wert; }
private:
int
wert;
};
int anwende (char op, int l, int r) {
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
switch (op) {
case ’+’: return l+r;
case ’-’: return l-r;
case ’*’: return l*r;
case ’/’: return l/r;
default: cerr<<"Unbekannter Operator "<<op<<"\n"; exit(1);
}
}
// clone ruft Kopierkonstruktor
// Kopierkonstruktor ruft clone
// tiefe Kopie durch gegenseitige Rekursion
//
class Ausdruck::OpKnoten : public Ausdruck::Knoten {
public:
OpKnoten (char p_op, Knoten *p_l, Knoten *p_r) : op(p_op), l(p_l), r(p_r) {}
˜OpKnoten () { delete l; delete r; }
OpKnoten (const OpKnoten &);
OpKnoten & operator= (const OpKnoten &);
OpKnoten * clone () const { return new OpKnoten(*this); }
ostream & write (ostream &os) const {
os << "("; l->write(os); os << op; r->write(os); os << ")";
return os;
}
int berechne () { return anwende (op, l->berechne(), r->berechne()); }
private:
char
op;
Knoten *l, *r;
};
Ausdruck::Ausdruck (char op, const Ausdruck & a1, const Ausdruck & a2)
: ak( new OpKnoten(op, a1.ak->clone(), a2.ak->clone()) ) {}
Ausdruck::Ausdruck (const Ausdruck &a)
: ak( a.ak->clone() ) {}
Ausdruck::˜Ausdruck () { delete ak; }
Ausdruck & Ausdruck::operator=(const Ausdruck &a) {
if ( &a != this ) {
delete (ak);
ak = a.ak->clone();
}
return *this;
}
Ausdruck::OpKnoten::OpKnoten (const OpKnoten & k) {
op = k.op;
l = k.l->clone();
r = k.r->clone();
}
Ausdruck::OpKnoten & Ausdruck::OpKnoten::operator=(const OpKnoten & k) {
if (&k != this) { // keine Zuweisung an sich selbst
delete(l);
delete(r);
l = k.l->clone();
r = k.r->clone();
241
Programmierung II
242
}
return *this;
}
ostream & operator<< (ostream &os, const Ausdruck &a) {
if ( a.ak == 0 )
return os<< "UNDEFINIERTER-AUSDRUCK";
else
return a.ak->write(os);
}
istream & operator>> (istream &is, Ausdruck &a) {
char
z;
Ausdruck a1,
a2;
char
op;
if (!(is >> z)) { cerr << "Eingabefehler\n"; exit (1); }
switch (z){
case ’(’ :
is >> a1;
is >> op;
is >> a2;
a.ak = new Ausdruck::OpKnoten(op, a1.ak, a2.ak);
a1.ak = 0;
a2.ak = 0;
is >> z;
if ( z == ’)’ ) return is;
else cerr << "Ausdrucksfehler\n"; exit (1);
case ’0’: case ’1’: case ’2’: case ’3’: case ’4’:
case ’5’: case ’6’: case ’7’: case ’8’: case ’9’:
a = Ausdruck (z-’0’);
return is;
default: cerr << "Ausdrucksfehler\n"; exit (1);
}
}
8.8.3
Muster der Klassendefinitionen
Die Definition der Syntaxbäume folgt dem gleichen Muster wie die der geometrischen Objekte (siehe Abbildung
74):
Die Umschlagklasse (GeoObjekt, Ausdruck) enthält einen Zeiger auf Basisklasse. Der Zeiger ist notwendig
um Polymorphismus zu aktivieren. Die Basisklasse schützt den Anwender vor den damit verbundenen Problemen. Die Basisklasse definiert alle Benutzeroperationen und hat die Ein–/Ausgabeoperatoren als Freunde.
Die Basisklasse (Geo, Knoten) definiert die gemeinsamen Operationen aller Ableitungen, inklusive Lese–
und Schreibmethoden, sowie clone als virtuelle Methoden.
Die Ableitungen (Punkt, Vektor, Gerade; bzw. Wertknoten, OpKnoten) implementieren die virtuellen Methoden der Basisklasse.
Diese Klassendefinitionen bilden eine Einheit deren Schnittstelle zur Anwendung von der Umschlagklasse
gebildet wird; nach dem Motto: Anwender / Anwendungscode kommen weder in Kontakt mit Zeigern noch
mit Ableitungen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
243
Dieses Muster ist wichtiger als die beiden Beispiele. Es sollte nach Möglichkeit bei Klassendefinitien eingesetzt
werden, die denen Ableitungen und Polymorphismus eine Rolle spielen.
Ausdruck
Knoten
berechne
GeoObjekt
Geo
Umschlagklasse
write
Basisklasse
clone
Umschlagklasse
berechne
Basisklasse
WertKnoten
OpKnoten
write
clone
berechne
write
clone
berechne
Vektor
Ableitungen
Syntaxbäume
Punkt
Gerade
Ableitungen
Geometrische Objekte
Abbildung 74: Umschlagklasse, Basisklasse und Ableitungen als Muster
8.9
8.9.1
Typinformationen und dynamische Typkonversionen
Expliziter Zugriff auf dynamische Typinformation
Beim Aufruf einer virtuellen Methode über einen Zeiger wird der aktuelle Typ des Objekts bestimmt und dann die
dazu passende richtige Methode aktiviert. Dies alles erfolgt ohne Zutun des Programms. Die Typinformationen
werden implizit ausgewertet. C++ stellt daneben aber auch zwei Konstrukte zur Verfügung mit denen explizit auf
dynamische Typinformation zugegriffen werden kann:
Den Konversionsoperator dynamic cast, sowie
den typeid–Operator.
Der Konversionsoperator erlaubt die Konversion eines Objekts von einer Stufe in der Ableitungshierarchie seiner
Klasse zu einer anderen. Der Operator stellt den aktuellen Typ eines Ausdrucks fest.
8.9.2
dynamic cast: Konversion zwischen Basisklasse und abgeleiteter Klasse
Mit dem dynamic cast–Operator kann zwischen Klassen in einer gemeinsamen Ableitungshierarchie konvertiert werden, wenn die Ableitungshierarchie mindestens eine virtuelle Methode enthält. Beispiel:
class Basis
{
public:
virtual void f() {};
// <<<---- virtuelle Methode
int b;
// jedes Objekt muss mit einer Typmarkierung versehen sein
};
// um die richtige Variante von f aufrufen zu koennen
class Ab : public Basis {
public:
int a;
};
...
Programmierung II
244
Basis * pb = new Ab;
pb->a = 0;
Ab
// pb zeigt formal auf ein Basis-Objekt
// FEHLER: pb->a nicht zugreifbar
* pa;
pa = pb;
// FEHLER: Zuweisung nicht moeglich !
pa = dynamic_cast<Ab *>(pb);
pa->a = 0;
// pa zeigt formal auf ein Ab-Objekt
// OK
}
Hier wird der Zeiger auf die Basisklasse in einen Zeiger auf die abgeleitete Klasse konvertiert. Das funktioniert
natürlich nur, wenn der Zeiger tatsächlich auf ein Objekt der abgeleiteten Klasse zeigt. Wenn das nicht der Fall ist,
dann liefert die Konversion den 0–Zeiger als Wert.
Die dynamische Konversion eines Zeigers in den “richtigen” Typ funktioniert nur, wenn die Klassenhierarchie
virtuelle Methoden enthält. Das kann man sich damit erklären, dass nur bei Klassen mit virtuellen Methoden die
Notwendigkeit besteht, Objekten dieser Klassen zur Laufzeit eine Markierung anzuheften, die Auskunft über den
Typ gibt. Ohne virtuelle Methoden fehlt die Markierung und die dynamische Typkonversion kann nicht ausgeführt
werden.
Die Verwendung von dynamic cast ist nicht auf Polymorphismus beschränkt. Sind die konvertierten Objekte nicht Mitglieder einer Klassenhierarchie mit virtuellen Methoden, dann entspricht dynamic cast einen
static cast.
8.9.3
Ein Beispiel für den Einsatz von dynamic cast
Der dynamic cast–Operator wird oft eingesetzt, um auf eine Funktionalität der abgeleiteten Klasse zuzugreifen, die in der Basisklasse nicht vorhanden ist. Beispiel:
// Angestellte
//
class Angestellte {
public:
Angestellte (const string & n) : name(n) {}
virtual ˜Angestellte () {}
virtual void praemie () = 0;
// alle Angestellten erhalten Praemien
protected:
string
name;
unsigned int gehalt;
};
// Programmierer
//
class Programmierer : public Angestellte {
public:
Programmierer (string n, string s)
: Angestellte(n), sprache(s)
{}
void praemie () {
std::cout << name << ": " << 250 << endl;
}
private:
string sprache;
};
// 250 Euro Praemie
// fuer Programmierer
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
245
// Manager
//
class Manager : public Angestellte {
public:
Manager (string n, unsigned int z)
: Angestellte(n), zahlUntergebene(z)
{}
void praemie () {
std::cout << name << ": " << 1000 << endl; // 1000 Euro Praemie
}
void extraPraemie () {
// und eine Extra-Praemie
std::cout << name << ": " << 1500 << endl; // nur fuer Manager
}
private:
unsigned int zahlUntergebene;
};
// Polymorphe Funktion
// Druckt Praemien aus und - nur bei Manager - auch die Extra-Praemie
//
void erfolgsPraemien ( list<Angestellte *> l ) {
Manager *
manager;
for ( list<Angestellte *>::iterator i = l.begin();
i != l.end();
++i) {
(*i)->praemie();
// Jedem das Seine
manager = dynamic_cast<Manager *>(*i);
if (manager)
// manager != 0 falls *i ein Manager ist
manager->extraPraemie(); // Managern aber noch etwas mehr!
}
}
Die polymorphe Funktion druckt die Prämien aller Angestellten aus. Die Höhe der Prämie hängt vom Status des
Angestellten ab (praemie ist virtuell). Nur Manger haben die Methode extraPraemie. Um auf sie zugreifen
zu können, wird versucht jeden Zeiger auf einen Angestellten in einen Zeiger auf einen Manager zu konvertieren. Wenn dies gelingt, ist das Ergebnis der Konversion ungleich Null und zeigt auf einen Manager. Dessen
extraPraemie ist jetzt zugreifbar.
Normalerweise sind virtuelle Methoden ausreichend, um die Unterschiede zwischen den Klassen in einer Ableitungshierarchie zugänglich zu machen. Der dynamic cast–Operator sollte auf besondere Situationen beschränkt bleiben.
8.9.4
typeid–Operator: Typ eines Objekts feststellen
Mit dem typeid–Operator kann der aktuelle Typ eines Objekts festgestellt werden. Genau wie bei
dynamic cast setzt die korrekte Erkennung eines abgeleiteten Typs die Existenz mindestens einer virtuellen
Methode in der Ableitunghierarchie voraus. Beispiel:
#include <iostream>
#include <typeinfo>
using namespace std;
class Basis {
public:
Programmierung II
246
Basis () {}
virtual void f () {} // <<---- virtuelle Methode
};
class Abgeleitet : public Basis {
public:
Abgeleitet () {}
};
int main() {
Basis
* pb1 = new Basis;
Basis
* pb2 = new Abgeleitet;
Abgeleitet * pa = new Abgeleitet;
// Typnamen ausgeben:
//
cout << typeid(*pb1).name() << endl;
cout << typeid(*pb2).name() << endl;
cout << typeid(*pa).name() << endl;
// Ausgabe: Basis
// Ausgabe: Abgeleitet
// Ausgabe: Abgeleitet
Abgeleitet ab;
Basis *
pb;
pb = ....
// Typ dynamisch testen
//
if ( typeid(*pb) == typeid(ab) ) {
... pb zeigt auf ein abgeleitetes Objekt ...
}
}
typeid kann benutzt werden um den Namen des Typs eines Ausdrucks festzustellen und auch um den Typ eines
Ausdrucks durch Vergleich zu testen.
So wie dynamic cast nicht auf den Einsatz in Zusammenhang mit Polymorphismus beschränkt ist kann auch
der typeid–Operator für beliebige Objekte verwendet werden. Beispielsweise kann mit:
int main() {
cout << typeid(0.9).name() << endl;
}
festgestellt werden, ob der Ausdruck 0.9 für den Compiler den Typ float oder double hat.
8.9.5
type info: Information über den Typ eines Objekts
Der typeid–Operator liefert als Wert ein Objekt vom Typ type info. Dieser Typ bietet die Vergleichsoperationen == und != sowie die Methode name. Die weitere Definition ist implementierungsabhängig und soll hier
nicht weiter erörtert werden.
8.10
Beispiel: Multimethoden
8.10.1 Typinformationen und die Auswahl von Operationen
In C++ wird die Information über den Typ der Objekte implizit genutzt, um eine von mehreren möglichen Operationen auszuwählen. Prinzipiell unterscheidet man die Mechanismen zur Auswahl der richtigen Operation danach,
ob sie die zur Übersetzungszeit, oder zur Laufzeit aktiviert werden:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
247
Mechanismen zur Übersetzungszeit:
– Überladung einer freien Funktion oder Methode:
Der (statische) Typ der Argumente entscheidet über die zu aktivierende Methode oder Funktion.
– Redefinition einer Methode in einer Ableitung:
Der (statische) Typ eines Objekts entscheidet über die zu aktivierende Methode.
– Funktionstemplate als Funktion verwenden:
Der Typ der Argumente entscheidet darüber welche Templateinstanz erzeugt und aktiviert wird.
Mechanismen zur Laufzeit:
– Polymorphismus:
Der (dynamische) Typ eines Objekts entscheidet über die zu aktivierende (virtuelle) Methode.
Diese Aufstellung zeigt ein starkes Ungleichgewicht zu Gunsten statischer Typinformationen. Der einzige
Auswahl–Mechanismus, der dynamisch bestimmte Typinformationen verarbeitet, ist der Polymorphismus: Die
Festlegung auf eine von mehreren möglichen virtuellen Methoden über den dynamischen Typ eines Objekts. C++
ist nun einmal eine primär statisch typisierte Programmiersprache.
8.10.2 Polymorphismus in C++: Der dynamische Typ nur eines Objekts wird beachtet
Bei der Überladung und bei der Verwendung von Templates können die Typen von im Prinzip beliebig vielen
Objekten als Auswahlkriterium herangezogen werden. Für die dynamische Auswahl steht nur der Polymorphismus
zur Verfügung und der richtet sich stets nach dem Typ genau eines Objekts.
x = f(a, b, c);
x = a->f(b, c);
// Auswahl von f durch den Typ von a, b und c
// Auswahl von f nur durch den Typ von a
Diese Beschränkung wird unangenehm bewußt, wenn eine Funktion implementiert werden soll, die von den Typen mehrerer Argumente abhängig ist. Angenommen wir haben mehrere arithmetische Klassen, etwa Natur für
natürliche Zahlen und Rational für rationale Zahlen mit jeweils einer Addtitionsmethode und einer Konversionsoperation von natürlichen zu rationalen Zahlen:
class Natur {
public:
...
Natur add (const Natur &) const;
...
};
class Rational {
public:
...
Rational (const Natur &); // Konversion Natur -> Rational
Rational add (const Rational &) const;
...
};
Für diese arithmetischen Typen kann problemlos ein Satz überladener Additionsoperatoren für gemischte Arithmetik definiert werden:
Natur
operator+ (const Natur
& n1, const Natur
& n2) {
return n1.add(n2);
}
Rational operator+ (const Rational & r1, const Rational & r2) {
return r1.add(r2);
}
Rational operator+ (const Rational & r, const Natur
& n) {
Programmierung II
248
return r.add(Rational(n));
}
Rational operator+ (const Natur
return (Rational(n)).add(r);
}
& n,
const Rational & r)
{
Bei dynamischer Typbestimmung kommt man dagegen in ernsthafte Probleme. Angenommen Natur und
Rational haben den Basistyp Zahl mit virtueller Additionsmethode:
class Zahl {
public:
virtual Zahl * addV (Zahl *) = 0;
};
class Natur: public Zahl {
public:
...
Zahl * addV (Zahl *) {
... linker Operand ist klar vom Typ Natur
...
... rechter Operand: Zahl mit unbekanntem abgeleiteten Typ ...
... Welche Addition ????
...
}
...
};
class Rational : public Zahl {
public:
...
Zahl * addV (Zahl *) {
... linker Operand ist klar vom Typ Rational
...
... rechter Operand: Zahl mit unbekanntem abgeleiteten Typ ...
... ????? Welche Additionsoperation wird aufgerufen ????
...
}
...
};
Bei binären Operationen wie der Addition, deren Auswahl vom Typ beider Operanden abhängt, kann eine automatische Auswahl nur mit statischen Mechanismen getroffen werden ( Überladung oder Template). Eine dynamische
Auswahl, die auf mehr als einem Argument basiert, ist nicht möglich.
8.10.3 Multimethoden
Als Multimethoden bezeichnet man Funktionen, die einen Aufruf, abhängig vom dynamischen Typ von mehr als
einem Argument, auf spezialisierte Funktionen verteilen.23 Eine Druck–Aktion hängt beispielsweise nur vom Typ
dessen ab, was gedruckt werden soll: Sie ist keine Multimethode und kann bequem als virtuelle Methode des zu
druckenden Objekts realsisert werden. Der Compiler erzeugt automatisch den Code der die Typinformation der
Objekte verwaltet und bei Bedarf beachtet. Eine Multiplikation dagegen wird in ihrem Ablauf vom Typ beider
Argumente beeinflusst: sie kann nicht bequem und einfach als virtuelle Methode realisiert werden: die Typen der
Argumente müssen im Programmcode explizit bestimmt und beachtet werden.
Für unser arithmetisches Beispiel benötigen wir eine Multimethode mit folgender Struktur:
// Multimethode zur gemischten Addition rationaler und natuerlicher Zahlen
//
Zahl * operator+ ( Zahl * z1, Zahl * z1 ) {
*z1 ist natueliche Zahl
23 Man spricht
bei “Multimethoden” auch von “multiple dispatching” im Gegensatz zu “single dispatching”.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
249
==> *z2 ist natuerliche Zahl
==> Addition natuerlicher Zahlen
*z2 ist rationale Zahl
==> Konversion von *z1; Addition rationaler Zahlen
*z1 ist rationale Zahl
==> *z2 ist rationale Zahl
==> Addition rationaler Zahlen
*z2 ist natuerliche Zahl
==> Konversion von *z2; Addition rationaler Zahlen
}
Eine Multimethode kann in C++ mühsam und unübersichtlich mit virtuellen Methoden, oder übersichtlich und
elegant mit explizit ausprogrammiertem Zugriff auf die dynamische Typinformation der Objekte realisiert werden.
8.10.4 Beispiel: Ausgangspunkt Gemischte Arithmetik mit statischer Typanalyse
Nehmen wir an, dass Klassen zur Darstellung natürlicher und rationaler Zahlen definiert wurden. Jede Klasse hat
ihre eigenen dyadischen und unären Operationen. Daneben wurden Konversionen und Operatoren für gemischte
Arithmetik definiert. Der Einfachheit halber beschränken wird und auf die Multiplikation als dyadischer Operation
und auf das unäre Minus:
class Natur {
// natuerliche Zahlen
friend class Rational;
public:
Natur () : w(0) {}
Natur (const Natur &n) : w(n.w) {}
Natur (unsigned int pw) : w(pw) {}
Natur mult (const Natur &pn) const { return Natur(pn.w + w); }
private:
unsigned int w;
};
class Rational {
// rationale Zahlen
friend Rational operator- (const Rational &); // unaeres private:
enum VZ {plus, minus};
VZ
vz;
unsigned int z, n;
Rational (VZ, unsigned int, unsigned int);
public:
Rational () : z(0), n(0) {}
Rational (unsigned int pz, unsigned int pn) : vz(plus), z(pz), n(pn) {}
Rational (const Natur &pn) : vz(plus), z(pn.w), n(1) {}
Rational (const Rational &r) : vz(r.vz), z(r.z), n(r.n) {}
Rational mult (const Rational &r) const;
friend Rational::VZ operator* (Rational::VZ, Rational::VZ); // * auf Vorzeichen
};
Rational::VZ operator* (Rational::VZ vz1, Rational::VZ vz2) {
if (vz1==vz2) return Rational::plus;
else return Rational::minus;
}
Rational::Rational (VZ pvz, unsigned int pz, unsigned int pn)
: vz(pvz), z(pz), n(pn) {}
Rational Rational::mult (const Rational &r) const {
Programmierung II
250
return Rational (vz * r.vz, z*r.z, n*r.n);
}
// unaeres Minus
Rational operator- (const Rational &r) {
return Rational (r.vz*Rational::minus, r.z, r.n);
}
// Ueberladene Operatoren fuer reine und gemischte Multiplikationen
//
Natur
operator* (const Natur & n1, const Natur & n2) {
return n1.mult(n2);
}
Rational operator* (const Rational & r1, const Rational & r2) {
return r1.mult(r2);
}
Rational operator* (const Rational & r,const Natur & n) {
return r.mult(Rational(n));
}
Rational operator* (const Natur & n, const Rational & r) {
return (Rational(n)).mult(r);
}
Die beiden Klassen definieren jeweils ihre eigene Multiplikation und ihren eigenen Minus–Operator. In dem überladenen operator* wird die gemischte Arithmetik zentral und übersichtlich definiert. Natürliche und rationale
Argumente können problemlos gemischt werden, solange nur die jeweiligen Typen zur Übersetzungszeit bekannt
sind.
8.10.5 Eine Oberklasse Wert für rationale und natürliche Zahlen
Sollen Zahlen beliebigen Typs zur Laufzeit manipluliert werden, etwa indem Zahlen unterschiedlicher Art eingelesen oder in einer Liste gespeichert werden, dann wird eine dynamische Typanalyse, also Polymorphismus benötigt.
Wir haben dann also eine Oberklasse zu definieren, in der die Varianten zusammengefasst sind.
Entsprechend unsrer üblichen Vorgehensweise definieren wir eine Basisklasse Zahl und eine Umschlagklasse
Wert. Die Ausgangsdefinition wird also wie folgt erweitert:
class Zahl { ... };
// Basisklasse
class Natur
: public Zahl { ... };
class Rational : public Zahl { ... };
// Ableitungen
class Wert {
...
private:
Zahl * pz;
};
// Umschlagklasse
Auf dem Typ Wert sollen jetzt polymorphe Varianten24 des unären Operators “operator-” und des dyadischen
Operators “operator*” definiert werden. Ein erster Ansatz besteht darin Zahl mit einer virtuellen Multiplikationen und einem virtuellen Minus auszustatten, die dann in den Ableitungen redefiniert werden. Beginnen wir mit
der Erweiterung für die Multiplikation:
class Zahl {
...
virtual Zahl * multP (const Zahl &) const;
};
class Natur : public Zahl {
...
Rational mult (const Rational &r) const;
24 D.h.
solche mit dynamischer Typanalyse.
// <-- ?? wird ersetzt durch
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
Zahl *
251
multP (const Zahl &) const;
// polymorphe Variante ??
};
class Rational : public Zahl {
...
Natur mult (const Natur &pn) const;
Zahl *
multP (const Zahl &) const;
};
// <-- ?? wird ersetzt durch
// polymorphe Variante ??
Schon diese ersten Zeilen erzeugen einen starken Widerwillen: Neben oder auch an Stelle der vorhandenen “statischen” Multiplikationen (mult) müssen polymorphe Varianten (multP) eingeführt werden. Dazu muss massiv in
den vorhandenen – mühsam entwickelten und getesteten – Code für die Klassen Natur und Rational eingegriffen werden. Das widerspricht den elementarsten softwaretechnischen Prinzipen, nach denen ein neues Leistungsmerkmal möglichst unabhängig von bereits vorhanden realisiert werden soll. Kurz und gut: virtuelle Methoden
führen zu Murks.
8.10.6 Multimethoden mit expliziter dynamischer Typprüfung
Virtuelle Methoden sind nicht geeignet den gewünschten Polymorphismus zu den vorhandenen Klassen hinzuzufügen. Mit explizit ausprogrammierter Typprüfung sieht die Sache glücklicherweise deutlich besser aus:
class Zahl {
public:
virtual ˜Zahl() {}
};
class Natur: public Zahl
{ ... unveraendert ... };
class Rational : public Zahl { ... unveraendert ... };
...
...
...
Alle Methoden und die
statischen Versionen der Operatoren
unveraendert
...
...
...
// Polymorphe Wertklasse
//
class Wert {
friend Wert operator- (const Wert &); // unaeres friend Wert operator* (const Wert &, const Wert &); // Multiplikation von Werten
public:
explicit Wert( const Natur & pn )
: pz( new Natur(pn)) {}
explicit Wert( const Rational & pr) : pz( new Rational(pr)) {}
˜Wert() { delete pz; }
private:
Zahl * pz;
};
// polymorphe Version des unaeren operator//
Wert operator- (const Wert &w) {
if ( Rational *pr = dynamic_cast<Rational *>(w.pz) )
return Wert(-(*(pr)));
if ( Natur *pn = dynamic_cast<Natur *>(w.pz) )
return Wert(-(*(pn)));
std::cerr << "FEHLER: Typ(w) ??\n"; exit (1);
}
// polymorphe Version des dyadischen operator*
Programmierung II
252
//
Wert operator* (const Wert &w1, const Wert &w2){
if ( Rational *r1 = dynamic_cast<Rational *>(w1.pz) ) {
if ( Rational *r2 = dynamic_cast<Rational *>(w2.pz) )
return Wert( *r1 * *r2 );
else if ( Natur *n2 = dynamic_cast<Natur *>(w2.pz) )
return Wert( *r1 * *n2 );
else { std::cerr << "FEHLER: Typ(w2) ?? \n"; exit (1); }
}
else if ( Natur *n1 = dynamic_cast<Natur *>(w1.pz) ) {
if ( Rational *r2 = dynamic_cast<Rational *>(w2.pz) )
return Wert( *n1 * *r2 );
else if ( Natur *n2 = dynamic_cast<Natur *>(w2.pz) )
return Wert( *n1 * *n2 );
else { std::cerr << "FEHLER: Typ(w2) ??\n"; exit (1); }
}
std::cerr << "FEHLER: Typ(w1) ??\n"; exit (1);
}
In dieser Lösung bleibt der vorhandene Code praktisch unverändert, nur public Zahl wird in die beiden Klassendefinitionen eingefügt. Alle Methoden und alle freien Operatoren können unverändert weiterverwendet werden.
Der vorhandene “statische” Code wird klar ersichtlich um die neue Funktionalität ergänzt: Eine Basisklasse, eine
Umschlagklasse und zur jeder statischen Funktion wird eine polymorphe Version definiert.
Standard–Lehrbücher von C++ empfehlen generell den Einsatz von virtuellen Methoden und erklären explizit
ausprogrammierte Typprüfungen und –Konversionen zu Stilfehlern. Daran kann man erkennen, dass Vererbung
und Polymorphismus wohl noch immer deutlich öfter empfohlen als tatsächlich ernsthaft praktiziert werden.
8.11
Vererbung und Templates
8.11.1 Generizität: Vielgestaltigkeit von Templates
Templates werden typischerweise eingesetzt, um Behälterklassen und auf ihnen operierende Algorithmen zu realisieren. Als einfaches Beispiel ein Tripel als Behälter mit drei Elementen:
template <class
class Tripel {
public:
T & get_1() {
T & get_2() {
T & get_3() {
private:
T x, y, z;
};
T>
return x; }
return y; }
return z; }
Ein solches Template beinhaltet eine Vielfalt von Klassen: Es kann die Form von Tripeln mit beliebigem Typ annehmen. Man spricht darum von Templates als generischen25 Klassen bzw. Funktionen. Die Vielgestaltigkeit von
Templates – ihre Generizität – unterscheidet sich drastisch von der Polymorphie, der Vielgestaltigkeit durch virtuelle Methoden. Generizität wird von manchen, z.B. von Stoustrup, auch parametrischer Polymorphismus genannt.
8.11.2 Templates als Ableitung von Templates
Eine einfache und weitverbreitete Kombination von Vererbung und Templates besteht darin, ein Template als
Ableitung eines anderen Templates zu definieren:
25 generisch: das
Genus, die Gattung, betreffend. Eine generische Klasse/Funktion ist eine Gattung von Klassen/Funktionen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
253
template <class T>
class Tripel { ... wie oben ... }
// Template als Ableitung eines Templates
//
template <class T>
class VierTupel : public Tripel<T> {
public:
T & get_4() { return u; }
private:
T u;
};
int main () {
VierTupel<int> q;
q.get_1()
q.get_2()
q.get_3()
q.get_4()
=
=
=
=
// VierTupel und Tripel werden instanziiert
1;
2;
3;
4;
}
Wenn das abgeleitete Klassentemplate instantiiert wird, dann wird auch die Basisklasse mit den gleichen Templateparametern instantiiert. Alle Möglichkeiten der Templates und der Vererbung können dabei kombiniert werden.
Beispielsweise können Methoden der Basisklasse virtuell sein und die Ableitung eine Templateinstanz als Komponente enthalten:
#include <iostream>
#include <list>
using namespace std;
template <class T>
class Liste {
public:
virtual ˜Liste() {}
virtual void ein (const T &)
virtual T aus ()
};
= 0;
= 0;
template <class T>
class UnsortierteListe : public Liste<T> {
public:
void ein (const T & t) {
l.push_back(t);
}
T aus () {
T temp = l.front();
l.pop_front();
return temp;
}
private:
list<T> l;
};
template<class T>
void listenTest ( Liste<T> * pl ) {
for (int i=0; i<10; ++i)
pl->ein(i);
Programmierung II
254
for (int i=0; i<10; ++i)
cout << pl->aus()<< " ";
cout << endl;
}
int main () {
UnsortierteListe<int> l;
listenTest<int> (&l);
}
8.11.3 Exotische Kombinationen von Templates und Vererbung
Templates und Vererbung können in vielfältiger Weise kombiniert werden. So können Templates Basisklassen von
Nicht–Templates sein und umgekehrt:
class Base { ... };
template <class T>class Ab: public Base { ... } ;
template <class T>class Base { ... };
class Ab: public Base<XY> { ... }
Eine Basisklasse kann als Templateparameter übergeben werden:
template <class T>
class A: public T { ... };
Sinnvolle Anwendung eines derart kreativen Umgangs mit den Möglichkeiten der Sprache sind nicht offensichtlich.
Zur Überraschung selbst langjähriger Kenner von C++ hat sich jetzt herausgestellt, dass es sie gibt. Sowohl den
Compilern als auch den Programmierern wird dabei aber einiges abgefordert. Die Diskussion der Möglichkeiten
und Anwendungen von Kombinationen der Vererbung und Templates sind darum ein fortgeschrittenes Thema.
8.12
Übersicht: Mechanismen der Vererbung und des Polymorphismus
8.12.1 Speicherplatz
Vererbung und Polymorphismus sind mächtige Hilfsmittel, die am Anfang gelegentlich etwas verwirrend wirken.
Sie basieren jedoch auf einigen Grundprinzipien, die für sich jeweils recht einfach und klar sind. Für sinnvolle
Anwendungen muss jedoch, wie wir oben gezeigt haben, in der Regel der gesamte Mechanismus aufgefahren
werden. Zum Verständnis sollte man sich auf die einzelnen Grundprinzipien konzentrieren.
Das erste der einfachen Prinzipien bezieht sich auf den Speicherplatz. Speicherplatz für beliebige Objekte wird
stets statisch, auf Basis des zur Übersetzungszeit bekannten Typs angelegt. Variablen von einem Basistyp können
darum immer nur Objekte vom Basistyp aufnehmen:
class
class
class
class
Geo
Geo {...};
Vektor : public Geo {...};
Punkt : public Geo {...};
Gerade : public Geo {...};
g;
// hier kann NUR ein Geo, nie ein Objekt einer Ableitung von
// Geo (Punkt, Vektor, Gerade) gespeichert werden!
vector<Geo> v;
// v wird immer NUR Geos enthalten, NIE einen Vektor, etc.
g = Vektor(2,3);
// Der Vektor wird auf seine Geo-Anteile reduziert
v.puh_back(Vektor(2,3)); // Der Vektor wird auf seine Geo-Anteile reduziert
Will man mit Objekten diverser abgeleiteter Typen arbeiten, dann müssen diese über Zeiger angesprochen werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
255
Geo *
g; // g kann ZEIGER auf Vektoren, Punkte und Geraden enthalten
vector<Geo *> v; // v kann ZEIGER auf Vektoren, Punkte und Geraden enthalten
8.12.2 Konstruktoren und Zuweisungsoperatoren
Der Compiler wählt den Zuweisungsoperator und die Konstruktoren, inklusive Kopierkonstruktor, immer auf Basis
des statischen – zur Übersetzungszeit bekannten – Typs aus.
void f (Geo pg) { ... };
Punkt p(1,1);
Vektor v(0,0);
f (p);
Geo g = v;
// nur der GEO-Anteil von p wird uebergeben
// nur der GEO-Anteil von v wird zugewiesen
Will man typunabhängig arbeiten, dann müssen Zeiger benutzt werden:
void f (Geo * pg) { ... };
Punkt p(1,1);
Vektor v(0,0);
Geo * pg;
f (&p);
// Ein Zeiger auf Punkt wird uebergeben
f (&v);
// Ein Zeiger auf Vektor wird uebergeben
pg = new Vektor(0,0); // Ein Zeiger auf Vektor wird kopiert
8.12.3 Polymorphismus: Typmarkierungen in Objekten
Virtuelle Methoden sind solche, die in Ableitungen redefiniert werden können. Bei einer virtuellen Methode wird
zur Laufzeit der Typ eines Objekts festgestellt und dann die richtige Methode aktiviert. Enthält eine Klasse virtuelle
Methoden, dann sind ihre Objekte polymorph (vielgestaltig). Die aktuelle “Gestalt”, der Typ des Objekts, wird zur
Laufzeit festgestellt. Dazu muss das Objekt eine Typmarkierung enthalten.
Polymorphismus wird immer dann benötigt, wenn der genaue Typ von Objekten erst zur Laufzeit feststeht. Typisches Beispiel dafür sind Behälter in denen nach Belieben Objekte unterschiedlichen Typs gespeichert werden
sollen (“Was immer es ist, speichere es!”), oder Operationen gleicher Art, die auf Objekten von unbekanntem Typ
angewendet werden sollen (“Was immer es ist, drucke es!”).
8.12.4 Polymorphismus arbeitet nur mit Zeigern
Eine aufgerufene freie Funktion oder Methode wird in aller Regel statisch – zur Übersetzungszeit – identifiziert.
Das nennt man statische Bindung. Wird jedoch eine virtuelle Methode über Zeiger aktiviert, dann arbeitet das System mit dynamischer Bindung. Jetzt wird die zu aktivierende Methode erst zur Laufzeit ausgewählt. Die Objekte
enthalten dann die Adressen ihrer Methoden in einer Methodentabelle. Der Compiler erzeugt keinen Sprung zu der
Methode, sondern einen Sprung zu der Methode, deren Adresse in der Methodentabelle gefunden wird.
Virtual ohne Zeiger und/oder Referenzen ist sinnlos!
8.12.5 Polymorphismus und das Erzeugen einer Kopie: Klonen
Konstruktoren und Zuweisungen kann man nicht virtuell definieren. Ein Konstruktor ist prinzipiell an einen bestimmten Typ gebunden. Der Zuweisungsoperator kann zwar formal virtuell sein, de facto ist es aber nicht möglich
eine polymorphe Zuweisung zu definieren. Wenn also Objekte abgeleiteter Typen über einen Zeiger auf ihren Basistyp kopiert werden sollen, dann muss eine virtuelle parameterlose “Klon–Funktion” definiert werden.
256
Programmierung II
8.12.6 Destruktoren von Basisklassen sollten virtuell sein
Der Destruktor einer Basisklasse B sollte virtuell sein, wenn Zeiger vom Typ B* auf Objekte von abgeleiteten
Typen zeigen.
class B { public: ˜B(){} ... };
Damit wird erreicht, dass der Destruktor des tatsächlichen Typs des Objekts aktiviert wird.
8.12.7 Virtuelle Methoden: polymorph in nur einem Argumnet
Mit virtuelle Methoden kann eine Methode abhängig vom dynamischen Typ eines Objekts aktiviert werden. Die
Sprache C++ organisiert die Typverwaltung und Auswahl der richtigen Methode automatisch ohne expliziten Programmcode. Ist eine zu aktivierende Methode oder Funktion vom Typ mehr als eines Objekts abhängig, dann gibt
es (in C++) keinen Automatismus mehr, der die Auswahl trifft. Sie muss explizit ausprogrammiert werden, entweder indem mit dynamic cast oder typid auf die vom Compiler generierten Typinformation zugegriffen wird,
oder indem eigene Typmarkierungen erzeugt und verwaltet werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
8.13
Übungen
Aufgabe 1
1. Welche Ausgabe erzeugt:
class Basis {
public:
Basis () : x(10) {}
void f () { ++x; }
int x;
};
class Abgeleitet : public Basis {
public:
Abgeleitet () {}
void f () { --x; }
};
int main() {
Abgeleitet * pa = new Abgeleitet;
Basis
* pb = pa;
pa->f();
pb->f();
cout << pa->x << endl;
cout << pb->x << endl;
}
2. Welche Ausgabe wird erzeugt, wenn die Methode Basis::f als virtuelle Methode definiert wird?
3. Was ändert sich, wenn Basis::f als rein virtuelle Methode definiert wird?
Aufgabe 2
Betrachten Sie folgendes Programm:
class Angestellt {
public:
Angestellt(string name) : _name(name) {}
Angestellt() {}
private:
string _name;
};
class Manager : public Angestellt {
public:
Manager(string name, string abt) : Angestellt(name), _abt(abt) {}
Manager() {}
private:
string _abt;
};
int main() {
Angestellt* mitarbeiter[10];
for (int i = 0; i < 10; i++)
mitarbeiter[i] = new Manager;
257
Programmierung II
258
mitarbeiter[0] = new Angestellt("Hugo Hacker");
mitarbeiter[1] = new Manager("Anita Eckberg", "Marketing");
// ...
for (int i = 0; i < 10; i++)
delete mitarbeiter[i];
}
1. Ist die Anweisung delete mitarbeiter[i] erlaubt obwohl Angestellt keinen Destruktor hat?
2. Mit welchen Werten belegt der Defaultkonstruktor von Manager sein Objekt?
3. Sind die Definitionen von Angestellt und Manager OK, oder sollten sie Destruktoren definieren?
Aufgabe 3
Das Programm
#include <iostream>
using namespace std;
class Basis {
public:
Basis () : b(1) {}
virtual void print () { cout << "b= " << b << endl; }
private:
int b;
};
class Abgeleitet : public Basis {
public:
Abgeleitet (int pa) : a(pa) {}
void print () { cout << "a= " << a << endl; }
private:
int a;
};
int main () {
Basis
*b1 = new Abgeleitet(22);
Basis
*b2 = new Abgeleitet(33);
*b1 = *b2;
b1->print();
b2->print();
}
erzeugt seltsamerweise die Ausgabe
a= 22
a= 33
1. Warum ist das so? Funktioniert die Zuweisung nicht so wie erwartet, stimmt etwas nicht mit print, ...?
2. Welche Ausgabe erzeugt das Programm ohne virtual vor print?
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
259
Aufgabe 4
Warum enthält folgendes Programm ein Speicherloch26 und wie ist es zu korrigieren, ohne dass dabei das Programm in seiner sonstigen Funktionalität verändert wird.
class B {
public:
B() : x(new int[1000]) {}
˜B() { delete[] x; }
int * x;
};
class Ab : public B {
public:
Ab() : y(new int[1000]) {}
˜Ab() { delete[] y; }
int * y;
};
int main () {
for (int i=0; i<100000; ++i)
for (int j=0; j<100000; ++j)
for (int k=0; k<100000; ++k) {
B *b = new Ab;
delete b;
}
}
Aufgabe 5
In folgendem Beispiel soll die Klasse AusdrKnoten durch eine Basisklasse Knoten und zwei von ihr abgeleitete Klassen WertKnoten und OpKnoten ersetzt werden.
class Ausdruck {
friend ostream & operator<< (ostream &, const Ausdruck &);
friend istream & operator>> (istream &, Ausdruck &);
public:
Ausdruck ();
Ausdruck (const Ausdruck &);
Ausdruck & operator=(const Ausdruck &);
˜Ausdruck ();
private:
class AusdrKnoten;
AusdrKnoten *
ak;
};
ostream & operator<< (ostream &, const Ausdruck &);
istream & operator>> (istream &, Ausdruck &);
class Ausdruck::AusdrKnoten {
public:
AusdrKnoten (const Zahl &);
AusdrKnoten (char, AusdrKnoten *, AusdrKnoten *);
˜AusdrKnoten ();
26 Ein Speicherloch (engl. memory leak) ist eine Programmkonstruktion die Speicher anfordert diesen nach Benutzung aber nicht wieder
komplett frei gibt. Ein Speicherloch kann zum Programmabsturz führen. (Ein Speicherloch ist keine Loch im Speicher, sondern ein Loch durch
das Speicher ausrinnt.)
260
Programmierung II
AusdrKnoten (const AusdrKnoten &);
AusdrKnoten & operator=(const AusdrKnoten &);
ostream & write (ostream &) const;
private:
enum Art {wertAusdr, opAusdr};
Art
art;
Zahl
wert;
char
op;
AusdrKnoten *l, *r;
};
1. Analysieren Sie den angebenen Quellcode und erläutern Sie welche Funktionalität bei der Einführung von
Vererbung ohne bzw. nur mit Hilfe von Polymorphismus realisiert werden kann.
2. Definieren Sie einen geeigneten Typ Zahl zur Ergänzung der Definitionen.
3. Wandeln Sie die Definitionen so um, dass AusdrKnoten durch eine Basisklasse Knoten und zwei von ihr
abgeleitete Klassen WertKnoten und OpKnoten ersetzt wird. Richten Sie sich dabei auch nach Vorbild
der im Skript erläuterten Definitionen für geometrische Objekte. Wird beispielsweise eine clone–Methode
benötigt? Wenn ja: wozu? Wenn nein: warum wurde sie bei geometrischen Objekten eingeführt und ist hier
jedoch unnötig?
4. Implementieren Sie Ihre Vererbungsvariante der Syntaxbäume.
Aufgabe 6
In folgendem Programm wird der dynamic cast–Operator eingesetzt. Geben Sie eine äquivalente Lösung ohne
ihn an.
// ACHTUNG: VERMURKSTES PROGRAMM: BITTE KORRIGIEREN !!
#include <iostream>
#include <list>
#include <string>
using namespace std;
class Angestellte {
public:
Angestellte (const string & n) : name(n) {}
virtual ˜Angestellte () {};
void praemie () {
std::cout << name << ": " << 100 << endl;
}
protected:
string
name;
unsigned int gehalt;
};
class Programmierer : public Angestellte {
public:
Programmierer (string n, string s)
: Angestellte(n), sprache(s)
{}
void praemie () {
std::cout << name << ": " << 250 << std::endl;
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
261
private:
string sprache;
};
class Manager : public Angestellte {
public:
Manager (string n, unsigned int z)
: Angestellte(n), zahlUntergebene(z)
{}
void praemie () {
std::cout << name << ": " << 1000 << std::endl;
}
private:
unsigned int zahlUntergebene;
};
void erfolgsPraemien ( list<Angestellte *> l ) {
for ( list<Angestellte *>::iterator i = l.begin();
i != l.end();
++i) {
Manager * boss = dynamic_cast<Manager *>(*i);
if (boss)
boss->praemie();
// Manager-Praemie
else {
Programmierer * hacker = dynamic_cast<Programmierer *>(*i);
if ( hacker )
hacker->praemie();
// Programmierer-Praemie
else (*i)->praemie(); // Angestellten-Praemie
}
}
}
int main () {
Manager
m("CarlaMeier", 5);
Programmierer p("JosefineMueller", "C++");
Angestellte
a("KlausKunz");
list<Angestellte *> l;
l.push_back (&m);
l.push_back (&p);
l.push_back (&a);
erfolgsPraemien(l);
}
Aufgabe 7
1. Ergänzen Sie folgendes Beispiel aus dem Skript um die fehlenden Definitionen und erweitern Sie es um Quadrupel, also Tupel mit vier Elementen, und definieren Sie eine Methode anwende, die eine als Parameter
bergebene Funktion auf alle Elemente des Tupels anwendet.
// Basisklasse
//
class NTupel {
public:
virtual ˜NTupel () {}
virtual NTupel * clone()
= 0;
virtual ostream & schreib (ostream &) = 0;
Programmierung II
262
};
// Ableitung 1
//
class Paar : public NTupel {
public:
Paar (int p_i, int p_j) : i(p_i), j(p_j) {}
NTupel * clone() {
return new Paar(*this); // Aufruf generierter Kopierkonstr.
}
ostream & schreib (ostream & os) {
return os << "<" << i << "," << j << ">";
}
private:
int i, j;
};
// Ableitung 2
//
class Tripel : public NTupel {
public:
Tripel (int p_i, int p_j, int p_k) : i(p_i), j(p_j), k(p_k) {}
NTupel * clone() {
return new Tripel(*this); // Aufruf generierter Kopierkonstr.
}
ostream & schreib (ostream & os) {
return os << "<" << i << "," << j << "," << k << ">";
}
private:
int i, j, k;
};
//Umschlagklasse
//
class Tupel {
friend istream & operator>> (istream &, Tupel &);
friend ostream & operator<< (ostream &, const Tupel &);
public:
Tupel(int i, int j)
: b(new Paar(i,j))
{}
Tupel(int i, int j, int k) : b(new Tripel(i,j,k)) {}
˜Tupel() { delete b; }
Tupel(const Tupel &p_tupel) : b(p_tupel.b->clone()) {}
Tupel & operator= (const Tupel & p_tupel) {
if ( this != &p_tupel ) {
delete (b);
if ( p_tupel.b == 0 ) b = 0;
else b = p_tupel.b->clone();
}
return *this;
}
private:
NTupel * b;
};
2. Wandeln Sie Ihre Erweiterung in ein Template um, bei dem der Typ der Elemente Templateparameter ist.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9
263
Dateien
9.1
9.1.1
Sequentielle Dateien
Dateien existieren unabhängig vom Programm
Dateien sind Datenspeicher. Man kann in ihnen Daten ablegen (schreiben) und sie später wieder entnehmen (lesen). Eine Datei ist damit einem Feld sehr ähnlich. In gewissem Sinne ist eine Datei als Zusammenfassung von
Werten zwar ein weiterer Behälter, von anderen Datentypen unterscheidet sie sich aber in einem sehr wesentlichen
Punkt. Dateien sind, im Gegensatz etwa zu Feldern, außerhalb des Programms. Ihre Existenz ist nicht an die eines
Programms gebunden. Sie existieren in der Regel bevor das Programm gestartet wurde und leben nach dessen Ende
meist weiter.
9.1.2
Dateien: In der Hardware, im Betriebssystem, im Programm
Dateien sind physikalische Objekte, z.B. bestimmte Bereiche auf einer Magnetplatte oder einer CD. Sie werden
vom Betriebssystem verwaltet. Programme greifen (fast) immer über das Betriebssystem auf Dateien zu. Jedes
Betriebssystem hat dabei seinen eigenen Satz von Systemaufrufen 27 mit deren Hilfe Programme auf die Hardware
von Dateien zugreifen können. Damit ein Programm, das mit Dateien arbeitet, nicht von dem Betriebssystem
abhängig ist, auf dem es abläuft, enthalten viele Programmiersprachen noch ein eigenes Dateikonzept, das dann
vom Compiler auf die Gegebenheiten des Betriebssystems abgebildet wird.
Dateien gibt es also in drei unterschiedlichen Ebenen:
Hardware: Dateien sind Bereiche auf physikalischen Medien wie Platten, CDs, Disketten, Bänder etc.
Betriebssystem: Das Betriebssystem verwaltet die physikalischen Medien, ordnet verschiedene Bereiche eines Mediums einem konzeptionell zusammenhängenden Bereich “Datei” zu, verwaltet Zugriffsrechte, Verwaltungsinformationen etc. Dabei hat jedes Betriebssystem sein Konzept von “Datei”, das sich in den Systemaufrufen niederschlägt, mit denen auf die Dateien zugegriffen werden kann. Die Systemaufrufe werden
in Operationen auf physikalischen Objekten umgesetzt.
Programmiersprache: Die Programmiersprachen haben ihr eigenes Konzept von “Datei”. Das vereinfacht für
die Programmierer den Umgang mit Dateien und macht Quellprogramme vom Betriebssystem unabhängig.
Auf Dateien wird jetzt mit bestimmten Konstrukten der Sprache zugegriffen. Diese werden vom Compiler
dann auf das jeweilige Betriebssystem abgebildet.
Die Sprachkonstrukte können normalerweise auch umgangen werden. Das Programm verwendet dann nicht die
“Dateien der Sprache”, sondern benutzt direkt über Systemaufrufe die “Dateien des Systems”. In manchen Systemen kann ein Programm sogar das Betriebssystem umgehen und direkt auf die Hardware zugreifen.
9.1.3
Öffnen: Dateien und ihre Repräsentanten im Programm verknüpfen
Die Dateikonzepte der verschiedenen Programmiersprachen sind zwar unterschiedlich, gewisse Gemeinsamkeiten
gibt es aber dennoch. Typischerweise arbeiten die Programme aller Programmiersprachen mit “logischen Dateien”
(Datei–Repräsentanten, Programmdatei), die mit einer “wirklichen Datei” (Betriebssystemdatei) verknüpft werden
müssen. Das Verknüpfen einer logischen Programmdatei mit einer realen Betriebssystemdatei nennt man Öffnen.
9.1.4
Beispiel: Datei kopieren
Als Beispiel betrachten wir ein einfaches Programm das eine Textdatei in eine andere kopiert. (Der Einfachheit
und Übersichtlichkeit halber wurde auf jede Fehlerbehandlung verzichtet):
27 Im
Betriebssystem implementierte Funktionen, die von Anwendungsprogrammen aufgerufen werden können.
Programmierung II
264
#include <fstream>
int main (int argc, char * argv[]) {
ifstream quelle;
// Eingabedatei (Programmdatei)
ofstream ziel;
// Ausgabedatei (Programmdatei)
char
ch;
quelle.open (argv[1]);
ziel.open
(argv[2]);
//
//
//
//
Dateien oeffnen:
Programmdateien mit realen Dateien verbinden.
Die Dateinamen wurden als Programmargumente
uebergeben
while (quelle.get(ch))
ziel.put(ch);
quelle.close ();
ziel.close ();
// Dateien schliessen:
// Verbindung reale Datei -- Programmdatei loesen
}
Wird dieses Programm in der Quelldatei copyDat.cc mit
g++ -o copyDat copyDat.cc
übersetzt und das Ergebnis mit
copyDat datei-1.txt datei-2.txt
aktiviert, dann wird es den Inhalt der Datei datei-1.txt in die Datei datei-2.txt kopieren.
9.1.5
Muster der Dateibearbeitung: Öffnen, Bearbeiten, Schließen
Eine Datei ist nicht Bestandteil eines Programms. Man kann darum vom Programm aus nicht direkt auf sie zugreifen. Wie in fast allen Programmiersprachen erfolgt auch in C++ der Zugriff auf eine Datei nach folgendem
Muster:
Internen Repräsentanten erzeugen: Für eine zu bearbeitende Datei wird ein programminternes Objekt als
Repräsentant erzeugt.
In unserem Kopierbeispiel sind quelle und ziel solche Repräsentanten. Wir bezeichnen sie als
“Programmdateien”, oder, wenn keine Verwechslungen mit den wirklichen (Betriebssystem–) Dateien
(datei-1.txt und datei-2.txt) zu befürchten sind, auch einfach als “Dateien”.
Öffnen: Die programminternen Repräsentanten quelle und ziel werden mit den wirklichen Dateien
datei-1.txt und datei-2.txt verbunden. Diesen Prozess nennt man “Öffnen”. Beim Öffnen einer
Datei muss immer zumindest der Name der Datei (auf der Ebene des Betriebssystems) angegeben werden.
In unserem Beispiel werden die Dateien mit
quelle.open (argv[1]);
ziel.open (argv[2]);
geöffnet.
Die Dateinamen sind C–Strings und werden hier aus der Kommandozeile übernommen. Damit werden zwei
Verbindungen erzeugt:
– quelle
– ziel
datei-1.txt
datei-2.txt
Bearbeiten der aktuellen Position: Ab sofort werden alle Aktionen des Programms auf quelle und ziel
an die wirklichen Dateien datei-1.txt und datei-2.txt weitergegeben. Das Programm bearbeitet
also eine Datei, indem Kommandos auf deren Repräsentanten ausgeführt werden. In unserem Beispiel liest
die Anweisung
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
265
quelle.get(ch)
ein Zeichen aus der mit quelle verbundenen Datei in die Variable ch und
ziel.put(ch)
schreibt das Zeichen in ch in die mit ziel verbundene Datei. Während der Bearbeitung wird die jeweilige
Datei lesend oder schreibend durchwandert. Mit einer Leseoperation wandert die aktuelle Dateiposition um
eins weiter. Beim Schreiben ist die aktuelle Position immer das Dateiende. Die Datei wird allerdings mit
jeder Schreiboperation etwas länger.
Schließen: Sind alle Aktionen erledigt, dann wird die Verbindung zur Datei wieder gelöst. Dies bezeichnet
man als “Schließen”.
quelle.close ()
ziel.close ()
sind in unserem Beispiel die entsprechenden Aktionen.
9.1.6
Die Art des Öffnens einer Datei
Es macht einen Unterschied, ob eine Datei zum Lesen oder zum Schreiben geöffnet wird.
Eine Datei wird als erstes vollständig gelöscht, wenn man sie zum Schreiben öffnet.
Beim Öffnen zum Lesen wird dagegen lediglich die aktuelle Position auf den Anfang gesetzt.
Die Art, in der die Datei geöffnet werden soll, erkennt das System hier am Typ des Repräsentanten. quelle ist
vom Typ ifstream (input file stream) und ziel ist vom Typ ofstream (output file stream). Damit ist klar,
was mit den Dateien passieren soll und wie sie also jeweils zu öffnen sind.
9.1.7
Konstruktor und Destruktor öffnen bzw. schließen Dateien
Konstruktor und Destruktor von ifstream sowie ofstream öffnen und schließen Dateien. Unser Beispiel wird
damit vereinfacht zu:
#include <fstream>
int main (int argc, char * argv[]) {
ifstream quelle (argv[1]);
ofstream ziel (argv[2]);
char
ch;
while (quelle.get(ch))
ziel.put(ch);
}
9.1.8
Fehlerbehandlung
Das ist aber jetzt vielleicht ein wenig zu einfach. Man sollte zumindest die Situation abfangen, in der eine Datei
nicht erfolgreich geöffnet werden kann:
...
ifstream quelle (argv[1]);
if (!quelle) {
cout << "kann " << argv[1] << " nicht oeffnen \n";
exit (1);
}
ofstream ziel (argv[2]);
Programmierung II
266
if (!ziel) {
cout << "kann " << argv[2] << " nicht oeffnen \n";
exit (1);
}
...
Das Öffnen einer Datei kann fehlschlagen, wenn die Datei nicht existiert, oder das Programm nicht zu der beabsichtigten Aktion – Lesen oder Schreiben – berechtigt ist. In diesem Fall ist die Datei in einem Fehlerzustand. 28
Der Dateizustand wird hier mit
if (!quelle) ... und
if (!ziel) ...
geprüft. “!” ist hier ein für alle Ein– oder Ausgabeströme definierter Operator operator!(), der true liefert,
wenn die letzte Operation fehlgeschlagen ist. !quelle ist äquivalent zu quelle.fail()
9.1.9
Fehlerzustände und Dateiflags
Mit der Methode fail() kann eines von mehreren Flags abgefragt werden. Mit den Flags wird der Zustand der
Datei überwacht. Neben fail gibt es noch weitere Methoden, mit denen insgesamt eine genauere Beobachtung
des Dateizustands möglich ist:
good() liefert true wenn kein Fehlerflag gesetzt ist, also wenn keine der anderen true liefert.
bad() liefert true wenn der Strom (die Datei) in einem Fehlerzustand ist.
fail() liefert true wenn die letzte Operation fehlgeschlagen ist. Normalerweise ist der Strom dann auch
im bad–Zustand.
eof() liefert true wenn das Ende des Stroms (der Datei) erreicht wurde.
Gelegentlich will man selbst explizit den Zustand eines Eingabestroms setzen. Beispielsweise wenn Werte eines
eigenen Datentyps eingelesenen werden, diese aber nicht den Formatvorgaben entsprechen. Den Eingabezustand
setzt man beispielsweise mit
istream is; ... is.setstate (ios::failbit);
Die möglichen Zustandsbits sind:
ios::badbit
ios::eofbit
ios::failbit
ios::goodbit
Mehrere Bits können mit der bitweisen Oder-Operation gesetzt werden. Beispiel:
is.setstate (ios::failbit | ios::badbit);
9.1.10 ofstream ist ein Ausgabe–Zeichenstrom wie cout
Ein Ausgabestrom den wir schon lange kennen ist cout. cout muss nicht deklariert und auch nicht geöffnet
werden, es ist für jedes Programm mit der “Standardausgabe” verbunden. Was genau die Standardausgabe ist,
definiert das Betriebssystem. In der Regel ist es das Terminal von dem aus das Programm gestartet wurde.
Interessanterweise ist mit jedem Objekt vom Typ ofstream all das möglich, was mit cout möglich ist. Insbesondere kann der Operator << benutzt werden. Damit können einfach eigene Ausgaben in einer Datei platziert
werden:
28 Genau gesagt ist natürlich nur die Programmdatei, der Dateirepräsentant, in einem Fehlerzustand, die wirkliche Datei bleibt von der
fehlgeschlagenen Operation unbeeindruckt.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
267
...
// Text in einer Datei speichern:
ziel << "-- Dies ist eine Kopie von " << argv[1]<< "\n";
...
9.1.11 ifstream ist ein Eingabe–Zeichenstrom
Komplementär dazu kann – entsprechend zu cin – der Operator >> mit Objekten vom Typ ifstream benutzt
werden. Damit können z.B. Werteingaben vom Typ int oder float ohne eigenen Konversionscode von einer
Datei gelesen werden. Ein einfaches Programm, das float–Zahlen aus einer als Programmargument übergebenen
Datei liest und ausgibt, ist:
#include <fstream>
int main (int argc, char * argv[]) {
ifstream quelle (argv[1]);
float
f;
int
i = 1;
if (!quelle) {
cout << "kann " << argv[1] << " nicht oeffnen \n";
exit (1);
}
quelle >> f;
while (!quelle.eof()) {
cout << i++ << ". " << f << endl;
quelle >> f;
}
}
!quelle.eof() testet ob die Datei zu Ende gelesen wurde. Die Leseoperation quelle >> f; vor Beginn
der Schleife mag etwas verwundern, sie ist notwendig, da eof() erst nach einem erfolglosen Leseversuch das
Dateiende anzeigt.
9.1.12 Eingabe und Ausgabe von Zeichen: get und put
Zur Ein– und Ausgabe verwendet man normalerweise die Operatoren << und >>. Mit ihnen können Werte aller
vordefinierten Datentypen, inklusive char, gelesen und geschrieben werden. Gelegentlich ist der Operator <<
allerdings “zu intelligent”. Weiße Zeichen – Leerzeichen, Zeilenvorschub, etc. – werden von ihm einfach ignoriert
und überlesen.
Will man ein “normales” Zeichen einlesen, dann ist das Überlesen in Ordnung. Will man dagegen eine Datei identisch kopieren, dann müssen auch die weißen Zeichen kopiert werden. Das “intelligente Überlesen” irrelevanter
Leerzeichen und Zeilenvorschübe ist hier nicht angebracht. Statt << benutzt man die Methode get, die ohne überflüssiges Nachdenken jedes Zeichen einliest. put ist die komplementäre Ausgabemethode, wir benutzen sie aus
Symmetriegründen.
Will man also Zeichen eines Eingabestroms lesen, ohne dass das System in irgendeiner Weise versucht diese zu
interpretieren, dann sollte get verwendet werden.
...
while (!quelle.eof()) {
...
//quelle >> ch;
Nicht wenn Leerzeichen etc. erkannt werden sollen!
quelle.get (ch); // OK
...
}
Programmierung II
268
...
9.1.13 Lesen bis zum Ende
quelle.get(ch) liest das nächste Zeichen von quelle ein und speichert es in ch.
get und << liefern als Ergebnis wieder den Eingabestrom, auf den sie angewendet wurden. Dieser kann in einem
booleschen Ausdruck verwendet werden, in dem er zu “Falsch” ausgewertet wird, wenn die Eingabe leer ist. Mit
einer Schleife wie
while (quelle.get (ch))
...
bzw.
while (quelle >> ch)
...
kann man darum wie mit
while (!quelle.eof())
...
bis zum Ende einer Datei lesen. Hierbei wird wieder der Dateizustand über das fail–Flag und den !–Operator
(siehe oben) getestet.
9.1.14 Eingabe von Zeilen als C–Strings: getline
Ganze Zeilen können bequem mit getline eingelesen werden. Wir verwenden es beispielsweise, wenn das
Kopierprogramm die Zeilenstruktur der Eingabe erkennen soll, etwa um die Zeilen nummeriert wieder auszugeben:
#include <fstream>
int main (int argc, char * argv[]) {
ifstream quelle (argv[1]);
ofstream ziel
(argv[2]);
char
buf[256];
// Zeilenpuffer
int
i = 1;
// Zeilennummer
if (!quelle) { ... }
if (!ziel)
{ ... }
quelle.getline (buf, 256); // Zeile mit maximal 256 Zeichen einlesen
while (!quelle.eof()) {
ziel << "Zeile ";
ziel.width (2);
ziel << i++ << ": " << buf << endl;
quelle.getline(buf, 256);
}
...
}
Man hätte die Schleife auch über das Ergebnis von getline steuern können:
while (quelle.getline (buf, 256)) { ... }
Dabei wäre allerdings die letzte Zeile unter den Tisch gefallen. Nachdem die letzte Zeile gelesen wurde ist die
Eingabe “Falsch”, die Schleife wird abgebrochen und diese Zeile wird nicht mehr ausgegeben.
getline liest in ein char–Feld. Der zweite Parameter gibt die maximale Länge an. Mit ihm wird verhindert, dass
getline über die Grenzen von buf hinaus schreibt. Ist die gelesene Zeile länger als die vorgegebene Grenze,
dann wird der Rest der Zeile weggeworfen. “Zeile” ist dabei definiert als die Zeichenfolge bis zum nächsten
Zeilenende–Zeichen. Dieses Zeichen kann auch explizit angegeben werden. Die beiden folgenden Anweisungen
sind darum gleichwertig:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
quelle.getline (buf, 256);
quelle.getline (buf, 256, ’\n’);
269
// Zeilenende-Zeichen default
// Zeilenende-Zeichen explizit angegeben
9.1.15 Eingabe von Zeilen als Strings: getline
Zeilen können nicht nur in char–Feldern eingelesen werden. Statt der Methode benutzt man dazu die Funktion
getline:
string s;
getline (quelle, s);
Die Flexibilität der Strings erlaubt das Einlesen von Zeilen beliebiger Länge.
9.2
9.2.1
Ein– und Ausgabe selbst definierter Typen
Standard–I/O–Ströme cin, cout und cerr
cin, cout und cerr sind vordefinierte Variablen
istream cin
istream cout
ostream cerr
die sich auf Quasi–Dateien beziehen. Sie werden für jedes Programm automatisch beim Programmstart geöffnet. cin wird dabei mit der Standard–Eingabe und cout mit der Standard–Ausgabe verbunden. cerr ist die
Standard–Fehlerausgabe. Sie ist oft identisch mit der Standard–Ausgabe, kann aber – auf der Ebene des Betriebssystems – in einen anderen Kanal (z.B. eine andere Datei) geleitet werden. cin und cout sind lediglich
Spezialfälle. Jede Ausgabe–Datei vom Typ ofstream kann wie ein ostream und damit wie cout behandelt
werden. Jede Eingabe–Datei vom Typ ifstream kann wie ein istream und damit wie cin behandelt werden.
Ein ofstream ist ein ostream und ein ifstream ist ein istream. (Vererbung: ifstream und ofstream
sind Ableitungen von istream und ostream.) Wir merken uns an dieser Stelle einfach, dass alles, was mit cin
und cout möglich ist, auch mit Dateien gemacht werden kann.
9.2.2
Die Operatoren >> und <<
Die wichtigsten Aktionen auf cin und cout sind die I/O–Operationen mit operator>> und operator<<.
Diese beiden Operatoren sind für alle einfachen Typen wie int, float etc. definiert:
int x;
cout << x;
das entspricht:
int x;
operator<< (cout, x);
Der <<–Operator hat zwei Parameter – eine Referenz auf einen ostream und ein auszugebendes Objekt – und
liefert einen Wert vom Typ ostream & als Ergebenis:
ostream & operator<< (ostream &, T);
Damit können mehrere Werte in folgender Form ausgegeben werden:
int x; float y;
cout << x << "," << y;
Ausführlich hingeschrieben ist das:
int x; float y;
operator<< (operator<< (operator<< (cout, x), ","), y);
Programmierung II
270
Für cin, istream und den >>–Operator gelten entsprechenden Aussagen.
9.2.3
Ausgabeoperator überladen
Der Ausgabeoperator << kann jederzeit zur Ausgabe eines Objektes mit selbst definiertem Typ überladen werden.
Man muss dabei beachten, dass << eine Referenz auf ein ostream–Objekt (output stream Objekt) als Argument
hat und ein solches liefern muss. Beispiel:
#include <iostream>
struct Vektor {
float x;
float y;
};
ostream & operator<< (ostream &s, const Vektor &v) {
return s << ’(’ << v.x << ", " << v.y << ’)’;
}
int main () {
Vektor v1 = {0.5, 1.0};
cout << v1 << endl;
}
9.2.4
Ausgabe auf Datei
Da eine geöffnete Ausgabedatei auch ein Ausgabestrom ist, kann der neu definierte Operator auch zur Ausgabe
auf eine Datei benutzt werden:
... wie oben ...
int main (int argc, char * argv[]) {
Vektor
v1 = {0.5, 1.0};
ofstream datei (argv[1]);
...
datei << "Vektor: " << v1 << endl;
}
9.2.5
Eingabeoperator überladen
Für eine selbstdefinierte Eingabe muss der Eingabeoperator >> überladen werden. Die neuen Operatoren können
dann sowohl auf cin als auch auf eine geöffnete Eingabedatei angewendet werden. Ein Beispiel mit selbst definierter Ein– und Ausgabe ist:
#include <iostream>
struct Vektor {
float x;
float y;
};
ostream & operator<< (ostream &s, const Vektor &v) {
return s << ’(’ << v.x << ", "<< v.y << ’)’;
}
istream & operator>> (istream &s, Vektor &v) {
char c;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
s >> c;
s >> v.x;
s >> c;
s >> v.y;
s >> c;
return s;
271
//(
//,
//)
}
int main (int argc, char * argv[]) {
Vektor v1 = {10, 10};
ifstream datei_ein (argv[1]);
ofstream datei_aus (argv[2]);
...
datei_ein >> v1;
// Aufruf operator>> (.., Vektor &v)
datei_aus << "Vektor v1:" << endl;
datei_aus << v1
<< endl; // Aufruf operator<< (.., const Vektor &v)
}
Den Operator wird man sicher noch erweitern wollen, um inkorrekte Eingaben abzufangen.
9.2.6
I/O–Operatoren als Freunde
Die I/O–Operatoren werden als freie Funktionen definiert. Sie haben damit keinen Zugang zu den privaten Teilen
der Klasse, deren Objekte sie ein– oder ausgeben. Will man nicht für alle privaten Datenkomponenten öffentliche
Zugriffsfunktionen definieren, müssen die Operatoren zu Freunden erklärt werden:
class Vektor {
friend ostream & operator<< (ostream &, const Vektor &);
public:
...
private:
float x;
// Zugreifbar fuer
float y;
// operator<<
};
ostream & operator<< (ostream &s, const Vektor &v) {
return s << ’(’ << v.x << ", "<< v.y << ’)’;
}
...
Hier ist eine freie Funktion der Freund einer Klasse. Natürlich kann der Eingabeoperator ebenfalls ein Freund sein:
class Vektor {
friend ostream &
friend istream &
public:
...
private:
float x;
float y;
};
9.2.7
operator<< (ostream &, const Vektor &);
operator>> (istream &, Vektor &);
Ein/Ausgabe für Klassentemplates
Klassentemplates können Funktionstemplates als Freunde haben. Beispielsweise erklärt ein Klassentemplate das
Funktionstemplate das sein I/O besorgt zu Freund. Beispiel:
Programmierung II
272
template<class T>
class Vektor {
friend ostream & operator<< <T> (ostream &, const Vektor &);
friend istream & operator>> <T> (istream &, Vektor &);
public:
...
private:
T x, y;
};
...
template<class T>
ostream & operator<< (ostream &s, const Vektor<T> &v) {
return s << v.x << " " << v.y;
template<class T>
istream & operator>> (istream &s, Vektor<T> &v) {
return s >> v.x >> v.y;
}
9.2.8
Eingabe rückgängig machen
Gelegentlich will man ein Zeichen “ungelesen machen”. Angenommen Ausdrücke seien zu analysieren. In die
Ausdrücke sind Werte unterschiedlicher Art eingebettet. Beispielsweise besteht der Ausdruck:
( [+3/2] - 45 )
aus zwei Unterausdrücken. Einer der beiden ist ein Bruch–Ausdruck, der andere eine ganze Zahl. Das erste Zeichen
– beispielsweise “4” – wird zuerst benötigt, um zu entscheiden, um welche Art von Unterausdruck es sich handelt,
dann wird es wieder benötigt, um den Wert zu bestimmen. Die Analyse wird dadurch erleichtert, dass bereits
gelesene Zeichen wieder auf den Eingabestrom zurück gelegt werden können:
void ausdruckAnalyse () {
...
char z;
cin >> z;
switch (z){
case ’(’ :
....
case ’)’ :
....
case ’*’: case ’+’: //
....
default: {
//
cin.unget();
//
Wert w;
is >> w;
//
....
}
}
...
}
Operator
Wert in einer von mehreren Varianten
schon gelesenes Zeichen zurueck legen
Wert mit erstem Zeichen einlesen
Die Methoden
istream & istream::unget()
istream & istream::putback(char)
machen das letzte Zeichen ungelesen. Bei putback wird geprüf ob das zurück gegebene Zeichen tatsächlich dem
gelesenen entspricht. Ansonsten sind die beiden äquivalent.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9.2.9
273
Ein– und Ausgabe von Datenstrukturen
Die Ein– und Ausgabe selbstdefinierter Typen muss sich nicht auf Klassen oder Verbunde beschränken. Mit etwas
Codierarbeit beim Schreiben und Analyseaufwand beim Lesen können auch Datenstrukturen gespeichert werden,
deren Komponenten durch Zeiger miteinander verkettet sind. Man definiert dazu eine geeignete textuelle Darstellung der Datenstruktur. Eine verkettete Liste beispielsweise kann einfach sequenziell als Folge ihrer Elemente –
eventuell durch Trennsymbole separiert – abgelegt werden. Ein Baum kann als geklammerter Ausdruck codiert
werden. Etc.
9.2.10 Formatfehler bei der Eingabe
Treten beim Einlesen selbstdefinierter Datentypen Fehler auf, dann wird man normalerweise nicht gleich das ganze
Programm abbrechen wollen, sondern statt dessen den Fehler an der Aufrufstelle der Leseoperation abfangen und
behandeln. Das failbit des Eingabestroms kann benutzt werden, um die Information über den Lesefehler zu
melden. Dies entspricht dem Verhalten vordefinierter Eingabeoperatoren. Beispiel:
#include <iostream>
using namespace std;
class Vektor {
friend istream & operator>> (istream &, Vektor &);
public:
Vektor () : x(0), y(0) {};
private:
int x, y;
};
istream & operator>> (istream &is, Vektor &v) {
char c;
is >> v.x;
is >> c;
if ( c == ’,’ ) // Vektorkomponenten muessen mit ’,’ getrennt sein
is >> v.y;
else
// Lesefehler: Strom ist in Fehlerzustand versetzen
is.setstate(ios::failbit);
return is;
}
int main () {
Vektor v;
cin >> v;
while ( cin ) { // solange korrekte Vektoren eingegeben werden
... v verarbeiten ...
cin >> v;
}
...
}
9.3
9.3.1
Darstellungskonversionen und String–Streams
Darstellungskonversion mit >> und <<
Die I/O–Operatoren >> und << haben nicht nur die Aufgabe von Datei zu lesen bzw. auf sie schreiben. Sie konvertieren auch Daten von der programminternen Binärdarstellung in die menschenlesbare Form von ASCII–Daten
und umgekehrt. Gelegentlich benötigt man diese Konversion auch programmintern, also ohne dass die Daten ausgegeben oder eingelesen werden. Dieses Konversionsproblem stellt sich in der Praxis recht oft, beispielsweise
Programmierung II
274
wenn Eingabedaten des Benutzers vor ihrer Übernahme ins Programm geprüft werden sollen, bei graphischen
Oberflächen (die nur Zeichen darstellen, aber keine Dateien sind), oder bei Programmen die mit anderen Daten
austauschen (verteilte Anwendungen).
Man kann die Konversionsfähigkeiten der Operatoren in eigenen Routinen nachprogrammieren, z.B. die Wandlung
von Strings in Integerwerte:
int zifferwert (char c) { return (int(c) - int(’0’));}
int string2int (string s) {
if (s.length() == 1) return zifferwert (s.at(0));
else
return
string2int (s.substr(0, s.length()-1)) * 10
+
zifferwert (s.at(s.length()-1));
}
Es ist natürlich wenig sinnvoll etwas, das der >>–Operator schon kann, so wie hier noch einmal zu implementieren.
Will man aber den >>–Operator zur Konversion nutzen, muss man zunächst über eine Datei gehen:
int string2int (string s) {
// Daten auf Datei schreiben, um
// spaeter den >>-Operator zu Konversion
// nutzen zu koennen:
ofstream fo ("temp.txt");
fo << s;
fo.close();
// Daten von Datei lesen
// dabei Konversion ASCII -> Binaer:
ifstream fi ("temp.txt");
int i;
fi >> i;
fi.close();
return i;
}
Auch das ist keine besonders gute Lösung. Im Vergleich zu programminternen Aktionen sind Aktionen auf Dateien
extrem aufwendig und langsam. In unserem Fall werden nur die Konversionsfähigkeiten von >> gebraucht: er soll
die ASCII–Darstellung in Binärdarstellung umwandleln; um diese Fähigkeit nutzen zu können, muss der String
dummerweise zuerst auf eine Datei geschrieben und dann von ihr gelesen werden.
9.3.2
String–Streams
Ein– und Ausgabeströme sind normalerweise mit externen Dateien verbunden. Das beschränkt die I/O–Operatoren
auf die Kommunikation mit Dateien. Das ist schlecht, denn die Shift–Operatoren können mehr als nur Ein- und
Ausgabe. Sie können Datenforamte umkonvertieren: von Text– in Binärdarstellung und umgekehrt. Es wäre schön,
wenn sie generell – und nicht nur bei der Ein– oder Ausgabe – zur Darstellungskonversion genutzt werden könnten.
Um dieses Problem zu lösen, um also mit << und >> die Darstellung von Daten programmintern wechseln zu
können, gibt es eine Art von “programminternen Dateien”: die String–Streams. Es gibt sie in zwei Varianten:
ostringstream (Headerdatei sstream): Ein Ausgabe–String–Stream. Mit ihm kann man Daten, statt
in eine Datei, in einen String–Stream hineinschreiben.
ostrstream (Headerdatei strstream) ist eine alte Variante von ostringstream.
istringstream (Headerdatei sstream): Ein Eingabe–String–Stream. Mit ihm kann man Daten, statt
aus einer Datei, aus einem String–Stream lesen.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
275
istrstream (Headerdatei strstream) ist eine alte Variante von istringstream.
Der Sinn dieser Konstrukte liegt wie gesagt darin, dass mit ihnen die mächtigen Stream–Operationen auch für
programminterne Aufgaben genutzt werden können.
Die älteren Konstrukte ostrstream und istrstream aus der Headerdatei strstream sollten nicht
mehr benutzt werden, wenn die C++–Implementierung die modereneren Varianten ostringstream und
istringstream aus der Headerdatei strstream zur Verfügung stellt.
9.3.3
ostringstream : Daten in Strings konvertieren
Mit dem <<–Operator können Daten unterschiedlichsten Typs ausgegeben werden. Bei der Ausgabe werden sie
in Folgen von Zeichen konvertiert und diese dann ausgegeben. Möchte man Daten in Zeichenfolgen konvertieren,
ohne sie gleich auf eine Datei auszugeben, benutzt man einen ostringstream. Beispiel:
#include <sstream>
#include <string>
using namespace std;
int main () {
ostringstream ostr;
int
i = 10;
float f = 72.12;
char c = ’A’;
// Daten mit << in ostr hineinschieben
// und dabei Binaerdaten in ASCII-Darstellung
// umwandeln:
//
ostr << "i = " << i << endl;
ostr << "f = " << f << endl;
ostr << "c = " << c << endl;
// Daten als String aus ostr herausholen:
//
string s = ostr.str();
// string s enthaelt
// die korrekte Darstellung (ASCII) der Werte
// von i, f und c.
cout << s << endl;
}
Hier wird ein Objekt vom Typ ostringstream erzeugt, mit << gefüllt und schließlich mit der Methode str()
in einen String verwandelt.
9.3.4
istringstream Daten aus einem String lesen
Mit istrstream kann in umgekehrter Richtung aus einem String gelesen und dabei in einen beliebigen Typ
konvertiert werden. Beispiel:
#include <sstream>
#include <string>
Programmierung II
276
#include <iostream>
using namespace std;
int main () {
string s = "10 72.12 A";
// Daten als String in istr schreiben:
//
istringstream istr (s);
int i; float f; char c;
// Daten mit >> aus istr herausholen und dabei
// aus ASCII-Darstellung in Binaerdaten umwandeln:
//
istr >> i;
istr >> f;
istr >> c;
// i, f und c
// enthalten jetzt die Werte 10, 72.12 und ’A’
cout << i << ", "<< f << ", "<< c << endl;
}
Der String s wird an den Konstruktor von istr übergeben und dieser füllt ihn dann mit den Zeichen des Strings
und macht damit aus einem String einen Eingabe–Stringstream. Aus diesem kann dann automatisch mit den richtigen Konvertierungsoperationen in die Variablen gelesen werden.
9.3.5
Beispiel, Konversionsfunktion: String nach Integer
Unser Konversionsbeispiel von oben lässt sich mit einem Stringstream folgendermaßen realisieren:
int string2int (string s) {
istringstream istr (s); // Mit ASCII-Zeichen fuellen
int i;
istr >> i;
// Konversion ASCII -> Binaer
return i;
}
Statt wie oben den String s in eine Datei zu schreiben, wird istr mit dem Wert von s initialisiert. Der Eingabeoperator kann sich dann sofort an seine Konversionsarbeit machen.
In umgekehrter Richtung benutzen wir einen ostringstream:
string int2string (int i) {
ostringstream of;
of << i;
// Konversion Binaer -> ASCII
return of.str(); // ASCII-Zeichen entnehmen
}
Die Methode str ohne Parameter liefert den Inhalt des Stringstreams als String. Mit einem Parameter setzt sie
dessen Inhalt neu:
of.str();
// liefert Inhalt von of
of.str("12.3"); // setzt Inhalt von of neu
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9.3.6
277
Besonderheiten von Stringstreams aus der strstream–Bibliothek
Die alte Stringstream–Bibliothek mit der Headerdatei strstream unterscheidet sich in drei Punkten von der
neueren:
1. strstream arbeitet mit C–Strings statt mit Strings.
2. Das Ende einer Zeichenkette wird nicht unbedingt automatisch gesetzt.
3. Die Speicherverwaltung ist nicht automatisch korrekt.
Die Konsequenz der beiden ersten Einschränkungen ist, dass alle Strings in C–Strings konvertiert werden müssen,
bevor sie in in eine istrsteam eingegeben werden können, sowie dass eingegebene Zeichen mit dem Endezeichen ends (0–Zeichen) abgeschlossen werden sollten. Beispiel:
#include <strstream>
#include <string>
#include <iostream>
using namespace std;
int main () {
string s = "10 72.12 A";
// istringstream mit C-String fuellen:
//
istrstream istr (s.c_str()); // s.c_str() ist s als C-String
....
// ostringstream Inhalt mit ends abschliessen:
//
ostrstream ostr;
ostr << "i = " << i << ends;
}
Eine Erörterung der Speicherverwaltung von strstream–Klassen geht über diese Einführung hinaus. Wir empfehlen die neuere Bibliothek zu benutzen und verweisen ansonsten auf Beschreibungen der Standardbibliothek.
9.3.7
Bibliotheksfunktion atoi
Für die Konversion von ASCII (in Form eines C–Strings) nach Integer gibt es in der C–Bibliothek die vordefinierten
Funktion atoi (“ASCII to Integer”).
#include <stdlib.h>
int string2int (string s) {
return atoi (s.c_str());
}
Diese spezielle Lösung wurde in C++ durch den allgemein verwendbaren Mechanismus der String–Streams ersetzt.
Natürlich kann atoi weiterhin auch in C++–Programmen verwendet werden.
9.4
9.4.1
Dateien in Iteratoren umwandeln
Iteratoren und Dateien
(Programm–) Dateien und Iteratoren, wie wir sie zum Durchlaufen der Listen konstruierten, sind sehr ähnliche
Konstrukte. Beide bieten die Möglichkeit über die Elemente einer “anderen” Datenstruktur zu wandern. Listen-
Programmierung II
278
iteratoren wandern über die Elemente einer Liste und Programm–Dateien (vom Typ istream oder ostream)
wandern über die Elemente einer wirklichen (Betriebssystem–) Datei.
9.4.2
Iteratoren auf Eingabedateien: istream iterator
Eine Eingabedatei kann sehr einfach in einen äquivalenten Iterator istream iterator verwandelt werden.
Beispiel:
#include <fstream>
#include <iterator>
using namespace std;
int main (int argc, char *argv[]) {
ifstream
inFile (argv[1]);
istream_iterator<int> iter(inFile);
istream_iterator<int> endOfInput;
// Datei
// Datei -> Iterator
// Ende-Iterator
int a [100];
int i = 0;
// Datei mit Iterator-Operationen durchlaufen,
// (Einlesen von int-Werten aus der Datei in das Feld a):
while ((iter != endOfInput) && (i<100)) {
a[i] = *iter;
// Wert einlesen
++iter;
// aktuelle Postion verschieben
++i;
}
}
istream iterator ist ein Template, dessen Argument den Typ der logischen Werte in der Datei angibt. 29
Tatsächlich ist die Datei eine Textdatei, die keine ints sondern chars (ASCII–Zeichen) enthält. Sie sollen hier
lediglich als int–Werte interpretiert werden. Die Umwandlung in einen Iterator unterstützt diese Interpretation.
Der erste Konstruktor
istream iterator<int> iter(inFile);
nimmt eine Eingabedatei als Argument und erzeugt daraus einen Iterator iter über einer Sequenz von int–
Werten. Der zweite (Default–) Konstruktor
istream iterator<int> endOfInput;
erzeugt einen “Iterator am Ende” endOfInput, dessen einziger Zweck der Vergleich mit iter ist. Man beachte
dass die Namen endOfInput und iter völlig willkürlich gewählt wurden.
In der while–Schleife wird die Datei durchlaufen und dabei eingelesen. Im Gegensatz zu den normalen Dateioperationen wird sie bei dieser Iterator–Version als Folge von int–Werten behandelt. So schiebt ++iter die
aktuelle Position (d.h. den Iterator) nicht auf das nächste Zeichen sondern auf den nächsten int–Wert. Die int–
Werte sind dabei rein virtuell, in der Datei stehen weiterhin Zeichen, keine Zahlen (d.h. keine ints in deren
spezieller Binärcodierung, sondern ASCII–codierte Zeichen).
(Siehe Abbildung 75):
9.4.3
Iteratoren interpretieren Eingabedateien mit dem operator>>
Jede Textdatei kann in einen Iterator über einen beliebigen Typ T verwandelt werden, falls für T der Eingabeoperator
29 Die
Datei kann auch die Standard–Eingabe cin sein.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
279
operator++, operator>>(..., int &)
istream_iterator<int>
ifstream
1
2
,
1
,
1
2
...
get, operator>>(..., char &)
Abbildung 75: Interpretation von Dateien auf unterschiedlichen Ebenen
istream & operator>> (istream &, T &e);
definiert ist. Ein solcher Operator kann natürlich jederzeit für jeden beliebigen Typ definiert werden. Beispiel:
#include
#include
#include
#include
<iostream>
<fstream>
<string>
<sstream>
using namespace std;
int string2int (string s) {
istrstream ifs (s);
int i;
ifs >> i;
return i;
}
struct Student {
Student ();
Student (string, int);
string
name;
int
matrikelNr;
};
Student::Student () : name ("??"), matrikelNr (-1) {}
Student::Student (string p_n, int p_nr) : name (p_n), matrikelNr (p_nr)
ostream & operator<< (ostream &s, const Student &e) { // Ausgabe-Operator
return s << e.name << ":" << e.matrikelNr << endl;
}
istream & operator>> (istream &s, Student &e) {
// Eingabe-Operator
char lineBuf[256] = "";
s.getline(lineBuf, 256);
string line = lineBuf;
e.name = string(line, 0, line.find(’:’));
e.matrikelNr = string2int (
(string(line, line.find(’:’)+1,
(line.length()-line.find(’:’))-1)
).c_str());
return s;
}
{}
Programmierung II
280
int main (int argc, char *argv[]) {
if (argc != 2) return 1;
ifstream
inFile (argv[1]);
istream_iterator<Student> iter(inFile);
istream_iterator<Student> endOfInput;
Student s;
while ((iter != endOfInput)) { // Schleife ueber eine Datei voller Studenten
s = *iter;
cout << s << endl;
++iter;
}
}
Das Programm kopiert die Datei deren Namen als Programmargument übergeben wurde auf die Standardausgabe.
9.4.4
Iteratoren auf Ausgabedateien: ostream iterator
Auch Ausgabedateien können zu Iteratoren über einen beliebigen Typ T transformiert werden, falls für T ein
Ausgabeoperator
ostream & operator<< (ostream &, const T &);
definiert ist. Eine andere Version des Programms, das eine Datei mit Studentendaten auf die Standardausgabe
kopiert:
...
... wie oben
...
int main (int argc, char *argv[]) {
if (argc != 2) return 1;
ifstream
inFile (argv[1]);
istream_iterator<Student> iterIn(inFile);
istream_iterator<Student> endOfInput;
ostream_iterator<Student> iterOut(cout);
while (iterIn != endOfInput) {
*iterOut = *iterIn;
++iterIn;
++iterOut;
}
// Eingabe-Iterator
// Ausgabe-Iterator
// Eingabe nach Ausgabe kopieren
}
9.4.5
Beispiel: Mischen
Ein weiteres Beispiel ist das Mischen von zwei sortierten Dateien zu einer sortierten Gesamtdatei:
#include
#include
#include
#include
#include
<iostream>
<fstream>
<iterator>
<string>
<sstream>
struct Element {
Element ();
Element (string, int);
string
info;
int
key;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
281
};
Element::Element () : info ("??"), key (-1) {}
Element::Element (string p_n, int p_key) : info (p_n), key (p_key)
{}
ostream & operator<< (ostream &s, const Element &e) {
return s << e.info << "\t" << e.key << endl;
}
istream & operator>> (istream &s, Element &e) { // Eingabe-Operator
char lineBuf[256] = "";
// mit string-stream
s.getline(lineBuf, 256);
// jedes Element steht in einer Zeile:
istringstream istrs (lineBuf);
// Info <weisse Zeichen> Key
istrs >> e.info;
istrs >> e.key;
return s;
}
// mische zwei sortierte Dateien zu
// zu einer sortierten Gesamtdatei
//
void mische (ifstream &a, ifstream &b, ofstream &c) {
istream_iterator<Element> ia(a);
istream_iterator<Element> ib(b);
istream_iterator<Element> endOfInput;
while ((ia != endOfInput) && (ib != endOfInput))
if ((*ia).key <= (*ib).key) {
c << *ia; ++ia;
} else {
c << *ib; ++ib;
}
while (ia != endOfInput)
{ c << *ia; ++ia; }
while (ib != endOfInput)
{ c << *ib; ++ib; }
}
int main (int argc, char *argv[]) {
if (argc != 4) return 1;
ifstream
inFile1 (argv[1]); // 1. Eingabedatei
ifstream
inFile2 (argv[2]); // 2. Eingabedatei
ofstream
outFile (argv[3]); // Ausgabedatei
mische (inFile1, inFile2, outFile);
}
9.5
9.5.1
Wahlfreier Zugriff
Wahlfreier Zugriff: Setzen der aktuellen Dateiposition
Dateien werden stets so gelesen und beschrieben, dass jede Lese– und Schreibaktion an der aktuellen Dateiposition
stattfindet und diese dann um eins weiter schiebt. Die aktuelle Position wird beim Öffnen auf gesetzt, d.h. auf
den Index des ersten Bytes in der Datei. Die aktuelle Position ist eine Byte–Position, unabhängig davon, was in der
Datei gespeichert wurde: int– oder float–Zahlen, Zeichen, Zeichenketten, etc.
In den bisherigen Beispielen wurde die aktuelle Position stets nur implizit verändert (d.h. als Seiteneffekt einer
anderen Operation). In diesem Abschnitt zeigen wir Programme, die Dateipositionen willkürlich – explizit – verschieben und damit Zugriff auf beliebige Stellen in einer Datei haben. Das explizite Verschieben der aktuellen
Position in einer Datei nennt man wahlfreien Zugriff (engl. random access).
Programmierung II
282
9.5.2
Positionierung des Lesezugriffs
Die aktuelle Position einer Datei kann im Programm sowohl abgefragt, als auch modifiziert werden. In folgendem
Beispiel wird dies ausgenutzt, um Dateien ab einer bestimmten Position auszugeben und um ihre Größe festzustellen:
#include <fstream>
using namespace std;
int main (int
ifstream
streampos
char
argc, char * argv[]) {
datei (argv[1]);
nr = string2int (argv[2]); // ab hier soll gelesen werden,
ch;
if (!datei) {
cout << "kann " << argv[1]
<< " nicht oeffnen \n"; exit (1);
}
//Die Datei ist offen, die Leseposition ist am Anfang (auf Byte 0)
// Absolut positionieren,
// Die Datei ab der eingegebenen Position ausgeben:
datei.seekg (nr);
// Datei positionieren
cout << "Datei " << argv[1]
<< " ab Position: "
<< datei.tellg () << endl;
datei.get (ch);
while (!datei.eof()) {
cout << ch;
datei.get (ch);
}
cout << "-------------------\n";
// Die Dateil"ange ausgeben:
datei.seekg (0, ios::end);
// auf 0-tes Byte vom Ende her,
// d.h auf das Ende, positionieren
cout << "Die Datei enthaelt "
<< datei.tellg () + 1
// aktuelle Position (=Ende) abfragen
<< " Zeichen\n";
// Vom Ende her positionieren,
// Die letzten Bytes der Datei ausgeben:
datei.clear ();
// EOF-Bedingung (vom letzten Lesen) entfernen
datei.seekg (-nr, ios::end); // Datei positionieren: -nr Bytes ab Dateiende
cout << "Datei " << argv[1]
<< " ab Position: "
<< datei.tellg () << endl;
datei.get (ch);
while (!datei.eof()) {
cout << ch;
datei.get (ch);
}
cout << "-------------------\n";
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
283
datei.seekg (nr); setzt die Leseposition (seekg = seek get) auf das Byte Nr. nr. (Das erste Byte hat die
Nr. .) Mit seekg kann man absolut auf ein bestimmtes Byte und relativ vom Anfang, vom Ende und von der
aktuellen Position her positionieren:
datei.seekg
datei.seekg
datei.seekg
datei.seekg
datei.tellg
(nr);
(nr, ios::beg);
(nr, ios::end);
(nr, ios::cur);
()
positioniert absolut auf ein bestimmtes Byte.
positioniert relativ zum Anfang.
positioniert relativ zum Ende.
positioniert relativ zur aktuellen Position.
gibt die aktuelle Leseposition an.
Mit
datei.seekg (0, ios::end);
wird auf das 0–te Byte rückwärts vom Ende her, d.h auf das Dateiende positioniert. Der Aufruf
datei.clear ();
im Beispiel oben ist notwendig, da die Datei zuvor bis zum Ende gelesen wurde und darum das EOF–Flag noch
gesetzt ist. (Ein erfolgloses Lesen setzt die EOF–Bedingung. seekg allein entfernt sie nicht wieder!)
9.5.3
Positionierung des Schreibzugriffs
Äquivalent zum Setzen der Leseposition mit seekg, kann mit seekp die Schreibposition einer Datei gesetzt
werden. Beispiel:
#include <fstream>
#include <stdlib.h>
using namespace std;
int main (int argc, char * argv[]) {
ofstream
datei (argv[1]);
streampos
pos;
char
ch;
if (!datei) { cout << "kann " << argv[1]
<< " nicht oeffnen\n";
exit (1); }
cin >> pos;
cin >> ch;
while (ch != ’X’) {
cout << "setze " << ch << "auf " << pos << endl;
datei.seekp (pos);
// Schreibposition setzen
datei.put (ch);
cin >> pos;
cin >> ch;
}
}
Mit diesem Programm können Bytes an beliebigen Positionen einer Datei geschrieben werden.
9.5.4
Dateien mit Lese– und Schreibzugriff
Dateien können gleichzeitig zum Lesen und zum Schreiben geöffnet werden. Das ist wichtig beispielsweise für
eine Datei, die als dauerhafter Speicher von Werten genutzt wird. Ein einfaches Beispiel ist:
#include <fstream>
#include <stdlib.h>
Programmierung II
284
using namespace std;
int main (int argc, char * argv[]) {
// Oeffnen zum Lesen und Schreiben:
fstream
datei (argv[1], ios::in|ios::out);
streampos
int
char
char
pos;
rpos;
buf[10];
c;
// Byte-Puffer
if (!datei) { cout << "kann " << argv[1]
<< " nicht oeffnen \n"; exit (1); }
cout << "Aktion: L(esen) oder S(chreiben) oder E(nde): ";
cin >> c;
while (true) {
switch (c) {
case ’L’:
// Lesen
cout << "Position: ";
cin >> rpos;
pos = rpos * 10;
datei.seekg (pos);
cout << "Lese Pos " << datei.tellg () << "\n";
datei.read (buf, 10);
cout << buf << endl;
break;
case ’S’:
// Schreiben
cout << "Was: ";
cin >> buf;
cout << "Position: ";
cin >> rpos;
pos = rpos * 10;
datei.seekp (pos);
cout << "Schreibe Pos " << datei.tellp () << "\n";
datei.write (buf, 10);
datei.flush();
break;
case ’E’: return 0;
}
cout << "Aktion: L(esen) oder S(chreiben) oder E(nde)\n";
cin >> c;
}
}
Mit diesem Programm können jeweils Folgen von 10 Zeichen in eine Datei geschrieben und später wieder gelesen
werden. Der Dateiinhalt bleibt bei Programmende erhalten und kann beim nächsten Aufruf wieder gelesen und
weiter modifiziert werden.
read und write sind Varianten von get und put, die nicht nur ein Zeichen sondern eine Zeichenfolge lesen
bzw. schreiben. Ihr erster Parameter ist ein Zeiger auf ein char–Feld und der zweite ist die Zahl der Bytes (chars)
die zu lesen oder zu schreiben sind.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9.5.5
285
Der Typ fstream und der Modus des Öffnens einer Datei
Die (Programm–) Datei ist hier vom Typ fstream. Weiter oben hatten wir es mit Dateien vom Typ ifstream
und ofstream zu tun, also mit Dateien, die auf Ein– oder Ausgabe spezialisiert waren. fstream ist allgemeiner
und kann darum beides. Beim Öffnen der (realen) Datei muss darum jetzt explizit der Öffnemodus angegeben
werden, d.h. die Art in der die Datei zu öffnen ist. ios::in ist der Eingabemodus, mit
datei(datei name, ios::in);
wird eine Datei zum Lesen, mit
datei(datei name, ios::out);
zum Schreiben und mit
datei(datei name, ios::in|ios::out);
zum Lesen und Schreiben geöffnet. Der Operator | ist ein Bit–Oder mit dem das Lese– und das Schreib–Flag
gleichzeitig gesetzt werden.
datei(datei name, ios::app);
öffnet zum “Anhängenden” (append) Schreiben. Die Datei wird geöffnet, aber dabei nicht wie bei ios::in
gelöscht. Alle Schreiboperationen hängen ihre Daten hinten an.
9.5.6
Die Bewegungen der Lese– und der Schreibposition sind nicht unbedingt unabhängig
Die Lese– und Schreibposition einer Datei können sich unabhängig voneinander bewegen, sie müssen aber nicht.
Wird beispielsweise mit seekg die Leseposition verändert, dann kann die Schreibposition noch den alten Wert
haben, sie kann jetzt aber auch den gleichen (neuen) Wert wie die Leseposition haben. Das gleiche gilt natürlich
umgekehrt auch für eine Veränderung der Schreibposition. Benötigt man beide Positionen, dann bewegt man sie
am besten auch immer beide parallel. (Nach dem Verändern einer Position ist die andere undefiniert.)
9.5.7
Mit flush werden Dateipuffer geleert
Der Aufruf datei.flush() bewirkt, dass die zu schreibenden Zeichen sofort auch tatsächlich in die Datei geschrieben werden. Normalerweise arbeiten die Dateioperationen mit einem internen I/O-Puffer. Mit ihm wird die
Zahl der aufwendigen realen Datei–Operationen minimiert und der Zugriff erheblich beschleunigt. Bei Anwendungen die sowohl lesen als auch schreiben, kann es passieren, dass geschriebene Werte beim nächsten Lesen noch
im Puffer sind und eigentlich Überschriebenes wieder gelesen wird. Dieser Effekt wird mit flush verhindert.
Übrigens hat endl ebenfalls eine flush Funktionalität.
ost << endl;
hat die gleiche Wirkung wie
ost << "\n"; ost.flush();
9.6
9.6.1
Dateistrukturen
Dateien als persistente Datenspeicher
Dateien sind nicht nur dazu da, um Programmeingaben zu liefern und Programmausgaben aufzunehmen. In Dateien kann man auch Daten über die Dauer eines Programmlaufs hinaus speichern. Damit kann ein Programm auf
einem Datenbestand arbeiten, ohne permanent aktiv sein zu müssen. Die Konten einer Bank sollen ja nicht gelöscht
werden, nur weil das Programm, das die Konten führt, einmal angehalten wird oder abstürzt.
Die peramanente Speicherung von Daten ist eine ganz wesentlichste Fähigkeit der Dateien. Sie sind damit als dauerhafte – oder wie man oft sagt persistente – Datenspeicher einsetzbar (Persistenz (engl. persistence) = Dauerhaftigkeit). Beliebige Daten können in ihnen für beliebig lange Zeiträume abgelegt und zur Bearbeitung bereitgehalten
werden.
Programmierung II
286
9.6.2
Dateien sind Byteströme
Im Gegensatz zu den programminternen Speichern, wie etwa Feldern, kann man nicht beliebige Arten von Dateien
anlegen. char–Felder, float–Felder, etc. Felder von beliebigen vor– oder selbst definierten Typen werden von
der Sprache unterstützt. Das gilt aber nicht für Dateien.30 Dateien sind – in C++ – immer Folgen von Bytes und
nicht etwa Folgen von Zahlen, oder von Stammsätzen eines Personalverzeichnisses etc.
9.6.3
Struktur einer Datei
Nehmen wir an, die Einträge eines Telefonbuchs sollen auf Datei gespeichert werden:
struct TEintrag {
TEintrag ();
TEintrag (string, string);
string
name;
string
telNr;
};
TEintrag::TEintrag () : name ("??") {}
TEintrag::TEintrag (string p_n, string p_nr) : name (p_n), telNr (p_nr)
{}
Es scheint zunächst leicht zu sein, solche Einträge auf Datei auszugeben:
ostream & operator<< (ostream &s, TEintrag p) {
return s << p.name << p.telNr;
}
Werden mit dieser Funktion allerdings tatsächlich Werte ausgegeben, z.B.:
TEintrag
hugo ("Hugo Meier", "4711");
TEintrag
emil ("Emil Mueller", "4712");
datei << hugo;
datei << emil;
dann geht die Struktur der Daten verloren:
Hugo Meier4711Emil Mueller4712
erscheint in der Datei.
datei ist eben eine Datei und damit eine Folge von Bytes und keine Folge von Telefonbucheinträgen vom Typ
TEintrag. Wollen wir umgekehrt von einer Datei den 17–ten Telefonbucheintrag lesen, dann können wir in der
Datei nicht mit datei.seekg(17) auf den 17. Eintrag positionieren und dann lesen: datei.seekg(17)
positioniert auf das 17–te Byte und nicht auf den 17–ten TEintrag.
Sollen Dateien also zur Speicherung von etwas anderem als Bytes (= chars) genutzt werden, dann ist offensichtlich noch etwas eigene Programmierarbeit erforderlich.
9.6.4
(Daten–) Satz und Feld
Eine Datei soll genutzt werden, um Folgen von Werten eines Typs zu speichern. Im allgemeinsten Fall ist
, ... . Kurz: ist ein Verbundtyp
benutzerdefiniert und enthält eine Reihe von Komponenten vom Typ ,
(Struct oder Klasse).
30 Diese Aussage gilt nicht für alle Programmiersprachen. In Pascal beispielsweise können Werte beliebiger Datentypen direkt in einer Datei
abgelegt werden. Es hat sich jedoch herausgestellt, dass diese Art der Dateiunterstützung problematisch ist. In C++ können zwar auch Werte
beliebiger Typen in einer Datei abgelegt werden. Dies muss aber von der Programmiererin selbst organisiert werden (z.B. durch Definition
eines geeigneten operator<< und operator>>, Umwandlung in einen Iterator, etc.), es kann nicht der Sprachimplementierung überlassen
werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
287
In der langen Tradition der Dateibearbeitung werden die logischen Elemente einer Datei (Daten–) Satz (engl. Record) genannt. Sind die Sätze selbst wieder strukturiert, dann nennt man die einzelnen Unterkomponenten jeweils
Feld (engl. Field):
Satz (Record): Logisches Element einer Datei. Dateien bestehen aus Folgen von Sätzen gleicher Art.
Feld: Element eines Satzes. Sätze bestehen aus Folgen von Feldern. Die Felder eines Satzes sind i.A. von
unterschiedlicher Art.
Damit die logische Struktur einer Datei als Folge von Sätzen, bestehend aus Folgen von Feldern, nicht in deren
Elementarstruktur “Bytefolge” untergeht, muss sie in irgendeiner Form erhalten werden: Dateien und die in ihnen
enthaltenen Sätze müssen strukturiert werden.
9.6.5
Feldstrukturen
Die Feldstruktur innerhalb eines Satzes kann mit verschiedenen Methoden kenntlich gemacht werden. Üblich sind:
1. Trennsymbole:
Die einzelnen Felder werden mit einem speziellen Trennsymbol z.B. einem Strich | voneinander getrennt.
Das Trennsymbol darf natürlich nicht als Bestandteil eines Felds auftreten. Die Reihenfolge der Felder
innerhalb eines Satzes muss fest und dem lesenden Programm bekannt sein.
2. fixe Länge:
Wenn jedem Feld eine feste Länge innerhalb der Datei zugewiesen wird, dann kann ein Programm (das
diese Länge kennt) Felder wieder eindeutig aus der Datei auslesen. Die Reihenfolge der Felder innerhalb
eines Satzes muss auch hier fest und dem lesenden Programm bekannt sein.
3. Längenfelder:
Will man die Länge der Felder variabel halten, dann muss jedes Feld zusammen mit einer Information über
dessen Länge gespeichert werden. Wieder muss die Reihenfolge der Felder innerhalb eines Satzes fest und
dem lesenden Programm bekannt sein.
4. Schlüssel–Wert Speicherung:
Will man die Reihenfolge der Felder innerhalb eines Satzes variabel halten, oder enthält ein Satz eventuell
nur eine Teilmenge aller möglichen Felder, dann müssen diese in der Schlüssel–Wert Form gespeichert
werden.
5. Schlüssel, Länge, Wert Speicherung:
Das ist die flexibelste aber auch aufwendigste Art der Speicherung.
9.6.6
Satzstrukturen
Bei der Speicherung der Sätze hat man im Prinzip die gleichen Probleme und Lösungen wie bei der von Feldern.
Üblich ist:
1. Trennsymbole: Sätze werden mit einem speziellen Symbol voneinander getrennt.
2. fester Länge: Alle Sätze haben die gleiche feste Länge.
3. Längenfeld: Jeder Satz beginnt mit einem Feld in dem dessen Länge zu finden ist.
4. Indexdatei: In einer zweiten Datei – der Indexdatei – wird die Startadresse jedes Satzes festgehalten. Satz
Nr. in der Indexdatei kann dann beispielsweise die Adresse (Position des ersten Bytes) von Satz Nr. in der
ersten Datei enthalten. Mit diesem Schema kann man direkt auf jeden Satz zugreifen, obwohl diese eventuell
alle unterschiedliche Längen haben. (Die Indexdatei hat natürlich eine feste Satzlänge.)
Programmierung II
288
Jede Art der Speicherung hat ihre Vor– und Nachteile. Sätze und Felder fester Länge können einfach modifiziert
oder durch andere ersetzt werden. Sie können aber auch zu einer enormen Verschwendung von Speicherplatz
führen. Sätze und Felder variabler Länge benötigen weniger Speicherplatz, sind aber nur mit Aufwand zu modifizieren oder zu ersetzen.
9.6.7
Beispiel
Als besonders einfache Variante betrachten wir ein Beispiel, bei dem die Trennsymbole Doppelpunkt und Zeilenvorschub zur Trennung der Felder bzw. der Sätze verwendet werden:
struct TEintrag {
TEintrag ();
TEintrag (string, string);
string
name;
string
telNr;
};
TEintrag::TEintrag () : name ("??") {}
TEintrag::TEintrag (string p_n, string p_nr) : name (p_n), telNr (p_nr)
{}
ostream & operator<< (ostream &s, const TEintrag &e) {
return s << e.name << ":" << e.telNr << endl;
}
istream & operator>> (istream &s, TEintrag &e) {
char lineBuf[256];
s.getline(lineBuf, 256);
string line = lineBuf;
e.name = string(line, 0,
line.find(’:’));
e.telNr = string(line, line.find(’:’)+1, (line.length()-line.find(’:’))-1);
return s;
}
Das Trennsymbol der Felder ist der Doppelpunkt (:). Die Sätze werden durch einen Zeilenvorschub getrennt. Will
man bei dieser Speicherung den –ten Satz einlesen, dann bleibt nichts übrig, als alle vorherigen zu überlesen:
bool getRecord (fstream &d, TEintrag &e, int nr) {
TEintrag buf;
d.seekg (0);
int count = 0;
while (count != nr && d) { // Vorherige ueberlesen
d >> buf;
// liest einen Satz
count ++;
}
if (d) {
d >> e;
// e enthaelt jetzt den gesuchten Satz
if (d) return true;
}
return false;
}
9.7
Binärdateien
9.7.1
Binärdateien
Wie bereits weiter oben erläutert, unterstützt C++ direkt nur die Speicherung von Bytes. (Lesbare) Zeichen
(ASCII–Zeichen) sind eine Untermenge der Bytes und ihre Speicherung ist damit unproblematisch. Bei der Speicherung beliebiger Daten kann man diese
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
289
entweder in Folgen (lesbare) Zeichen (Bytes) umwandeln und dann diese speichern (z.B. den Int–Wert
als drei Bytes ’1’,’2’,’3’), oder
direkt – ohne Umwandlung– als Bytefolgen genauso ablegen wie sie im Speicher liegen.
Im zweiten Fall spricht man von Binärdateien.
Man beachte, dass es sich dabei nicht um unterschiedliche Arten von C++–Dateikonstrukten oder gar um unterschiedliche Arten von realen Dateien handelt. Es geht nur um unterschiedliche Arten mit Dateien zu arbeiten.
(Siehe Abbildung 76):
Speicher im Rechner
int i = 21;
4 Bytes int im Programm
0001 0101 0000 0000 0000 0000 0000 0000
Umwandlung
z.B. operator<<
exakte
Kopie
Speicherung in
Textdatei
0011 0010 0011 0001
ASCII-2
ASCII-1
Binärdatei
0001 0101 0000 0000 0000 0000 0000 0000
seltsame Zeichen
Abbildung 76: Text– und Binärdateien
9.7.2
Binärdaten in ASCII–Zeichen umwandeln und speichern
Die Daten des Typs TEintrag von oben bestehen nur aus Zeichenfolgen. Das muss nicht immer so sein. Gelegentlich will man auch Werte anderer Basistypen (int, float, ...) abspeichern. Sollen etwa Verbunde vom Typ
Student:
struct Student {
Student ();
Student (string, int);
string
name;
int
matrikelNr;
};
Student::Student () : name ("??") {}
Student::Student (string p_n, int p_nr) : name (p_n), matrikelNr (p_nr)
{}
als Sätze in einer Datei gespeichert werden, dann kann die Matrikelnummer entweder als Folge von Zeichen oder
als int–Wert abgelegt werden.
Die Speicherung (Schreiben und Lesen) als ASCII Zeichenfolge ist leicht zu organisieren:
ostream & operator<< (ostream &s, const Student &e) {
return s << e.name << ":" << e.matrikelNr << endl;
}
// Ausgabe
Bei der Ausgabe wird die Matrikelnummer von int in eine Zeichenfolge umgewandelt. Der operator<< leistet
hierbei die Hauptarbeit.
9.7.3
Konversion eines Teilstrings in Binärdarstellung
Beim Einlesen muss dann in umgekehrter Richtung konvertiert werden: von einer Zeichenfolge in einen int–Wert.
Diese Konversion der Matrikelnr in Binär– (int) Darstellung ist etwas aufwendiger als die Ausgabe:
Programmierung II
290
istream & operator>> (istream &s, Student &e) {
// Einlesen
char lineBuf[256];
s.getline(lineBuf, 256);
string line = lineBuf;
e.name = string(line, 0, line.find(’:’));
e.matrikelNr = atoi (
//Konversion nach int
(string(line, line.find(’:’)+1,
(line.length()-line.find(’:’))-1)
).c_str());
return s;
}
Hier wird zunächst aus der Eingabe in line der Teil–String vom Doppelpunkt (:) bis zum Ende herausgefiltert,
dieser dann mit
(...).c str()
in eine C–String umgewandelt und aus diesem schließlich mit
atoi (...)
der entsprechenden int–Wert berechnet.
9.7.4
String–Streams
Für die Konversionsarbeit kann natürlich auch ein String–Stream eingesetzt werden. In diesem Fall arbeitet man
am besten mit weißen Zeichen als Trennsymbolen. Bleibt es beim Doppelpunkt, dann ist auch die Arbeit mit
String–Streams etwas umständlich:
#include <sstream>
...
istringstream is ((string(line, line.find(’:’)+1,
(line.length()-line.find(’:’))-1)
).c_str());
is >> e.matrikelNr;
...
Mit
istringstream is (..String..);
wird ein String in einen Eingabe String Stream umgewandelt. Auf diesen kann dann der Eingabe–Operator >>
angewendet werden und somit dessen Konversionsfähigkeiten genutzt werden:
is >> ..int-Variable..;
9.7.5
Ein Zeilenvorschub ist nur in Binärdateien ein einfaches Zeichen
Wie wir gesehen haben, ist die Frage, ob eine Datei eine Binär– oder eine Textdatei ist, keine Eigenschaft der
Datei, sondern eine Frage der Formatierung, also der Art wie die Daten beim Lesen und Schreiben interpretiert
und eventuell umgewandelt werden. Die Formatierung wird durch die Shift–Operatoren definiert und liegt damit fast vollständig in der Verantwortung des Programms. Das “fast” bezieht sich auf ein besonderes Zeichen:
den Zeilenvorschub. Der Zeilenvorschub ist ein ganz besonderes Zeichen: Jedes Betriebssystem hat sein eigenes Verständnis welches Zeichen bzw. welche Zeichenfolge “Zeilenvorschub” bedeuten. Damit ein Programm von
diesen Unterschieden unbehelligt bleibt, wird der systemspezifische Zeilenvorschub als “\n” an das Programm geliefert – egal welches Zeichen für des System nun gerade “Zeilenvorschub” bedeutet. Umgekehrt wird jedes “\n”
des Programms in der systemspezifischen Art als Zeilenvorschub gespeichert, z.B. als zwei Zeichen “\r\n”.
Bei einer Datei, deren Inhalt als Binärdaten behandelt werden, kann diese Sonderbehandkung von einzelnen Zeichen zu Fehlern führen. Das Byte “\n” kann zufällig Bestandteil einer Float–Zahl sein. Wird das Byte dann ohne
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
291
Konversion direkt gespeichert, dann wird das Basis–I/O-System von C++ es als Zeilenvorschub interpretieren, der
an die Gegebenheiten und Konventionen des jeweiligen Betriebssystems anzupassen ist. Es ist klar, dass dies dazu
führen kann, dass die Daten zerstört werden. Für das Lesen aus Datei gilt entsprechendes.
Bei Binärdaten sollte darum die Sonderbehandlung des Zeilenvorschubs ausgeschaltet werden. Man erreicht dies
durch Öffnen der Datei im Binärmodus, d.h. mit dem Flag ios::binary:
ifstream
datei ("dat.bin", ios::binary); // Datei binaer oeffnen:
// keine Sonderbehandlung von ’\n’
...
datei << "\n";
...
datei >> c;
// auf jedem Ssytem ausgegeben wird \n ausegeben
// Ein Zeilenvorschub erscheint nicht unbedingt als \n
Jetzt werden alle Zeichen unverändert geschrieben und gelesen.
9.7.6
Binärdateien: Binärdaten unverändert direkt speichern
Die Konversionen binär nach ASCII–Daten und umgekehrt kann man dem System ersparen, wenn die Binärdaten
direkt, so wie sie sind, in die Datei geschrieben werden. Eine Datei mit solchen “rohen” Daten wird Binärdatei
genannt.
Wir zeigen eine Variante, bei der alle Daten ohne Konversion gespeichert werden und die mit festen Feld– und
Satzlängen (statt mit Trennsymbolen) arbeitet:
ostream & operator<< (ostream &s, const Student &e) {
//Name speichen:
char buf[20];
// Puffer fuer den auf 20 Zeichen
//
normierten Namen
strncpy (buf, "", 20);
// Puffer saeubern: mit 0-en fuellen
strncpy (buf, e.name.c_str(), // max 20 Zeichen des Namens kopieren
e.name.length() < 20 ? e.name.length() : 20);
s.write (buf, 20);
// 20 Bytes in die Datei speichern
//Matrikelnummer speichern:
s.write (static_cast<const char *>( // int als char-Folge behandeln
static_cast<const void *>(
&e.matrikelNr)),
sizeof (int));
// sizeof(int) Bytes werden gespeichert
return s;
}
Der erste Teil der Routine beschäftigt sich damit, den Namen auf 20 Zeichen auszuweiten, ihn zu kürzen und
auszugeben.
Der zweite Teil besteht im wesentlichen darin die Konversion der Matrikelnummer von int in einen String zu
verhindern. Mit einer einfachen Ausgabe in der Form
s << e.name << ": < e.matrikelNr << endl;
würde e.matrikelNr unweigerlich in eine Zeichenkette verwandelt. Dies kann verhindert werden, indem man
das Schreiben der write–Methode überlässt, der man mit den beiden Cast–Operationen vorgaukelt, dass sie
einen C–String (vom Typ char *) schreibt:
s.write (static_cast<const char *>(
static_cast<const void *>(
&e.matrikelNr)),
sizeof (int));
//
//
//
//
//
//
2. Cast des Zeigers
von void * nach char *
1. Cast des Zeigers
von int * nach void *
ein Zeiger auf int
wieviele Bytes sind zu schreiben
Programmierung II
292
Mit static cast<void *>(..Zeiger..) kann ein Zeiger in einen vom Typ void * konvertiert werden und static cast<..Zeiger-Typ..>(..Zeiger vom Typ void *..) ist sozusagen die Umkehrfunktion.
9.7.7
Binärdatei schreiben und ansehen
Speichert man mit diesen Funktionen Datensätze:
...
fstream
datei ("stud.bin", ios::in|ios::out|ios::binary);
...
Student
hugo; ("Hugo Meier", 15);
Student
emil; ("Emil Muller", 254);
...
datei << emil;
datei << hugo;
...
in einer Datei mit Namen stud.bin und sieht sich dann (unter Unix) mit dem Dump–Utility od das Ergebnis an
od -xc stud.bin
dann wird sich die Datei etwa wie folgt zeigen:
0000000 6d45
E
0000020 0000
\0
0000040 7265
e
0000060 0a00
\0
0000062
6c69 4d20 6cfc
m
i
l
0000 00fe 0000
\0 \0 \0
0000 0000 0000
r \0 \0 \0
656c 0072 0000 0000
M
u
l
l
e
7548 6f67 4d20 6965
\0 \0 \0
H
u
0000 0000 000f 0000
\0 \0 \0 \0 \0
r
\0
\0
\0
\0
\0
g
o
M
e
i
\0
\0 017
\0
\0
\0
\n
Die Datei wird in Form von Zeilenpaaren – Datei–Zeile, ASCII–Decodierung der Zeile – dargestellt. Jede Zeile
enthält in der ersten Spalte die Adresse und dann 16 Bytes Dateiinhalt. Die Adresse ist die Dateiposition des ersten
Bytes dieser Zeile in Hex–Darstellung.
Die Matrikelnummern finden sich hier als jeweils 4 Bytes mit den Hex–Werten 00fe 0000 (245) bzw. 000f
0000 (15)31.
Die Einleseoperation ist komplementär zur Ausgabeoperation:
istream & operator>> (istream &s, Student &e) {
char buf[21];
strncpy (buf, "", 21);
s.read(buf, 20);
e.name = string(buf);
s.read (static_cast<char *>(static_cast<void *>(
&e.matrikelNr)),
sizeof(int));
return s;
}
9.7.8
Systemabhängige Byteordnung und Bytelänge
Das Format von Binärdaten ist systemabhängig. Dateien, die auf dem einem System erzeugt werden, können nicht
ohne weiteres auf einem anderen gelesen werden. Die Zahlen der Bytes pro int– oder float–Wert variieren
31 Die genaue Art der Speicherung ist systemabhängig. Das Beispiel wurde auf einem Rechner mit Intel–Prozessor erstellt, wie man unschwer
an der Byteordnung der beiden Zahlen erkennt.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
293
von System zu System und das Format der Speicherung kann sich ebenfalls unterscheiden. Sind unterschiedliche
Systeme im Spiel, dann einigt man sich am einfachsten darauf alles im ASCII–Format zu speichern.
9.8
9.8.1
Dateiabstraktion: Datei als Speicher von Sätzen fester Länge
Modularisierung
Angenommen wir wollten ein Auskunftsystem für Telefonnummern implementieren. Eine solche Anwendung
umfasst stets zwei elementare Grundbestandteile – zwei Module wie man sagt. Einer der beiden Module wickelt
die Anfragen ab, der andere beschäftigt sich mit der Speicherung der Daten.
Eine solche Modularisierung (Zerlegung) der Aufgabe hat wesentliche Vorteile. Bei der Bearbeitung der Benutzeranfragen muss man sich nicht um Details der Datenspeicherung kümmern und umgekehrt kann die Speicherung
der Daten vollständig unabhängig von der speziellen Anwendung konzipiert und realisiert werden. Änderungen
des einen Moduls tangieren den anderen nicht und im Idealfall kann ein Modul oder beide auch in völlig anderen
Anwendungen eingesetzt werden.
9.8.2
Dateiabstraktion
Bei einer Modularisierung ist die erste und wichtigste Frage die nach der Schnittstelle der Module. In unserem Fall
interessieren wir uns speziell für die Schnittstelle der Dateibehandlung zum Rest der Anwendung.
Das Modul “Dateibehandlung” soll natürlich etwas mehr Service bieten als die Dateioperationen, die sowieso
schon von C++ her zur Verfügung stehen – andernfalls wäre es ja völlig überflüssig. Die Frage ist, welcher “Mehrwert” denn genau geboten werden soll. Es muss so etwas wie eine mächtigere, intelligentere Version von Datei
sein, eine die Besseres bietet als die Byteströme, die von den vordefinierten Dateien manipuliert werden.
Alles was es nicht vordefiniert gibt, sondern selbst implementiert werden muss, wird üblicherweise “Abstraktion”
genannt. Die gesuchte Schnittstelle zum Speichermodul nennen wir darum eine Dateiabstraktion. (Siehe Abbildung 77):
BildschirmSteuerung
Benutzeroberfläche
Datenspeicherung
Konzept
DateiZugriff
Auskunftsystem
Modularisierung
Schnittstelle
BildschirmSteuerung
Modul-1:
Benutzeroberfläche
Operation auf abstrakter Datei
Modul-2:
Datenspeicherung
(abstrakte Datei)
DateiZugriff
Operation auf C++-Datei
Operation auf realer Datei
Abbildung 77: Konzept einer abstrakten Datei als Speichermodul
9.8.3
FixedFile: Datei–Abstraktion für Sätze fester Länge
Wir wollen eine Dateiabstraktion implementieren, die Datensätze fester Länge speichern kann. Der Service dieser
Abstraktion soll bescheiden sein, sie soll uns lediglich das Öffnen, Schließen, Schreiben und Lesen abnehmen. Wir
Programmierung II
294
möchten aber auch, dass der Service genutzt werden kann, um Sätze beliebiger aber fester Länge abzuspeichern.
Die Verwendung der Abstraktion FixedFile könnte wie folgt aussehen:
Record r;
const int SIZE_R =
char
buf[SIZE_R];
.... Groesse der Darstellung von R ....
.... R als Bytestrom ....
FixedFile ff;
// neue externe Datei erzeugen:
ff.create ("testdatei.test", SIZE_R);
// bereits existierende Datei "offnen
ff.open ("testdatei.test");
// r schreiben:
r.pack (buf);
ff.store (buf);
...
// r lesen:
ff.fetch (buf);
r.unpack (buf);
// Satz in Bytefolge verwandeln: r -> buf
// Bytefolge abspeichern
// Bytefolge von Datei lesen
// Byte in Datensatz umwandeln: buf -> r
Für das Objekt ff von Typ FixedFile sind Sätze nichts anderes als Bytefolgen fester Länge. Die Transformation von Sätzen in Bytes und umgekehrt liegt vollständig außerhalb von ff. Die Methoden pack und unpack
vom Satztyp Record sind für diese Aktionen zuständig.
9.8.4
pack und unpack: wer wandelt Sätze in Bytefolgen und umgekehrt um
Die Aufrufe von pack und unpack aus der Anwendung heraus treffen nicht unbedingt unsere Idealvorstellungen.
Es wäre wesentlich schöner, wenn ein Objekt vom Typ Record direkt – ohne (un)pack – gespeichert und
gelesen werden könnte:
FixedFile ff;
Record
r;
...
// r schreiben:
ff.store (r);
...
// r lesen:
ff.fetch (r);
Das steht allerdings im Widerspruch zu unserer Forderung, dass FixedFile ff mit beliebigen Sätzen zurechtkommen soll. Wenn es alle Sätze behandeln kann, dann muss es unabhängig vom Typ Record der Sätze sein und
kann infolgedessen nicht wissen, wie diese gepackt und entpackt werden.
Die Umwandlungen (Packen und Entpacken) kann nur eine Methode von Record vornehmen. Den Aufruf der
entsprechenden Pack–Operationen kann man allerdings von der Anwendung in die Dateioperationen verschieben.
Die Leseoperation folgt dann folgendem Schema:
... FixedFile::fetch (Record &r, ...) {
...
char buf [..recSize..];
file.read (buf, ..recSsize..);
// Bytes lesen
r.unpack (buf);
// Bytes in Record konvertieren
...
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
295
Jetzt ist aber FixedFile::fetch direkt vom Satz–Typ Record und dessen Größe recSsize abhängig.
Damit müsste für jeden Satztyp ein eigener Dateityp FixedFile fuer Record xy definiert werden.
Dem kann aber mit der Umwandlung von FixedFile in ein Template abgeholfen werden, dafür gibt es sie ja:
FixedFile<Record> ff;
Record
r;
...
// r schreiben:
ff.store (r);
...
// r lesen:
ff.fetch (r);
9.8.5
Datei–Abstraktion für Sätze fester Länge als Template
Die Transformation eines Satzes in eine Bytefolge kann jetzt wie gewünscht innerhalb von FixedFile stattfinden. Zwar ist es immer noch der Datensatz r, der weiß, wie er zu Bytes wird (pack) und aus Bytes entstehen kann
(unpack). Dieses Wissen kann aber als Template–Argument in FixedFile hinein transportiert werden.
Betrachten wir beispielsweise die Leseoperation fetch:
template<class Record>
bool FixedFile<Record>::fetch (Record &r, streampos pos) {
if (!isOpen) return false;
if (pos > 0)
file.seekg (pos);
char buf [Record::recSize];
file.read (buf, Record::recSize);
if (!file) {
::perror ("Kann Datei nicht lesen\n");
return false;
}
r.unpack (buf);
return true;
}
Die Satzgröße recSize kommt hier mit dem Templateparameter Record in den Code von FixedFile. Der
Typ der Datensätze Record muss natürlich die entsprechenden Dienste liefern. Z.B.:
struct EinRecord {
...
// Wird von FixedFile benoetigt:
void
pack
(char *) const;
void
unpack (const char *);
static const int recSize = ...;
};
...
FixedFile<EinRecord> ff;
EinRecord
r;
...
// Satz -> Bytes
// Bytes -> Satz
// Anzahl der Bytes im gepackten Satz
Die drei Komponenten müssen natürlich öffentlich sein, damit FixedFile sie benutzen kann. recSize wird
im Template benötigt um Sätze zu lesen und zu schreiben. Die Speicheroperation beispielsweise ist:
template<class Record>
bool FixedFile<Record>::store (const Record &r, streampos pos) {
if (!isOpen) return false;
if (pos > 0) file.seekp (pos);
Programmierung II
296
char buf [Record::recSize]; // Platz fuer Satz als Bytefolge
r.pack (buf);
// Als Bytefolge ablegen
file.write (buf, recSize);
// in Datei speichern
file.flush();
if (!file) {
::perror ("Kann Datei nicht schreiben\n");
return false;
}
return true;
}
Die Klassendefinition des Templates für Dateien mit Sätzen fester Länge ist insgesamt:
template<class Record>
class FixedFile {
public:
FixedFile ();
FixedFile (const char *fileName);
˜FixedFile ();
bool open
(const char *fileName);
bool create (const char *fileName);
void close ();
bool store (const Record &, streampos pos = -1);
bool fetch (Record &,
streampos pos = -1);
bool append (const Record &);
bool rewind ();
bool eof ();
private:
fstream
int
bool
file;
recSize;
isOpen;
static const int headerSize = 32;
bool
bool
readHeader ();
writeHeader ();
};
Die Leseposition soll hier als die Byteposition des Satzanfangs interpretiert werden. Selbstverständlich kann man
auch mit Satznummern arbeiten. Beim Zugriff muss die Satznummer einfach mit der Satzlänge multipliziert werden
und schon erhält man die Byteposition.
9.8.6
Datei–Header mit Metadaten
Mit open wird eine exisitierende Datei geöffnet und mit create wird eine Datei neu erzeugt. Jede Datei beginnt
mit einem Header–Satz. In ihm werden Informationen über den Inhalt der Datei (also Metadaten) gespeichert. In
unserem Beispiel speichern wir hier einfach die feste Länge der Sätze:
template<class Record>
bool FixedFile<Record>::open (const char *fileName){
if (isOpen) {
close (); isOpen = false;
}
// Datei wird gelesen und geschrieben, sie muss bereits existieren:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
297
file.open (fileName, ios::in|ios::out|ios::nocreate);
if (!file) {
::perror ("Kann Datei nicht oeffnen\n");
return false;
} else {
if (readHeader ()) { // Header lesen gelungen
//Auf den ersten Daten-Satz positionieren:
file.seekp (headerSize, ios::beg);
file.seekg (headerSize, ios::beg);
isOpen = true;
return true;
}
}
return false;
}
Mit dem Header der Datei kann jetzt geprüft werden ob zumindest die Satzlänge korrekt ist:
template<class Record>
bool FixedFile<Record>::readHeader () {
char buf[headerSize];
file.seekg (0);
file.read (buf, headerSize);
if (!file) {
::perror ("Kann Header nicht lesen\n");
return false;
} else {
recSize = atoi (buf);
if (recSize != Record::recSize) {
::perror ("Falsches Dateiformat!\n");
return false;
}
return true;
}
}
Selbstverständlich ist eine passende Satzlänge allein noch keine Garantie, dass die Datei auch tatsächlich Sätze
vom erwarteten Typ enthält. Ein gewisser Schutz besteht aber und bei Bedarf können weitere Informationen im
Header abgelegt werden.
Zur Konversion eines C–Strings nach Interger haben wir hier wieder die Funktion
#include <stdlib.h>
int atoi (const char *);
aus der C–Bibliothek benutzt.
9.8.7
Metadaten erzeugen
Die Methode create hat komplementär zu open die Aufgabe den Header zu erzeugen:
template<class Record>
bool FixedFile<Record>::create (const char *fileName){
if (isOpen) {
close (); isOpen = false;
}
file.open (fileName, ios::out);
if (!file) {
Programmierung II
298
::perror ("Kann Datei nicht erzeugen\n");
return false;
} else {
recSize = Record::recSize;
file.close();
return writeHeader ();
}
}
Der Rest der Methoden ist wohl offensichtlich.
9.8.8
Rückblick auf den Entwurf: Das Verantwortungsprinzip
Der Entwurf von FixedFile und ihrem Partner Record basiert auf dem Prinzip der Verantwortung: Welche
Klasse ist für was verantwortlich. Verantwortung sollte dabei nur einer Klasse übertragen werden, die auf Grund
ihres Wissens in der Lage ist, die Verantwortung zu tragen.
Nach diesem Prinzip hat sich folgende Aufgabenteilung ergeben:
FixedFile ist für alle Operationen auf der realen Datei zuständig: Erzeugen, Öffnen, Bytefolgen lesen
und schreiben.
Record muss sich selbst in eine Bytefolge konvertieren und aus einer Bytefolge rekonstruieren können.
Damit ergibt sich dann fast zwangsläufig die Interaktion der beiden. In UML als Interaktionsdiagramm dargestellt
(Siehe Abbildung 78):
open
fetch (r)
read
unpack
ff:FixedFile
:fstream
r:Record
Abbildung 78: Interaktion von FixedFile und Record
FixedFile muss, um die richtige Zahl an Bytes lesen und schreiben zu können, die Größe von Record kennen.
Record exportiert den entsprechenden Wert recSize als statische Datenkomponente, er ist ja für alle Instanzen
gleich.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9.9
9.9.1
299
Iteratoren auf abstrakten Satz–Dateien
Interne aktuelle Position oder externe aktuelle Position (Iterator)
Im letzten Abschnitt haben wir eine Dateiabstraktion betrachtet, bei der Sätze über ihre Byteposition zugreifbar
waren. Das Beispiel kann leicht so modifiziert werden, dass beim Zugriff statt der Byteadresse eines Satzes dessen
Nummer angegeben wird. Das Ergebnis ist eine virtuelle (= abstrakte) Satz–Datei mit wahlfreiem Zugriff.
Für viele Anwendungen ist eine solche Satz–Datei die geeignete Abstraktion die man über “rohe” Dateien legt.
Ein wesentliches Element realer Dateien enthält sie allerdings nicht mehr: Es gibt keine verschiebbare aktuelle
Position. Das Konstrukt der Satzdatei kann bei Bedarf auch um eine verschiebbare aktuelle Dateiposition in einer
abstrakten Satzdatei erweitert werden. Der wahlfreie Zugriff kann dabei erhalten bleiben oder wegfallen.
Wie bei Listen oder anderen Behälterklassen hat man zwei prinzipielle Konzepte zur Verfügung um eine aktuelle
Position in einer Sequenz zu realisieren:
interne aktuelle Position: Die Datei–Abstraktion beinhaltet eine (oder mehrere) verschiebbare aktuelle Positionen. Beispielsweise eine Lese– und eine Schreibposition. Das ist das Positionskonzept der realen Dateien.
externe aktuelle Position (Iterator): Die Datei–Abstraktion ist positionslos und wird mit einem (oder mehreren) zugordneten Iteratoren durchlaufen. Dieses Konzept wird in der Regel bei Listen und anderen Behälterklassen verwendet.
Man beachte, dass wir hier von Satz–Positionen reden; nicht von Byte–Positionen. Eine reale Datei hat eine Lese–
und eine Schreibposition als Byteadressen.
Bei der Konstruktion einer Dateiabstraktion als permanentem Speicher von Satzfolgen kann die Unterscheidung
in Lese– und Schreibposition in irgendeiner Form übernommen werden. Das muss nicht sein.
9.9.2
Datei–Abstraktion mit interner aktueller Position
Bei Datei–Abstraktionen mit interner aktueller Position wird ein virtueller Cursor über die Datei von Satz zu Satz
geschoben. Der Satz an der aktuellen Position kann gelesen, modifiziert und eventuell gelöscht werden. Nach dem
Öffnen der Datei steht der Cursor vor dem ersten Satz, mit next wird er zum nächsten verschoben.
Der Cursor ist Bestandteil der Datei (–Abstraktion) und wird mit deren Methoden verschoben. Eine entsprechende
Abstraktion könnte wie folgt aussehen:
template<class Record>
class CursorFile {
public:
CursorFile ();
CursorFile (const char *fileName);
˜CursorFile ();
bool open
(const char *fileName);
bool create (const char *fileName);
void close ();
bool
bool
bool
bool
bool
next
enter
update
read
erase
bool rewind
private:
bool
();
(const Record &);
(const Record &);
(Record &);
();
();
offLeft;
//
//
//
//
//
auf den naechsten Satz positionieren
neuen Satz speichern
aktuellen Satz ueberschreiben
aktuellen Satz lesen
aktuellen Satz loeschen
// zurueck an den Anfang
// ist die aktuelle Position vor dem ersten ?
Programmierung II
300
bool
...
offRight;
// ist die aktuelle Position hinter dem letzten ?
};
Ein Verwendung würde etwa folgendes Aussehen haben:
...
CursorFile<Student> studies("Studie-Datei");
...
studies.rewind();
// zurueck spulen
while (!studies.next()) { // auf den naechsten positionieren
Student s;
studies.read (s);
// Satz an der aktuellen Position
... s bearbeiten ...
// lesen, veraendern, ...
}
...
9.9.3
Datei–Abstraktion mit Iterator: istream iterator und ostream iterator reichen nicht
Weiter oben haben wir gesehen wie mit istream iterator bzw. mit ostream iterator reale (Text–)
Dateien (vom Typ ifstream bzw. ofstream) in Iteratoren über einem beliebigen Typ T verwandelt werden. Hier wollen wir abstrakte Dateien (mit beliebigem selbst definiertem Typ) mit Iteratoren versehen. Ein
istream iterator kann nur lesen. Ein ostream iterator kann nur schreiben; beide können sich nur
immer jeweils um ein Element nach vorn bewegen. Mit einem Iterator sollte man aber gleichzeitig lesen, schreiben
und sich eventuell auch in beliebiger Richtung bewegen können.
Ein ausgewachsener Iterator zu einer abstrakten Datei müsste also selbst definiert werden. Dateien (abstrakt oder
real) mit externer Position (d.h mit zugeordnetem Iterator) sind jedoch nicht üblich. Wir verzichten darum auf diese
Art der Positionierung.
9.10
Verwaltung von Sätzen
9.10.1 Elementare Dateioperationen
Für die meisten Datei–orientierten Anwendungen sind Dateien Kollektionen von Sätzen. Die prinzipiellen Operationen auf einer Datei sind darum:
Lesen: Soll ein Satz gelesen werden, dann muss er vorher in der Datei gefunden und identifiziert werden.
Verändern: Verändern eines Satzes kann als Löschen und anschließendes Hinzufügen verstanden werden.
Hinzufügen: Beim Hinzufügen muss ein Platz für den neuen Satz innerhalb der Datei gefunden werden. Im
einfachsten Fall und als letzte Möglichkeit ist am Ende der Datei praktisch immer Platz für einen weiteren
Satz.
Löschen: Sätze können tatsächlich in der Datei gelöscht werden. Da dies aber mit erheblichem Aufwand
verbunden ist – alle nachfolgenden müssen vorgeschoben werden –, wird man oft zu besseren Methoden
des Löschens greifen – z.B. den Satz nur als gelöscht markieren –, die dann auch einen Einfluss auf das
Hinzufügen haben.
9.10.2 Löschen und Kompaktieren
Soll ein Satz in einer Datei gelöscht werden, kann man einfach die gesamte Datei mit Ausnahme von in
eine neue Datei kopieren und anschließend in umbenennen. Hierbei wird praktisch im Fluge als
gelöscht markiert und die Datei anschließend kompaktiert32.
32 Der Begriff “Kompaktieren” soll deutlich machen, dass die Datei in eine kompaktere Form gebracht wird, Verwechslungen mit “Kompression” sollen dabei aber vermieden werden.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
301
Diese Strategie lässt sich auch leicht auf die Veränderung eines Satzes erweitern: statt wird einfach dessen neue
Version nach kopiert. Bei kleinen Dateien mit ein paar Hundert Sätzen, in denen Modifikationen nicht
sehr häufig sind, ist das durchaus eine sinnvolle Strategie, die Implementierung ist einfach und das Verfahren kann
auch auf Dateien mit Sätzen variabler Größe angewendet werden.
Bei einem verbesserten Verfahren werden die zu löschenden Sätze mit einer speziellen Markierung versehen.
Später können dann viele obsolete Sätze in einem Kompaktierungslauf entfernt werden. Es ist klar, dass als gelöscht
markierte Sätze beim Lesen übersehen werden.
9.10.3 Dynamische Wiederverwendung gelöschter Sätze
Enthält eine Datei Sätze, die mit “Gelöscht” markiert sind, dann spricht auch nichts dagegen diese bei den folgenden Hinzufüg–Operationen wieder zu verwenden. Bei fester Satzgröße ist das nicht problematisch, man nimmt
den ersten, bei variabler Größe wird man nach einer passenden Lücke suchen müssen. Nach einiger Zeit werden
dann viele kleine Lücken die Datei bevölkern und ein neuer Kompaktierungslauf ist fällig.
9.10.4 Verfügbarkeitsliste
Die Suche nach einer Lücke in Form eines gelöschten Satzes kann beschleunigt werden, wenn die Lücken in einer
Liste verkettet sind. Ein gelöschter Satz enthält dann neben der Löschmarke auch noch einen Verweis – in Form
einer Dateiposition – auf den nächsten gelöschten: sie bilden eine Liste freier Plätze.
Löschen bedeutet dann markieren und in eine Verfügbarkeitsliste einfügen. Hinzufügen bedeutet einen geeigneten
Satz aus der Verfügbarkeitsliste entnehmen – falls vorhanden, ansonsten wird am Dateiende geschrieben.
Bei Sätzen fester Länge kann man stets am Anfang der Verfügbarkeitsliste arbeiten, diese also wie einen Stapel
behandeln. Bei variaber Satzgröße muss die Liste natürlich nach einem passenden freien Platz durchsucht werden.
9.10.5 Beispiel Verfügbarkeitsliste bei fester Satzgröße
Als Beispiel wollen wir eine Verfügbarkeitsliste für Dateien mit fester Satzgröße implementieren.
Die Verwaltung einer Verfügbarkeitsliste in Stapelart haben wir bereits beim Pool kennengelernt. Die Dateivariante ist zwar etwas mühsamer, bringt aber keine wesentlichen neuen Problemstellungen. Freigeben ist vorn in die
Liste einhängen. Die entsprechende (private) Methode ist allocate. Sie positioniert die Datei auf den Beginn
eines freien Platzes. Die (private) Datenkomponente availAnc ist der Start der Verfügbarkeitsliste:
template<class Record>
class AVFile {
public:
...
bool
erase ();
...
private:
...
streampos availAnc;
...
bool
allocate ();
...
};
// Satz loeschen und freigeben
// Start der Verfuegbarkeitsliste, Spiegel des
// entsprechenden Wertes in der Datei
// auf freien Satz positionieren
Der Anker der Liste availAnc darf beim Schließen der Datei nicht verloren gehen. Sein Wert wird darum nach
jeder Änderung auf der Datei gesichert. Wir erklären ihn einfach zum Bestandteil des Headers und speichen mit
der (privaten) Methode writeHeader.
template<class Record>
//positioniert auf freien Record
bool AVFile<Record>::allocate () {
Programmierung II
302
streampos pos;
if (availAnc == 0) {
// kein freier Platz in der Liste:
pos = getFileEnd ();
// Auf das Dateiende positionieren
char buf [Record::recSize+1] = "";
buf[0] = ’A’;
file.write (buf, Record::recSize+1); // Aktiv-Marke, Dummy-Record
if (!file) {
::perror ("Kann Record nicht anhaengen\n");
return false;
}
} else { //Element aus Vefuegbarkeitsliste nehmen
pos = availAnc;
char buf[Record::recSize+1] = "";
file.seekg (availAnc);
file.read (buf, Record::recSize+1);
if (buf[0] != ’D’) {
::perror ("Datei-Inkonsistenz: freier Record nicht geloescht!\n");
return false;
}
availAnc = streampos (atoi (&buf[1])); //availAnc ist Headerbestandteil
writeHeader ();
//Header veraendert -> sichern
file.seekp (pos);
file.write ("A", 1);
}
file.flush();
file.seekg (pos);
//Auf Record-Anfang positionieren
file.seekp (pos);
if (!file) {
::perror ("Kann Record nicht einfuegen\n");
return false;
}
return true;
}
Die komplementäre Operation des Freigebens wird beim Löschen eines Satzes benötigt:
template<class Record>
//aktuellen Record loeschen und Platz freigeben
bool AVFile<Record>::erase () {
if (!isOpen || offLeft || offRight) return false;
char buf [Record::recSize+1] = "";
buf[0] = ’D’;
// Loeschmarke
sprintf (&buf[1], "%d", int(availAnc));
streampos pos = file.tellg (); // aktuelle Position
availAnc = pos;
// in Vefuegbarkeitsliste haengen
writeHeader ();
// Verfuegbarkeitsliste sichern
file.write (buf, recSize+1);
// in die Liste einhaengen
file.flush();
if (!file) {
::perror ("Kann Record nicht loeschen\n");
return false;
}
file.seekg (pos);
file.seekp (pos);
return true;
}
Die Routine löscht den Record an der aktuellen Dateiposition durch Markieren mit einem D und Einhängen in die
Verfügbarkeitsliste.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
9.11
303
Übungen
Aufgabe 1
1. Schreiben Sie ein Programm das eine Textdatei in eine andere kopiert. Die Namen der beiden Dateien sollen
per Programmzeile übergeben werden. Am Ende soll Ihr Programm ausgeben wieviele Zeilen und wieviele
Zeichen es kopiert hat.
2. Schreiben Sie ein Programm das eine Textdatei liest und die Zahl der Zeichen, Worte und Zeilen dieser Datei
ausgibt. Ein Wort ist dabei eine zusammenhängende Folge von Zeichen die kein “weisses Zeichen” enthält.
Weisse Zeichen sind Leerzeichen, Tabulator und Zeilenvorschub.
3. Schreiben Sie ein Programm, das die Größe einer Datei bestimmt, ohne sie einzulesen.
4. Schreiben Sie ein Programm, das eine Datei ab einer bestimmten Byteposition ausgibt. Datei und Byteposition sind per Programmargument zu übergeben. Ihr Programm soll eine Fehlermeldung ausgeben, wenn die
Datei kürzer ist, als die übergebene Byteposition.
5. Schreiben Sie ein Programm prog, das seine Eingaben von der Standardeingabe liest, wenn es ohne Parameter (Kommandozeilenargument) aufgerufen wird. Mit Parameter soll es seine Eingabe von der Datei
lesen, deren Name als Argument übergeben wurde. Beispiel, Eingabe von der Standardeingabe:
prog
Eingabe von der Datei hugo.txt:
prog hugo.txt
Aufgabe 2
1. Warum ist es vorteilhafter Ein-/Augabeoperatoren zu definieren, als spezeielle Lese– und Schreibmethoden
oder Funktionen?
class C {
...
void print ();
...
};
// SCHLECHT
ostream & operator<< (ostream & os, const C & c); // GUT
2. Wodurch unterscheiden sich Binärdateien von Textdateien?
3. Wodurch unterscheidet sich die Ausgabe von endl von der Ausgabe eines Zeilenvorschub–Zeichens?
4. Wie gibt man eine Matrix von Float–Zahlen so aus, dass alle Werte mit zwei Stellen hinter dem Komma und
exakt ausgerichtet gedruckt werden.
Aufgabe 3
Schreiben Sie ein Programm das eine Textdatei einliest und die in ihr enthaltenen Worte in einer Warteschlange
speichert. Anschließend werden alle Worte entsprechend ihrer Priorität (= lexikographische Ordnung) wieder der
Warteschlange entnommen.
Benutzen Sie soweit wie möglich die Standardbibliothek.
304
Programmierung II
Aufgabe 4
Implementieren Sie ein Template als Abstraktion “Dateien mit Sätzen fester Länge”. (Templateargument ist der
Satztyp.) Ihre Abstraktion soll eine Cursor–Semantik realisieren. D.h. die Datei kann von Satz zu Satz vorwärts
und rückwärts durchwandert werden. Der Satz an der aktuellen Position kann gelesen, modifiziert und gelöscht
werden. Ausserdem soll es möglich sein einen neuen Record hinzuzufügen.
Aufgabe 5
Implementieren Sie ein Telefonverzeichnis, das seine Daten mit Hilfe der Dateiabstraktion aus Aufgabe 2 verwaltet.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
A
305
STL: wichtige Behälterklassen
Wir fassen hier in einer knappen Übersicht die wichtigsten Operationen einiger zentraler Behälterklassen der STL
zusammen.
A.1
Vektoren
Konstruktoren
vector<T>
vector<T>
vector<T>
vector<T>
v
v(int)
v(int,T)
v(vector<T>)
Defaultkonstruktor
Vektor mit Größe
Vektor mit Größe und Initalwert
Kopierkonstruktor
Elementzugriff
v[i]
v.front()
v.back()
Index
Erster
Letzter
Einfügen
v.push back(T)
v.insert(iter,T)
v.swap(vector<T>)
hinten anhängen
an iter–Position einfügen
Wert mit anderem Vektor tauschen
Entfernen
v.pop back()
v.erase(iter)
v.erase(iter-1,iter-2)
letzten entfernen
Element an iter–Position entfernen
alle im Bereich entfernen
Größe
v.capacity()
v.size()
v.resize(unsigned,T)
v.empty()
maximale Größe
aktuelle Größe
Größe verändern mit Füllwert
ist der Vektor leer?
Iteratoren
v.begin()
v.end()
A.2
Anfang
Ende (hinter dem letzten Element)
Listen
Konstruktoren
list<T> l
list<T> l(list<T>)
Defaultkonstruktor
Kopierkonstruktor
Elementzugriff
l.front()
l.back()
Erster
Letzter
Programmierung II
306
Einfügen und Entfernen
l.push front(T)
l.push back(T)
l.insert(iter,T)
l.swap(list<T>)
l.pop front()
l.pop back()
l.erase(iter)
l.erase(iter-1,iter-2)
l.remove(T)
l.remove if(f)
vorn anhängen
hinten anhängen
an iter–Position einfügen
Wert mit anderer Liste tauschen
ersten entfernen
letzten entfernen
Element an iter–Position entfernen
alle im Bereich entfernen
alle Elemente mit Wert entfernen
alle Werte entfernen auf die Bedingung f zutrifft
Größe
l.size()
l.empty()
aktuelle Größe
ist der Liste leer?
Iteratoren
l.begin()
l.end()
Anfang
Ende (hinter dem letzten Element)
Listenbearbeitung
l.sort()
l.reverse()
l.merge()
A.3
Sortieren
Reihenfolge umkehren
Zwei Listen mischen
Mengen und Multimengen
Konstruktoren
set<T> s
set<T> s(set<T>)
multiset<T> s
multiset<T> s(set<T>)
Defaultkonstruktor
Kopierkonstruktor
Defaultkonstruktor
Kopierkonstruktor
Einfügen und Entfernen
s.insert(T)
s.remove(T)
s.insert(iter,T)
s.swap(set<T>)
s.erase(iter)
s.erase(iter-1,iter-2)
einfügen
entfernen
an iter–Position einfügen
Wert mit anderer Menge tauschen
Element an iter–Position entfernen
alle im Bereich entfernen
Test
s.count(T)
s.find(T)
wie oft enthalten
Position in der Menge (Iterator)
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
Größe
s.size()
s.empty()
aktuelle Größe
leer?
Iteratoren
s.begin()
s.end()
Anfang
Ende (hinter dem letzten Element)
Mengenverarbeitung
s1
s1
s1
s1
s1
s1
== s2
!= s2
<= s2
< s2
>= s2
> s2
A.4
gleich
ungleich
Teilmenge
echte Teilmenge
Obermenge
echte Obermenge
Abbildungen (Sortierte Schlüssel–Wert–Paare)
Konstruktoren
map<S, W> m
map<S, W, f> m
Defaultkonstruktor, S: Schlüsseltyp, W: Werttyp
Defaultkonstruktor, f: Vergleichsfunktion für Schlüssel
Einfügen und Entfernen
m.insert(pair(S,W))
m.erase(S)
m.erase(iter)
Schlüssel–Wert–Paar einfügen
Paar mit Schlüssel S entfernen
Paar an iter–Position entfernen
Test
m.find(S)
m[S]
Position des Paars mit Schlüssel S (Iterator)
Wert zu Schlüssel S
Größe
m.size()
m.max size()
m.empty()
aktuelle Größe
maximale Größe
leer?
Iteratoren
m.begin()
m.end()
Anfang
Ende (hinter dem letzten Element)
Mengenverarbeitung
m1 == m2
m1 != m2
gleich
ungleich
307
Programmierung II
308
Literatur
[1] Bjarne Stoustrup: The C++ Programming Language
Addison-Wesley 1997
Das Werk des Meisters, muss man kennen, wenn auch nicht unbedingt als erstes C++–Lehrbuch lesen
[2] Ulrich Breymann: C++ Einführung und professionelle Programmierung
6. Auflage Hanser 2001
Deutschsprachiges Standardlehrbuch für C++. Umfangreiche Einführung auf Hochschulniveau, enthält eine ausführliche Beschreibung der C++–Bibliothek.
[3] Andrew Koenig, Barabara Moo: Accelerated C++
Addison–Wesley 2000
Kompakte Einführung mit Betonung der STL. Sehr geeignet für einen schnellen Einstieg von C++–Anfängern mit
Programmiererfahrung, als Referenz zu knapp.
[4] Stanley B. Lippman, Josee Lajoie: C++ Primer
Addison–Wesley 1998
Sehr umfangreich, diskutiert alle Feinheiten der Sprache mit vielen Beispielen.
[5] Nocolai Josuttis: The C++ Standard Library
Addison–Wesley 1999
Sehr gute und umfangreiche Beschreibung der Standardbibliothek (inklusive STL).
[6] Coplien, J.O.: Advanced C++ Programming
Addison-Wesley 1992
Der Klassiker zum Entwurf von C++ Programmen. Noch immer lesenswert.
[7] Timothy Budd: Data Structures in C++
Addison-Wesley 1998
Datenstrukturen und Algorithmen mit Hilfe der STL.
[8] Bernd Oestereich: Objektorientierte Softwareentwicklung
Oldenburg, 5–te Auflage 2001
Deutschsprachiges Standardwerk zum Thema seines Titels. Enthält gute Beispiele zu UML.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B
309
Lösungshinweise
B.1
Lösungshinweise zu Kapitel 1
Aufgabe 1
1. Ein Struct ist einfach eine Klasse, in der alles öffentlich ist. Die Definitionen
class Vektor { public: float x, y };
struct Vektor { float x, y; };
sind völlig äquivalent.
2.
struct S
... private: ... ;
ist das Gleiche wie
class S
public: ... private: ...
;
3. h ist eine freie Funktion, g und h sind Methoden von S. Nur in h gelten darum die mit public und
private definierten Zugriffsbeschränkungen. Es ist unerheblich, zu welchem Objekt die Komponenten x
und y jeweils gehören. Entscheidend ist, von wo aus der Zugriff erfolgt: aus einer Methode von S oder aus
irgend einer anderen Methode oder Funktion.
class S {
int y;
public:
int x;
int f (S);
private:
int g (S);
};
int S::f (S ps) {
x
= 1;
y
= 2;
ps.x = 1;
ps.y = 2;
return ps.g(ps)
+ ps.y;
}
int S::g (S ps) {
x
= 1;
y
= 2;
ps.x = 1;
ps.y = 2;
return ps.f(ps)
+ ps.y;
}
int h (S ps) {
ps.x = 1;
ps.y = 2;
// FEHLER
return ps.f(ps)
+ ps.g(ps); // FEHLER
}
4. Jedes Programm, in dem Klassen vorkommen, kann in ein äquivalentes Programm ohne Klassen umgesetzt
werden. Klassen dienen nicht dazu Programme zu formulieren, die anders nicht möglich wären. Sie dienen
dazu Programme mit besserer Struktur zu definieren.
5.
(a) Kann eine Funktion ein Freund einer Klasse sein: Ja!
Programmierung II
310
(b) Kann eine Klasse ein Freund einer Funktion sein: Nein! Funktionen haben nichts Öffentliches oder
Privates, das Konzept der Freundschaft ist für sie sinnlos.
(c) Kann eine Klasse ein Freund einer Klasse sein: Ja!
6. Welche Fehler enthält folgende Klassendefinition. Wie wird das Gewollte korrekt formuliert?
class C {
public:
C (int x)
{
void
incA () {
static void incB () {
private:
int
a = 0; //
static int b = 1; //
const int c;
};
int main () {
C c;
// <- C
c.incA();
c.incB();
// incB
}
c=x; } // <- c ist konstant
++a; }
++b; }
<- So werden Komponenten nicht initialisiert
<- So werden Komponenten nicht initialisiert
hat keinen Defaultkonstruktor
ist static
Korrektur:
class C {
public:
C (int x) : a(0), c(x) {}
// Konstante mit Initialisierer
// initialisieren,
void
incA () { ++a; }
static void incB () { ++b; }
private:
int
a;
// Konstruktoren initialisieren
static int b;
const int c;
};
int C::b = 1;
int main () {
C c(0);
c.incA();
C::incB();
}
// statische Komponenten definieren und initialisieren
// definierten Konstruktor verwenden
// Aufruf statischer Methode
7. In der Methode
float Punkt::entfernt_von (Punkt p) {
return sqrt ( (pos.x - p.pos.x)*(pos.x - p.pos.x)
+
(pos.y - p.pos.y)*(pos.y - p.pos.y));
}
ist der Zugriff auf x und y nicht erlaubt: es sind private Komponenten von Vektor. Es ist dabei unerheblich,
dass der Vektor Bestandteil des Punktes ist.
Zur Korrektur könnte Punkt ein Freund von Vektor sein:
class Vektor {
friend class Punkt;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
311
public:
Vektor ();
...
private:
float x, y;
};
....
oder alternativ öffentliche Methoden abieten, um auf seine Komponenten zuzugreifen.
class Vektor {
public:
Vektor ();
...
float x_koord () const { return x; }
float y_koord () const { return y; }
...
private:
float x, y;
};
...
8. In
class S {
public:
S() : y(y+1) {} // y um 1
˜S() : y(y-1) {} // y um 1
static void f () {
cout << x << ", " << y<<
}
private:
int
x = 1; // x mit
static int y = 0; // y mit
};
erhoehen
reduzieren
endl; // x und y ausgeben
1 initialisieren
0 initialisieren
sollten x und y korrekt initialisiert werden. x muss im Konstruktor initialisiert werden und y muss als Klassenvariable definiert und initialisiert werden. Die Belegung und gleichzeitige Modifikation der statischen
Komponente y im Initialisierer ist Blödsinn. Ein Initialisierer in im Destruktor ist ebenfalls Unsinn. In einer
statischen Methoden kann nicht auf nicht–statische Komponenten zugegriffen werden.
Korrektur:
class S {
public:
S() : x(1) { ++y; } // x initialisieren, y um 1 erhoehen
˜S() {--y;}
// y um 1 reduzieren
static void f () {
cout << y << endl; // y ausgeben (kein Zugriff auf x)
}
private:
int
x;
static int y;
};
int S::y = 0;
// y definieren und mit 0 initialisieren
9. Klassenvariablen müssen ausserhalb der Klasse definiert werden:
Programmierung II
312
class C {
public:
static int i;
};
int C::i = 1;
Sie müssen darum nicht public sein. Es ist also erlaubt zu definieren:
class C {
private:
static int i;
};
int C::i = 1;
das ist kein Widerspruch zur Philosophie von private, denn
int C::i = 1;
gehört zu C. Folgendes ist trotzdem nicht erlaubt:
class C {
private:
static int i;
};
int main () {
int C::i = 1;
...
}
Eine Klassenvariable kann nicht in einer Funktion definiert werden, auch dann nicht, wenn diese main heißt.
10. Keine Lösung.
11. Keine Lösung.
Aufgabe 2
1. Erläutern und begründen Sie jeden Einsatz von const in folgender Definition:
class Menge {
public:
...
Menge operator+ (const Menge &) const;
ˆ
ˆ
|
+- operator+ veraendert 1tes Argument nicht
+-- operator+ veraendert 2tes Argument nicht
...
}
2. Die Klassifikation der Methoden und Datenkomponenten in öffentlich und privat ergibt sich aus der Trennung in Schnittstelle und Implementierung. Die Schnittstelle bietet eine an dem mathematischen Verständnis
von Mengen orientierte rein wertorientierte Sicht. Zur Implementierung gehören die Datenkomponeten und
Hilfsfunktionen.
3. Das erste const in folgender Deklaration
Menge operator+ (const Menge) const;
erklärt einen Wertparameter als const. Das ist Unsinn. Wertparameter werden prinzipiell nicht verändert.
Das zweite const bezieht sich auf das Objekt selbst.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
313
4. Das erste const in
Menge operator+ (const Menge &) const;
ist nicht unsinnig, da es sich um einen Referenzparameter handelt.
5. Die beiden Methoden:
Menge operator+ (Menge) const;
Menge operator+ (const Menge &) const;
unterscheiden sich in der Art der Parameterübergabe. Die zweite Variante ist vorzuziehen, da die Refernzübergabe von komplexen Argumenten wesentlich effizienter ist, als die Wertübergabe. Bei einer Referenzübergabe kann der formale Parameter aber nicht als lokale Variable der Funktion/Methode verwendet
werden.
6. Vollständige Definition der Klasse Menge:
class Menge {
public:
Menge ();
// leere Menge
Menge (int);
// ein-elementige Menge
Menge operator+ (const Menge &) const; // Vereinigung
Menge operator* (const Menge &) const; // Schnitt
bool
istEnthalten (int i) const;
// ist Element
private:
void fuegeEin (int i);
void entferne (int i);
int m[10];
int a;
};
Menge::Menge () {
a = -1;
}
// Konstr. leere Menge
Menge::Menge (int i) { //Konstr. ein-elementige Menge
a = 0;
m[0] = i;
}
void Menge::fuegeEin (int i) {
if (!istEnthalten (i))
if (a < 9) {
a++;
m[a] = i;
}
}
void Menge::entferne (int i) {
for (int j = 0; j <= a; j++)
if (m[j] == i) {
for (int k=j; k<a; k++)
m[k] = m[k+1];
a--;
return;
}
}
bool Menge::istEnthalten (int i) const {
for (int j = 0; j <=a; j++)
if (m[j] == i) return true;
Programmierung II
314
return false;
}
Menge Menge::operator+ (const Menge &s) const {
Menge res;
for (int j = 0; j <= a; j++)
res.fuegeEin (m[j]);
for (int j = 0; j <= s.a; j++)
res.fuegeEin (s.m[j]);
return res;
}
Menge Menge::operator* (const Menge &s) const {
Menge res;
for (int j = 0; j <= a; j++)
if (s.istEnthalten (m[j]))
res.fuegeEin (m[j]);
return res;
}
7. Vereinigung und Schnitt als statische Methoden:
class Menge {
public:
public:
Menge ();
// leere Menge
Menge (int);
// ein-elementige Menge
Menge vereinigung (const Menge &, const Menge &);
Menge schnitt (const Menge &, const Menge &);
bool
istEnthalten (int i) const;
// ist Element
private:
void fuegeEin (int i);
void entferne (int i);
int m[10];
int a;
};
...
Menge Menge::vereinigung (const Menge & t, const Menge & s) {
Menge res;
for (int j = 0; j <= a; j++)
res.fuegeEin (t.m[j]);
for (int j = 0; j <= s.a; j++)
res.fuegeEin (s.m[j]);
return res;
}
Menge Menge::schnitt (const Menge &t, const Menge & s) {
Menge res;
for (int j = 0; j <= a; j++)
if (s.istEnthalten (t.m[j]))
res.fuegeEin (t.m[j]);
return res;
}
8. Die Ausgabe von Mengen in drei Varianten:
(a) Eine Ausgabe–Methode:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
315
class Menge {
public
....
void print () { ... }
};
(b) Eine freie Ausgabe–Funktion:
class Menge {
friend void print (Menge &);
....
};
void print (Menge &m) { ... }
(c) Eine statische Ausgabe–Methode:
class Menge {
public
....
static void print (const Menge & m) { ... }
};
9.
class Menge {
friend Menge operator+ (const Menge &, const Menge &); // Vereinigung
friend Menge operator* (const Menge &, const Menge &); // Schnitt
public:
...
private:
...
};
Menge operator+ (const Menge & m1, const Menge & m2) {
Menge res;
for (int j = 0; j <= m1.a; j++)
res.fuegeEin (m1.m[j]);
for (int j = 0; j <= m2.a; j++)
res.fuegeEin (m2.m[j]);
return res;
}
.. operator* entsprechend ..
Aufgabe 3
Konten haben einen Besitzer, einen Wert und einen Zinssatz. Der Zinssatz ist für alle Konten gleich: 3 Prozent für
Guthaben, 5 Prozent für Kredite (= negative Guthaben). Von einem Konto zum anderen können beliebige Beträge
überwiesen werden. Das Guthaben bzw. der Kredit eines Kontos kann für einen bestimmten Zeitraum verzinst
werden; das Guthaben wird dabei entsprechend verändert.
Diese Beschreibung eines Kontos hat zwei Aspekte:
Welche Attribute (Eigenschaften) hat ein Konto?
Was kann man mit einem Konto machen?
Die Attribute sind: Besitzer, Wert und Zinssatz. Der Zinssatz richtet sich danach, ob es sich um ein Kredit– oder
Guthabenkonto handlet. Der Zinsatz muss sich jeweils an den Kontostand anpassen. Die Attribute des Kontos
werden einfach zu Datenkomponenten:
class Konto {
public:
Konto (Besitzer b) : wert(0), zinssatz(3) {}
Programmierung II
316
...
private:
Besitzer besitzer;
int
wert;
int
zinssatz;
};
Was man mit einem Konto machen kann bestimmt die öffentlichen Methoden und freien Funktionen. Der Defaultkonstruktor repräsentiert das Anlegen eines Kontos. Dabei muss der Besitzer angegeben werden. Es muss möglich
sein Beträge einzuzahlen und abzuheben. Dabei muss der Zinssatz aktualisiert werden. Der Zins wird nach dem
aktuellen Zinssatz berechnet:
class Konto {
public:
Konto (Besitzer b) : wert(0), zinssatz(3) {}
void ein (unsigned int betrag) {
wert = wert + betrag;
if (betrag >= 0) zinssatz = 3;
}
void aus (unsigned int betrag) {
wert = wert - betrag;
if (betrag < 0) zinssatz = -5;
}
void verzinse (unsigned int tage) {
wert = int((float(wert) * zinssatz / 100.0) * tage / 365.0);
}
private:
Besitzer besitzer;
int
wert;
int
zinssatz;
};
Überweisungen können als freie Funktion definiert werden:
void ueberweise (Konto & von, Konto & nach, unsigned int betrag) {
von.aus(betrag);
nach.ein(betrag);
}
Aufgabe 4
1. Was versteht man unter einem Konstruktor und was ist der Default–Konstruktor?
Ein Konstruktor ist eine Initialisierungsfunktion die automatisch aktiviert wird, wenn ein Objekt vom entsprechenden Typ angelegt wird. Der Default–Konstruktor wird aufgerufen, wenn es keine Hinweise des
Programmieres gibt, die den aufruf eines anderen Konstruktor fordern. Z.B. keine Parameter bei einer Variablendefinition.
2. Stimmt es, dass ...
(a) eine Klasse immer mindestens / genau / höchstens einen Konstruktor haben muss?
Nein, stimmt nicht.
(b) eine Klasse immer einen Default–Konstruktor haben muss?
Nein, stimmt nicht.
(c) der Compiler einen Default–Konstruktor erzeugt, wenn für eine Klasse keiner definiert wurde?
Nein, stimmt nicht.
(d) Objekte immer mit einem Konstruktor initialisiert werden?
Nein, stimmt nicht. Wenn eine Klasse keinen Konstruktor hat, dann wird auch keiner aktiviert.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
317
(e) Objekte, deren Klasse mindestens einen Konstruktor hat, immer mit einem Konstruktor initialisiert
werden?
Ja, das stimmt. Wenn mindestens ein Konstruktor definiert wurde, dann wird jedes Objekt mit einem
Konstruktor initialisiert – wenn es keinen passenden gibt dann ist das Programm fehlerhaft.
(f) Klasssen mit Konstruktor auch einen Destruktor haben müssen?
Nein, stimmt nicht.
(g) Klassen mit Destruktor auch einen Konstruktor haben müssen? Nein, stimmt nicht.
3. Nur Konstruktoren dürfen Initialisierer enthalten?
4. Konstante datenkomponenten und Komponenten ohne Defaultkonstruktor müssen mit einem Initialisierer
initialisiert werden.
5. In
class A {
public:
A(int p) : x(p) {}
private:
int x;
};
class B {
public:
B(A a) { y = a; } // <--FALSCH Korrekt: B(A a) :y(a) {}
private:
A y;
};
hat A keinen Defaultkonstruktor. Eine A–Komponente muss mit einem Initialisier belegt werden.
In
class A {
public:
A(int p) : x(p) {}
private:
int x;
};
class B {
public:
B(A a) : y(a) {}
private:
A y;
};
hat A gar keinen Konstruktor, eine Inititalisierung wird vom System nicht versucht und der Defaultkonstruktor nicht vermisst.
Eine globale Variablendefinition für eine Variable b vom Typ B belegt die A–Komponente mit 0, eine loakle
Variablendefinition lässt es uninitialisiert.
6. Das Programmstück
class X {
public:
X () : f(0.0) {}
float f;
};
class C {
Programmierung II
318
public:
X
float
};
a;
b;
int main () {
C c;
}
ist korrekt. Der Konstruktor von X wird aktiviert, obwohl C keinen Konstruktor hat.
7. Nicht korrekt ist:
class X {
public:
X () : f(0.0) {}
float f;
};
class C {
public:
C (X x) : a(x) {}
X
a;
float
b;
};
int main () {
C c;
}
C hat eiunen Konstruktor, also muss der in main implizit aufgerufe Defaultkonstruktor exisitieren!
8. In der vorletzten Aufgabe haben wir den seltenen Fall eines Compiler–erzeugten Default–Konstruktors.
9. Eine Klasse erklärt eine andere zum Freund. Dies hat keinerlei Auswirkung auf die Ausführung der Konstruktoren?
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.2
319
Lösungshinweise zu Kapitel 2
Aufgabe 1
1. Konversionen von einem l–Wert in einen r–Wert finden immer dann statt, wenn ein l-Wert an Stelle eines
r-Werts benutzt wird. Konversionen von einem r–Wert in einen l–Wert sind im Allegemeinen nicht möglich.
Sie treten aber im Beispiel beim Aufruf der Funktion f auf: in folgendem Programm statt:
...
f (i, j) = f (i+1, // <<== r-Wert -> l-Wert
j) + g (i+2, j);
...
2. Das folgende Programm ist korrekt.
#include <iostream>
int
a = 10;
int & b = a;
int
c = a;
int & f (int &b) {
b++;
return b;
}
using namespace std;
int main () {
int &x = c;
x = a;
f(c) = f(c) + f(x);
cout << f(b)++ << endl;
// Ausgabe 11 (Postinkrement-Operator !)
cout << f(b) + f(b) << endl; // Ausgabe 28 (= 14 + 14 !!)
}
Der Wert des 2-ten Ausdrucks hängt von der Auswertungsstrategie des Compilers ab und kann auch 27
(13+14) sein.
3. Was ist alles falsch an folgender Definition eines Zuweisungsoperators:
class X {
public:
...
X & operator= (const X x) {
X res;
res.i = x.i;
return res;
<<== kein Referenzparameter
<<== sollte Zuweisung an sich selbst pruefen
<<== sollte *this zurueck geben
<<== sollte *this zurueck geben
}
private:
int i;
};
4. Der Zuweisungsoperator ist rechts–asoziativ, die am weitesten rechts stehende Zuweisung wird zuerst ausgeführt und a=b=c entspricht a=(b=c). Die beiden geklammerten Formen sind erlaubt. Bei (a=b)=c;
wird zuerst b an und dann c a zugewiesen. Bei a=(b=c) wird zuerst c an b und dann b an a zugewiesen.
5. Der Zuweisungsoperator liefert eine Referenz als Ergebnis, damit die Zuweisung ein zuweisbares Ergebnis
hat, also beispielsweise die Konstruktion (a=b)=c; möglich wird.
Programmierung II
320
6. Wenn der Zuweisungsoperator als Ergebnis eine konstante Referenz liefert, dann sind Zuweisungen an das
Ergebnis einer Zuweisung nicht möglich:
class C {
...
const C & operator= (const C &);
...
};
int main () {
C x, y, z;
x = y;
// OK
x = y = z;
// OK
x = (y = z); // OK
(x = y) = z; // FEHLER
}
Aufgabe 2
#include <iostream>
using namespace std;
class Bruch {
public:
Bruch ();
Bruch (int);
Bruch (int, int);
Bruch (const Bruch &);
//
//
//
//
Default-Konstruktor: 0 als Bruch
ganze Zahl als Bruch
Zaehler, Nenner
Kopierkonstruktor
char vorzeichen () const;
int zaehler () const;
int nenner () const;
Bruch & operator= (const Bruch &); // Zuweisung
Bruch operator+ (const Bruch &) const;
// Addition
Bruch operator- (const Bruch &) const;
// Subtraktion
Bruch operator- () const;
// unaere Subtraktion
private:
enum Vorzeichen {plus, minus};
Vorzeichen vz;
int
z;
int
n;
static int
static int
kgv
ggt
(int, int);
(int, int);
static void
static void
static void
kuerze
(Bruch &);
erweitere
(Bruch &, int);
gleichnamig (Bruch &, Bruch &);
};
// I/O--Operatoren als freie Funktionen:
ostream & operator<< (ostream &, const Bruch &); // Ausgabe
istream & operator>> (istream &, Bruch &);
// Eingabe
//Freie Operatoren
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
321
// Unaeres Minus fuer Brueche:
Bruch operator- (const Bruch &);
// Operationen mit gemischten Operanden:
Bruch operator+ (int,
const Bruch &);
Bruch operator+ (const Bruch &, int);
Bruch operator- (int,
const Bruch &);
Bruch operator- (const Bruch &, int);
//------------------------------------------------------------------int main () {
Bruch b0,
b1(2),
b2(2,3),
b3 = -Bruch (4,6);
//
//
//
//
b0
b1
b2
b3
=
=
=
=
0
2
2/3
-(4/6)
b0 = 4;
// implizite Konversion int->Bruch
// Gemischte Typen, int und Bruch:
b3 = Bruch(5,7) + (2 + (b1 - b2) + 3) - b0;
cout << b3 << endl;
}
/*------------------------------------------------------------------I/O : Freie Funktionen
*/
istream & operator>> (istream &is, Bruch &b) {
char vz,
bruchstrich;
int z;
int n;
is >> vz;
is >> z;
is >> bruchstrich;
is >> n;
if ( vz == ’+’ )
b = Bruch (z, n);
else
b = - Bruch (z, n);
return is;
}
ostream & operator<< (ostream &os, const Bruch &b) {
os << b.vorzeichen() << b.zaehler() << "/" << b.nenner();
return os;
}
/* ------------------------------------------------------------------Oeffentliche Methoden von Bruch
*/
Bruch::Bruch()
: z(0), n(1) {}
Bruch::Bruch(int i)
: z(i), n(1) {} // dient auch als Konversionsfunktion
Bruch::Bruch(int i, int j) : z(i), n(j) { kuerze (*this); }
// Kopierkonstruktor:
322
Programmierung II
Bruch::Bruch (const Bruch &b) {
vz = b.vz;
n = b.n;
z = b.z;
}
char Bruch::vorzeichen () const {if (vz == plus) return ’+’; else return ’-’; }
int Bruch::zaehler
() const { return z; }
int Bruch::nenner
() const { return n; }
// Zuweisungsoperator
Bruch & Bruch::operator= (const Bruch &b) {
if (&b != this) {
vz = b.vz;
n = b.n;
z = b.z;
}
return *this;
}
// binaerer minus-Operator fuer Brueche
Bruch Bruch::operator- (const Bruch &y) const {
Bruch t = y;
if (t.vz == minus) t.vz = plus;
else
t.vz = plus;
return *this + t;
}
// binaerer Additionsoperator
Bruch Bruch::operator+ (const Bruch &y) const {
Bruch res;
Bruch t1 = y;
// Hilfsvariablen um die Veranderung der
Bruch t2 = *this;
// Argumente von + zu verhinderen
gleichnamig (t1, t2);
res.n = t1.n;
if (t1.vz == t2.vz) { // gleiche Vorzeichen
res.vz = t1.vz;
res.z = t1.z + t2.z;
} else {
// unterschiedliche Vorzeichen
if (t1.z > t2.z) {
res.vz = t1.vz;
res.z = t1.z - t2.z;
} else {
res.vz = t2.vz;
res.z = t2.z - t1.z;
}
}
kuerze (res);
return res;
}
/* ------------------------------------------------------------------Freie Bruch-Funktionen
(das unaere Minus kann nicht als Methode definiert werden.)
*/
Bruch operator- (const Bruch &b) {
return 0 - b;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
323
}
Bruch
Bruch
Bruch
Bruch
operator+
operator+
operatoroperator-
(int i, const Bruch &b)
(const Bruch &b, int i)
(int i, const Bruch &b)
(const Bruch &b, int i)
{
{
{
{
return
return
return
return
Bruch (i)
b + Bruch
Bruch (i)
b - Bruch
+ b;
(i);
- b;
(i);
}
}
}
}
/* ------------------------------------------------------------------Private Methoden von Bruch
*/
int Bruch::ggt (int x, int y) {
while (x != y) {
if (x > y) x = x-y;
if (y > x) y = y-x;
}
return x;
}
void Bruch::kuerze (Bruch &b) {
if (b.z == 0) return;
int f = ggt (b.z, b.n);
b.z = b.z / f;
b.n = b.n / f;
}
int Bruch::kgv (int x, int y) {
return (x*y/ggt(x,y));
}
void Bruch::erweitere (Bruch &b, int f) {
b.z = b.z*f;
b.n = b.n*f;
}
void Bruch::gleichnamig (Bruch &b1, Bruch &b2) {
int n = kgv (b1.n, b2.n);
erweitere (b1, n/b1.n);
erweitere (b2, n/b2.n);
b1.n = n;
b2.n = n;
}
Aufgabe 3
1. Ein Stapel als zustandsorientierten Datentyp ist eine einfache Stapelversion, die keinen Kopierkonstruktor
und keinen Zuweisungsoperator benötigt mit Methoden die den Zustand verändern:
class Stapel {
public:
Stapel();
void push (int x);
void pop ();
int top ();
private:
...
};
2. Objekte wertorientierte Klassen werden wie die Werte vordefinierter Datentypen im Programm manipuliert.
Programmierung II
324
Die Klassen müssen alle dazu notwendigen Mechanismen definieren. Bei zustandsorientierten Klassen ist
dies meist nicht notwendig, da sie nur in eingeschränkter Form verwendet werden: Variablen anlegen und
mit den Methoden der Klasse auf sie zugreifen. Keine Zuweisung, keine Wertübergabe.
3. Ein Stapel als zustandsorientierter konkreter Datentyp benötigt einen funktionierenden Koierkonstruktor und
Zuweisungsoperator. Dies können jeweils die vordefinierten sein, deren korrektes Funktionieren ist aber zu
überprüfen.
Aufgabe 4
1. Kein Hinweis.
2. Es handelt sich um eine wertorientierte Klasse.
3.
class Menge {
public:
Menge ()
{ ... ++z; ... }
Menge (int) { ... ++z; ... }
˜Menge ()
{ --z; }
Menge ( const Menge &m) { ... ++z; ... }
Menge operator+ (Menge) const;
Menge operator* (Menge) const;
bool
istEnthalten (int i) const;
static int anzahl() { return z; }
private:
void fuegeEin (int i);
void entferne (int i);
int m[10];
int a;
static int z;
};
int Menge::z = 0;
Der Kopierkonstruktor muss jetzt definiert werden. Erzeugen durch Kopieren ist ein Erzeugen, mit dem die
Zahl der Objekt sich erhöht.
4.
class Menge {
friend ostream & operator<<
friend istream & operator>>
...
};
ostream & operator<< (ostream
...
}
istream & operator>> (istream
...
}
(ostream &, const Menge &);
(istream &, Menge &);
&os, const Menge &m) {
&is, Menge &m) {
Aufgabe 5
Bei der Ausführung des Zuweisungsoperator (im Gegensatz zu der des Kopierkonstruktors) ändert sich die Zahl
der Objekte nicht.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.3
325
Lösungshinweise zu Kapitel 3
Aufgabe 1
Geben Sie zu den folgenden graphisch dargestellten Situationen geeignete Definitionen und Anweisungen an, die
zu dieser Situation führen.
1.
int
x
y
int * p
* q
* t
2.
int
3.
int
x
y
int * p
** q
* t
=
=
=
=
=
0,
1;
&x,
&y,
q;
=
=
=
=
=
0,
1;
&x,
&p,
&y;
x = 1,
y = 2,
z = 3,
t = 4;
int * p = &y,
* q = &z,
* r = &t,
* o = &x;
1. t = x; ist in keiner der drei Situationen erlaubt da t und x jeweils unterschiedliche Typen haben.
2. *t = x;
(a)
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
v
V V
|
|
|
+---+ +---+
+-|-+
+-|-+
+-|-+
| 0 | | 1 |
| + |
| + |
| + |
+---+ +---+
+---+
+---+
+---+
x
y
p
q
t
*t = x; führt zu:
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
v
V V
|
|
|
+---+ +---+
+-|-+
+-|-+
+-|-+
| 0 | | 0 |
| + |
| + |
| + |
+---+ +---+
+---+
+---+
+---+
x
y
p
q
t
(b)
+---------------+
|
+--------|------------------+
v
V
|
q
|
+---+ +---+
+-|-+
+---+
+-|-+
| 0 | | 1 |
| + |<-+ | + |
| + |
+---+ +---+
+---+ | +-|-+
+---+
x
y
p
+----+
t
*t = x; belegt y mit 0.
Programmierung II
326
(c)
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +---+
x
p
y
q
z
r
t
o
*t = x; nicht erlaubt.
3. t = *x; ist in keiner der drei Situationen erlaubt: x ist kein Zeiger.
4. *t = *x; ist in keiner der drei Situationen erlaubt: x ist kein Zeiger.
5. *p = *q;
(a)
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
v
V V
|
|
|
+---+ +---+
+-|-+
+-|-+
+-|-+
| 0 | | 1 |
| + |
| + |
| + |
+---+ +---+
+---+
+---+
+---+
x
y
p
q
t
*p = *q; führt zu:
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
v
V V
|
|
|
+---+ +---+
+-|-+
+-|-+
+-|-+
| 1 | | 1 |
| + |
| + |
| + |
+---+ +---+
+---+
+---+
+---+
x
y
p
q
t
(b)
+---------------+
|
+--------|------------------+
v
V
|
q
|
+---+ +---+
+-|-+
+---+
+-|-+
| 0 | | 1 |
| + |<-+ | + |
| + |
+---+ +---+
+---+ | +-|-+
+---+
x
y
p
+----+
t
*p = *q; ist nicht erlaubt: p und q haben nicht den gleichen Typ.
(c)
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +---+
x
p
y
q
z
r
t
o
*p = *q; führt zu:
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 3 | | +----->| 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +---+
x
p
y
q
z
r
t
o
6. p = &x;
(a)
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
v
+---+
| 0 |
+---+
x
V V
+---+
| 1 |
+---+
y
|
+-|-+
| + |
+---+
p
|
+-|-+
| + |
+---+
q
327
|
+-|-+
| + |
+---+
t
p = &x; hat keine Wirkung, p zeigt bereits auf x.
(b)
+---------------+
|
+--------|------------------+
v
V
|
q
|
+---+ +---+
+-|-+
+---+
+-|-+
| 0 | | 1 |
| + |<-+ | + |
| + |
+---+ +---+
+---+ | +-|-+
+---+
x
y
p
+----+
t
p = &x; hat keine Wirkung, p zeigt bereits auf x.
(c)
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +---+
x
p
y
q
z
r
t
o
p = &x; führt zu:
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
+->| 1 | | +---+ | 2 | | +----->| 3 | | +------>| 4 | | + |
| +---+ +---+ | +---+ +---+
+---+ +---+
+---+ +---+
|
x
p
|
y
q
z
r
t
o
+--------------+
7. q = &t;
(a)
+---------------+
|
+---------|---------+
|
| +-------|---------|--------+
v
V V
|
|
|
+---+ +---+
+-|-+
+-|-+
+-|-+
| 0 | | 1 |
| + |
| + |
| + |
+---+ +---+
+---+
+---+
+---+
x
y
p
q
t
q = &t; ist nicht erlaubt.
(b)
+---------------+
|
+--------|------------------+
v
V
|
q
|
+---+ +---+
+-|-+
+---+
+-|-+
| 0 | | 1 |
| + |<-+ | + |
| + |
+---+ +---+
+---+ | +-|-+
+---+
x
y
p
+----+
t
q = &t; führt zu:
+---------------+
|
+--------|------------------+
v
V
|
q
|
+---+ +---+
+-|-+
+---+
+-|-+
| 0 | | 1 |
| + |
| + | +->| + |
+---+ +---+
+---+
+-|-+ | +---+
x
y
p
+---+
t
Programmierung II
328
(c)
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +---+
x
p
y
q
z
r
t
o
q = &t; führt zu:
+---------------------------------------------------+
V
|
+---+ +---+
+---+ +---+
+---+ +---+
+---+ +-|-+
| 1 | | +----->| 2 | | +---+ | 3 | | +------>| 4 | | + |
+---+ +---+
+---+ +---+ | +---+ +---+ +->+---+ +---+
x
p
y
q
|
z
r
|
t
o
+---------------+
Aufgabe 2
1. Überprüfen Sie Ihr Diagramm mit Hilfe des Debuggers!
2. Die Anweisung
(*((*(b.n)).n)).v = 125;
im angegebenen Programm entspricht:
b.n->n->v = 125;
Aufgabe 3
1. i habe den Typ int, und p den Typ int *. Welcher der folgenden Ausdrücke ist korrekt bzw. nicht
korrekt. Geben Sie zu den korrekten Ausdrücken den Typ an.
(a) i + 1
OK, int
(b) *P
Falsch (P nicht definiert)
(c) &p
OK, int **
(d) **(&p)
OK, int
(e) &i == p
OK, bool (2 Werte vom Typ int * werden verglichen)
(f) i == *p
OK, bool (2 Werte vom Typ int werden verglichen)
(g) *p + i > i
OK, bool (2 Werte vom Typ int werden verglichen)
2. Definieren Sie folgende Variablen und Typen:
(a) Ein Feld von Zeigern auf float–Variablen:
float *a[10];
(b) Eine Funktion die einen Zeiger auf eine float–Variable als Argument hat und einen Zeiger auf eine
int–Variable liefert.:
int * f(float *);
(c) Ein Feld von Zeigern auf Funktionen die einen Zeiger auf float–Variablen als Argument und Ergebnis haben:
float * (*a[10])(float *);
etwas übersichtlicher:
typedef float * PF(float *);
PF * a[10];
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
329
(d) Ein Feld von 10 Verbunden (structs) die einen Zeiger auf int und eine Methode als Komponenten
haben. Die Methode soll Zeiger auf float in Zeiger auf int abbilden:
struct S
int *p; int * m(float *); ;
S a[10];
(e) Eine Variable, die einen Zeiger auf ein float–Objekt enthält:
float * p;
(f) Eine Variable, die einen Zeiger auf eine Variable enthält, deren Inhalt auf ein float–Objekt zeigt:
float ** p;
(g) Den Typ der Zeiger auf float–Objekte:
typedef float * P;
(h) Den Typ S wobei jedes Objekt vom Typ S aus einem int und einem Zeiger auf ein bool, sowie
einem Zeiger auf ein S Objekt besteht: struct S
int x; bool * y; S * z; ;
(i) Eine Variable die aus 10 S Objekten besteht:
S a[10];
(j) Eine Variable die aus 10 Zeigern auf S Objekte besteht:
S * a[10];
(k) Den Typ den ein Feld von Zeigern auf float–Variablen hat:
typedef float * A[10];
(l) Den Typ den eine Funktion hat, die einen Zeiger auf eine float–Variable als Argument hat und einen
Zeiger auf eine int–Variable liefert:
typedef int * F(float *);
3. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die p[i] mit der Adresse
von a[i] belegt (i = 0..9):
for ( int i=0; i<10; ++i)
p[i] = &a[i];
4. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die a[i] mit dem Wert
dessen belegt, auf das p[i] zeigt (i = 0..9):
for ( int i=0; i<10; ++i)
a[i] = *p[i];
5. Der Adressoperator & wird auf l–Werte angewendet und hat einen r–Wert als Ergebnis.
6. Der Dereferenzierungsoperator * wird auf r–Werte angewendet und hat einen l–Wert als Ergebnis.
7. Experimentieren Sie, benutzen Sie Compiler und eventuell Debugger!
Aufgabe 4
Kein Lösungshinweis.
Aufgabe 5
1. Mit new erzeugte Variablen überleben das Ende der Funktion, in der sie erzeugt wurden. Lokale Variablen
leben dagegen nur solange wie die Funktion zu der sie gehören. Desshalb ist
int * get_int1 () {
int * p;
p = new int;
return p;
}
Programmierung II
330
OK. Die Funktion
int * get_int2 () {
int i;
int p = &i;
return p;
}
liefert dagegen einen Verweis auf eine “tote” Variable.
2. Nach
int * p;
p = new int;
*p = 17;
...
delete p;
zeigt p auf eine “zerstörte” Variable. Achtung delete p belegt p nicht mit = sondern führt dazu, dass es
einen ungültigen Zeiger als Wert hat.
3.
p = 0;
delete p;
ist unsinnig, da p nach der Zuweisung den Nullzeiger enthält und delete 0 ist ohne Wirkung. (Das, auf
das p vor der Zuweisung zeigte, wird hier nicht freigegeben.) Die umgekehrte Reihenfolge ist sinnvoll: erst
das vernichten, auf das der Zeiger zeigt, dann den Zeiger auf 0 setzten.
4. Eine Variable im Heap ist immer anonym (namenlos).
5. In
struct S {
int a;
S * l;
};
int main () {
S *x, *y;
x = new S;
y = new S;
x->a = 0;
y->a = 1;
x->l = y;
y->l = 0;
...
}
sind x und y im Stack. Die Objekte, auf die sie zeigen, sind im Heap. Die erzeugte Struktur ist:
(0,-)--->(1,0)
ˆ
ˆ
|
|
x
y
6. Nach
struct S {
int a;
S * l;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
331
};
int main () {
S * r = 0;
S * l = 0;
for (int i = 0; i< 5; i++) {
S * n = new S;
if (i == 0) {
n->a = 1;
r = n;
} else {
n->a = l->a * i;
l->l = n;
}
l = n;
n->l = 0;
}
...
zeigt r auf eine Kette von 5 Structs vom Typ S mit den a–Komponenten 1, 1, 2, 6, 24. l zeigt auf das letzte
Element dieser Kette.
7. Eine Ausgabeschleife, welche die Datenfelder der verketteten Verbunde in der Reihenfolge ihrer Verkettung
ausgibt:
for ( S* p=r; p!=0; p=p->l)
cout << p->a << endl;
8. Ein Programm in dem eine Zahlenfolge bis zur Eingabe von 0 eingelesen und in umgekehrter Reihenfolge
in verketteten Verbunden im Heap abgelegt wird:
struct S {
int a;
S * l;
};
int main () {
S * p = 0;
do {
int x;
cin >> x;
if ( x == 0 ) break;
S * n = new S;
n->a = x;
n->l = p;
p = n;
} while (true);
}
Aufgabe 6
1. Die Struktur
A-->B-->C-->D-->E-->F-->G
wird beispielsweise erzeugt durch:
Programmierung II
332
struct S {
char a;
S *
l;
};
int main () {
S * p = 0;
char c = ’H’;
do {
--c;
S * n = new S;
n->a = c;
n->l = p;
p = n;
} while (c != ’A’);
for ( S* r=p; r!=0; r=r->l)
cout << r->a << endl;
}
2. Die Struktur
1-->2-->3-->4-->5-->6-->7
|
|
|
|
|
|
|
V
V
V
V
V
V
V
A
B
C
D
E
F
G
kann ähnlich wie die letzte erezugt werden:
struct S {
int
a;
char *b;
S *
l;
};
int main () {
S * p = 0;
char c = ’H’;
int i = 8;
do {
--i;
S * n = new S;
n->a = i;
--c;
n->b = new char;
*(n->b) = c;
n->l = p;
p = n;
} while (i != 1);
}
3. Die Erzeugung der Struktur
1-->2-->3-->4-->5-->6-->7--8
ˆ
|
|
|
+--------------------------+
sollte kein Problem bereiten. Folgende Lösung ist möglich:
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
struct S {
int
a;
S *
l;
};
int main () {
S * p = 0;
int i = 8;
do {
--i;
S * n = new S;
n->a = i;
n->l = p;
p = n;
} while (i != 1);
// Das letzte Element mit dem ersten verketten
S * last = p;
while (last->l != 0)
last = last->l;
last->l = p;
}
4. Die Struktur
1-->2-->3-->4-->5-->6-->7
|
|
|
|
|
|
|
V
V
V
V
V
V
V
A-->B-->C-->D-->E-->F-->G
kann erzeugt werden durch ein Programm wie:
struct S {
char a;
S *
l;
};
struct T {
int
a;
S *
b;
T *
l;
};
int main () {
S * p = 0;
T * q = 0;
char c = ’H’;
int i = 8;
do {
--c; --i;
S * n = new S;
T * m = new T;
n->a = c; m->a = i; m->b = n;
n->l = p; m->l = q;
p = n;
q = m;
} while (c != ’A’);
}
333
Programmierung II
334
Aufgabe 7
Kein Lösungshinweis.
Aufgabe 8
1. Das Programm:
#include <iostream.h>
#include <string>
using namespace std;
void assign_1 (int *x, int *y) {
x = y;
}
void assign_2 (int * &x, int * &y) {
x = y;
}
int main () {
int
a[10] = {90,90,90,90,90,90,90,90,90,90};
int * p = new int[10];
for (int i=0; i<10; i++) p[i] = i;
assign_1 (a, p);
for (int i=0; i<10; i++)
cout << a[i] << " , " << p[i] << endl;
assign_2 (a, p);
// FEHLER R-Wert als Argument fuer nicht-CONST L-Wert
for (int i=0; i<10; i++)
cout << a[i] << " , " << p[i] << endl;
p = a;
for (int i=0; i<10; i++)
cout << a[i] << " , " << p[i] << endl;
}
enthält einen Fehler: den Aufruf
assign 2 (a, p);
An einen Parameter vom Typ int * & kann kein int * übergeben werden: Der formale Parameter ist
keine konstante Referenz. Damit die Übergabe hätte mit folgender Definition funktioniert:
void assign 2 (int * const & x, int * &y);
Damit wäre aber natürlich die Zuweisung innerhalb von assign 2 nicht möglich.
2. a = p; ist nicht erlaubt: Felder sind zwar Zeiger, Zuweisungen an Felder sind nicht erlaubt.
3. Wird ein mit new T[N] angelegtes Feld statt mit delete[] mit delete freigegeben, dann wird nicht
der Bereich des ganzen Felder freigegeben, sondern nur das erste Element.
4. delete 0; ist ohne Wirkung.
5. Objekte auf dem Stack werden automatisch bei Funktionsende freigegeben. Ein Versuch sie selbst explizit
freizugeben wie in
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
335
int main() {
int x;
delete &x; // ABSTURZ
}
führt (hoffentlich) zum unmittelbaren Absturz des Programms.
6. Mit
int *p = new int [10]; // 10 ints anlegen
wird p mit einem Zeiger auf ein Feld von 10 Ints belegt. Mit
int *p = new int (10); // 1 int anlegen, mit 10 initialisieren
wird p mit einem Zeiger auf eine Int–Variable belegt, die mit 0 initialisiert wurde.
7. Mit
int * const &p
wird der Wert von p als konstant erklärt. Mit
const int * &q
wird das, auf das q zeigt als konstant erklärt.
8.
void f (const char * x) {
cout << x << endl;
}
int main () {
char *p = new char [10];
const char *q = "Charlotte";
p[0] = ’X’;
p[1] = 0;
f (p);
f (q);
f ("Hugo");
}
Aufgabe 9
Der Destruktor einer Klasse C ist dafür verantwortlich ...
1. FALSCH: ... alle Objekte der Klasse C wegzuräumen.
2. FALSCH: ... Objekte der Klasse C im Heap wegzuräumen.
3. FALSCH: ... alle Komponenten von Objekten der Klasse C wegzuräumen.
4. FALSCH: ... Komponenten von Objekten der Klasse C, die im Heap liegen, wegzuräumen.
5. FALSCH: delete ist nichts anderes, als eine spezielle Art den Destruktor aufzurufen: p sei vom Typ S*,
dann ist delete p; das Gleiche wie (*p). S()
Der Destruktor ist dafür verantwortlich, dass die notwendigen Aufräumarbeiten vor dem Wegräumen des Objektes
erledigt werden. Das Wegräumen erledigt delete bzw. der Stack–Mechanismus.
Aufgabe 10
Die Ausgabe des gegebenen Programms lässt sich experimentell überprüfen.
336
Programmierung II
Aufgabe 11
Was ist alles falsch an folgender Klassendefinition:
class S {
public:
S ()
: v(0), l(0) {}
S (int i, S *pl) : v(i), l(pl) {}
S (const S &ps) {
delete l;
// Kein delete im Konstruktor!
l = 0;
v = ps.v;
for ( S *p = ps.l; p != 0; p=p->l)
// Verkettung wird invertiert
l = new S(p->v, l);
}
S & operator= (const S &ps) : l(0), v(ps->v) { // Initialisierer nur in Komstruktoren
for ( S *p = ps.l; p != 0; p=p->l)
// kein Test auf Selbstzuweisung
l = new S(p->v, l);
// Kein delete auf l
return *this;
// Verkettung wird invertiert
}
private:
int v;
S
*l;
};
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.4
337
Lösungshinweise zu Kapitel 4
Aufgabe 1
1. const–Fehler werden vom Compiler durch Analyse des Quellprogramms aufgedeckt. Es handelt sich immer um Verstöße des Programms an einer Stelle gegen Beschränkungen, die an anderer Stelle im Programm
festgelegt wurden, es sind also Inonsistenzen eines Programms, die aufgedeckt werden.
2. Das Betriebssystem schützt nicht vor fehlerhaften Zugriffen innerhalb eines Programms.
3. Fehlerhafte Zugriffe, wie beispielsweise solche durch unitialisierte Zeiger, können mit dem Mechanismus
der Konstanten nicht aufgedeckt werden.
4.
#include <iostream>
#include <stdlib.h>
int main (int argc, char * argv[]) {
int sum = 0;
for (int i=1; i<argc; ++i)
sum = sum + atoi(argv[i]);
cout << sum << endl;
}
5.
#include <iostream>
#include <stdlib.h>
#include <string>
int main (int argc, char * argv[]) {
if ( argc != 3 ) { cout << "Falscher Aufruf" << endl; return 1; }
if ( string(argv[0]) == "max" ) {
cout << (atoi(argv[1])>atoi(argv[2])
? atoi(argv[1])
: atoi(argv[2]))
<< endl;
return 0;
}
if ( string(argv[0]) == "min" ) {
cout << (atoi(argv[1])<atoi(argv[2])
? atoi(argv[1])
: atoi(argv[2]))
<< endl;
return 0;
}
cout << "Falscher Aufruf" << endl; return 1;
}
Achtung beim Vergleich von C–Strings: Mit if(argv[0] == max") wird die Gleichheit der Zeiger
auf die Zeichenfolgen und nicht die Gleichheit der Zeichenfolgen geprüft. Um das zu vermeiden werden
aus den C–Strings “richtige” Strings erzeugt (der eine explizit, durch den Konstruktor, der zweite implizit).
Alternativ hätte man die C–Strings auch mit der Funktion strcmp vergleichen können:
#include <iostream>
#include <stdlib.h>
#include <string.h>
// C-String Funktionen
int main (int argc, char * argv[]) {
if ( argc != 3 ) { cout << "Falscher Aufruf" << endl; return 1; }
//Achtung strcmp=0 ist Gleichheit
if ( strcmp(argv[0], "max") == 0 ) {
Programmierung II
338
cout << (atoi(argv[1])>atoi(argv[2])
? atoi(argv[1])
: atoi(argv[2]))
<< endl;
return 0;
}
// !strcmp ist also auch Gleichheit
if ( !strcmp(argv[0], "min") ) {
cout << (atoi(argv[1])<atoi(argv[2])
? atoi(argv[1])
: atoi(argv[2]))
<< endl;
return 0;
}
cout << "Falscher Aufruf" << endl; return 1;
}
6. Siehe Skript.
7. Externe Bindung wird im Skript erörtert.
8. Wenn Funktionen interne Bindung hätten, dann könnte es in einem Programm zwei unterschiedliche Funktionen mit dem gleichen Namen und den gleichen Parametertypen geben. Sie müssten nur in unterschiedlichen Übersetzungseinheiten definiert werden.
9. Inline–Definitionen von freien Funktionen und Methoden gehören in H–Dateien.
10.
class C {
public:
C ();
private:
static int x;
};
C::C() : x(5) {} // <<== FEHLER
Statische Komponenten werden definiert und in der Definition initalisiert:
// H-Datei:-------------class C {
public:
C ();
private:
static int x;
};
// C-Datei:-------------C::C() {}
int C::x = 5;
11. Zwei Quelldateien a.cc und b.cc werden übersetzt und gebunden:
a.cc:
#include <iostream>
float f();
typedef char X;
const X a = ’a’;
int main () {
std::cout<<f()<<std::endl;
}
b.cc:
#include <iostream>
typedef float X;
const X a = 0.0;
float f () {
std::cout<<a<<std::endl;
return a;
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
339
Das Programm ist korrekt – Konstante haben interne Bindung – und erzeugt zweimal die Ausgabe “0”.
Wird das const weggelassen, dann ist das Programm nicht mehr korrekt: globale Variablen haben
externe Bindung.
Aufgabe 2
1. Das Programm wird auf die Dateien B.h, B.cc und main.cc aufgeteilt. Die erste Übersetzungseinheit
besetht aus B.h und B.cc, die zweite besteht aus main.cc
// B.h ------------------------------class B {
friend int main();
public:
B();
˜B();
int f();
static int g ();
private:
int
x;
static int y;
const int z;
};
// B.cc -----------------------------#include "B.h"
int B::y = 0;
B::B() : x(0), z(y) { ++y; }
B::˜B() { --y; }
int B::f() { ++x; return x+z; }
int B::g() { return y; }
// main.cc --------------------------#include "B.h"
#include <iostream>
using namespace std;
int main () {
cout << B::g() << endl;
B b1;
cout << B::y << endl;
B b2;
if ( b1.f() + b2.f() > 2) {
B b3;
cout << B::g() << endl;
}
cout << B::g() << endl;
}
2. Makedatei:
main : main.o B.o
g++ -o main main.o B.o
main.o : main.cc B.h
g++ -Wall -c main.cc
B.o : B.cc B.h
g++ -Wall -c B.cc
340
Aufgabe 3
Kein Lösungshinweis.
Programmierung II
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.5
341
Lösungshinweise zu Kapitel 5
Aufgabe 1
Kopierkonstruktor und Zuweisungsoperator werden am einfachsten rekursiv definiert:
class S {
public:
S() : a(0), l(0) {}
S(int p_a, S * p_l) : a(p_a), l(p_l) {}
˜S()
{ delete l; }
S (const S &);
S & operator= (const S &);
private:
int a;
S * l;
};
S::S (const S &s)
: a(s.a),
l(s.l==0 ? 0
: new S(*(s.l)) // rekursiver Aufruf des Kopierkonstruktors
)
{}
S & S::operator= (const S &s) {
if (&s != this) {
a = s.a;
delete l;
if (s.l == 0) l = 0;
else l = new S(*(s.l)); // rekursiver Aufruf des Kopierkonstruktors
}
return *this;
}
Aufgabe 2
In der zweiten, der “Anfänger”–Variante:
class Stapel {
public:
...
private:
int
a;
Stapel * s;
};
können leere Stapel nicht vernünftig dargestellt werden.
Aufgabe 3
Kein Lösungshinweis.
Aufgabe 4
Ausdrücke sind der Datentyp für die Benutzer. Benutzer haben nur die sichere also die tiefe Kopie zur Verfügung.
Knoten sind ein interner Datentyp für den Implementier der Ausdrücke, der je nach Bedarf flach oder tief kopieren
342
Programmierung II
will und darf.
Im Konstruktor für Operator–Ausdrücke wird der Kopierkonstruktor auf neu erzeugte Objete angewendet und
damit der Kopierkonstruktor aktiviert, dieser wiederum kopiert dann tief. Im Konstruktor für Operator–Knoten
wird einfach der Zeiger kopiert.
Aufgabe 5
// Ausdruck.h --------------------------------------------------------//
#ifndef AUSDRUCK_H_
#define AUSDRUCK_H_
#include <Rational.h>
#include <string>
#include <iostream>
class Ausdruck {
friend ostream & operator<< (ostream &, const Ausdruck &);
friend istream & operator>> (istream &, Ausdruck &);
public:
Ausdruck ();
Ausdruck (const Ausdruck &);
Ausdruck & operator=(const Ausdruck &);
˜Ausdruck ();
private:
class AusdrKnoten;
AusdrKnoten *
ak;
};
ostream & operator<< (ostream &, const Ausdruck &);
istream & operator>> (istream &, Ausdruck &);
#endif
// ----------------------------------------------------------------------
// Ausdruck.cc ---------------------------------------------------------//
#include <Ausdruck.h>
#include <stack>
class Ausdruck::AusdrKnoten {
public:
AusdrKnoten ();
AusdrKnoten (const Rational &);
AusdrKnoten (char, AusdrKnoten *, AusdrKnoten *);
˜AusdrKnoten ();
AusdrKnoten (const AusdrKnoten &);
AusdrKnoten & operator=(const AusdrKnoten &);
ostream & write (ostream &) const;
private:
enum Art {undefAusdr, wertAusdr, opAusdr};
Art
art;
Rational
wert;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
343
char
op;
AusdrKnoten *l, *r;
};
Ausdruck::Ausdruck () : ak(0) {}
Ausdruck::Ausdruck (const Ausdruck &a) : ak( new AusdrKnoten(*(a.ak)) ) {}
Ausdruck::˜Ausdruck () { delete ak; }
Ausdruck & Ausdruck::operator=(const Ausdruck &a) {
if ( &a != this ) {
delete (ak);
ak = new AusdrKnoten(*(a.ak));
}
return *this;
}
Ausdruck::AusdrKnoten::AusdrKnoten () : art(undefAusdr), op(’#’), l(0), r(0) {}
Ausdruck::AusdrKnoten::AusdrKnoten (const Rational &p_r)
: art(wertAusdr), wert (p_r), op(’?’), l(0), r(0) {}
Ausdruck::AusdrKnoten::AusdrKnoten (char p_op, AusdrKnoten *p_l, AusdrKnoten *p_r)
: art(opAusdr), op(p_op), l(p_l), r(p_r) {}
Ausdruck::AusdrKnoten::˜AusdrKnoten () { delete(l); delete(r); }
Ausdruck::AusdrKnoten::AusdrKnoten (const AusdrKnoten &a) {
art = a.art;
op = a.op;
if ( a.art == opAusdr) {
l = new AusdrKnoten(*(a.l));
r = new AusdrKnoten(*(a.r));
}
}
Ausdruck::AusdrKnoten & Ausdruck::AusdrKnoten::operator=(const AusdrKnoten &a) {
if (&a != this) { // keine Zuweisung an sich selbst
delete(l); l = 0;
delete(r); r = 0;
art = a.art;
op = a.op;
if ( a.art == opAusdr) {
l = new AusdrKnoten(*(a.l));
r = new AusdrKnoten(*(a.r));
}
}
return *this;
}
ostream & Ausdruck::AusdrKnoten::write(ostream & os) const {
switch (art) {
case wertAusdr:
os << ’[’ << wert << ’]’;
break;
case opAusdr:
if ( (l==0 ) || (r==0) ){ cerr << "FEHLERHAFTE STRUKTUR 3\n"; exit (1); }
Programmierung II
344
os << ’(’;
l->write(os);
os << op;
r->write(os);
os << ’)’;
break;
default:
cerr << "FEHLERHAFTE STRUKTUR 4: " << art << "\n";
exit (1);
}
return os;
}
std::ostream & operator<< (std::ostream &os, const Ausdruck &a) {
return a.ak->write(os);
}
std::istream & operator>> (std::istream &is, Ausdruck &a) {
stack<char>
oSt;
stack<Ausdruck::AusdrKnoten *> aSt;
char
z;
Ausdruck::AusdrKnoten * a1, * a2;
char
op;
do {
if (!(is >> z)) break;
switch (z){
case ’(’ :
break;
case ’)’ : {
op = oSt.top();
oSt.pop();
a2 = aSt.top();
aSt.pop();
a1 = aSt.top();
aSt.pop();
aSt.push (new Ausdruck::AusdrKnoten(op, a1, a2));
break;
}
case ’+’: case ’-’:
oSt.push (z);
break;
case ’[’: {
Rational r; char tmp;
is >> r;
is >> tmp;
if ( tmp != ’]’ ) { cerr << "Ausdrucksfehler 1\n"; exit (1); }
aSt.push (new Ausdruck::AusdrKnoten(r));
break;
}
default: cerr << "Ausdrucksfehler 2\n"; exit (1);
}
} while (true);
if ( !aSt.empty() ) {
delete a.ak;
a.ak = aSt.top();
return is;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
} else {
cerr << "AUSWERTFEHLER\n"; exit(1);
}
}
// ---------------------------------------------------------------------// Rational.h ----------------------------------------------------------//
#ifndef RATIONAL_H_
#define RATIONAL_H_
#include <iostream>
using namespace std;
class Rational {
friend ostream & operator<< (ostream &, const Rational &);
friend istream & operator>> (istream &, Rational &);
public:
Rational ();
Rational (int);
Rational (int, int);
Rational (const Rational &);
char vorzeichen () const;
int zaehler () const;
int nenner () const;
Rational & operator= (const Rational &);
Rational operator+ (const Rational &) const;
Rational operator- (const Rational &) const;
Rational operator- () const;
private:
enum Vorzeichen {plus, minus};
Vorzeichen
vz;
unsigned int
z;
unsigned int
n;
static int
static int
kgv
ggt
(int, int);
(int, int);
static void
static void
static void
kuerze
(Rational &);
erweitere
(Rational &, int);
gleichnamig (Rational &, Rational &);
};
// I/O--Operatoren als freie Funktionen:
ostream & operator<< (ostream &, const Rational &);
istream & operator>> (istream &, Rational &);
//Freie Operatoren
// Unaeres Minus fuer Brueche:
Rational operator- (const Rational &);
// Operationen mit gemischten Operanden:
345
Programmierung II
346
Rational
Rational
Rational
Rational
operator+
operator+
operatoroperator-
(int,
const Rational &);
(const Rational &, int);
(int,
const Rational &);
(const Rational &, int);
#endif
// ---------------------------------------------------------------------// Rational.cc ---------------------------------------------------------//
.... Rationale Zahlen sind die bekannten Brueche, sie sollten keine ....
.... Probleme bereiten
....
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.6
Lösungshinweise zu Kapitel 6
Aufgabe 1
Die Umwandlung in ein Template ist trivial:
template< double (*f)(double) >
double nullStelle ( double u,
// untere Intervallgrenze
double o,
// obere Intervallgrenze
double eps ) { // Genauigkeit: minimale Intervallgroesse
... unveraendert ....
}
Aufgabe 2
Zweidimensionaler Integer–Vektoren als Template:
#include <iostream>
#include <string>
using namespace std;
template<class T>
class Vektor {
friend ostream & operator<< <T> (ostream &, const Vektor &);
friend istream & operator>> <T> (istream &, Vektor &);
public:
Vektor ();
Vektor (T, T);
int & x ();
// x--Koordinate
int & y ();
// y--Koordinate
Vektor operator+ ( const Vektor &) const; // Vektoraddition
private:
T x_, y_;
};
template<class T> Vektor<T>::Vektor () {}
template<class T> Vektor<T>::Vektor (T px, T py) : x_(px), y_(py) {}
template<class T>
ostream & operator<< (ostream &os, const Vektor<T> &v) {
return os << "(" << v.x_ << "," << v.y_ << ")";
}
template<class T>
istream & operator>> (istream &is, Vektor<T> &v) {
char c;
is >> c; // (
is >> v.x_;
is >> c; // ,
is >> v.y_;
is >> c; // )
}
template<class T>
Vektor<T> Vektor<T>::operator+ ( const Vektor &v) const {
return Vektor(x_+v.x_, y_+v.y_);
347
348
Programmierung II
}
// Anwendungsbeispiel:
int main () {
Vektor<string> v1 ( "Effi ", "Briest "),
v2 ( "Madame ", "Bovary ");
cout << v2 + v1 << endl;
}
Aufgabe 3
Siehe Sortierbeispiele im Skript.
Aufgabe 4
Der Operator operator[] sollte eine Referenz zurück geben.
Aufgabe 5
Eine dünnbesetzte Matrix kann dargestellt werden als Abbildung von Zeilenindizes auf Zeilen, wobei Zeilen Abbildungen auf Elemente sind, etwa wie folgt:
template<class T>
class Matrix {
private:
typedef map<unsigned int, T> Zeile;
map<unsigned int, Zeile>
mat;
...
public:
...
Zeile & operator[] ( unsigned int i ) { return mat[i]; }
...
};
Aufgabe 6
#include <iostream>
using namespace std;
template<unsigned int n>
class Matrix {
friend istream & operator>> <n> (istream &, Matrix<n> &);
friend ostream & operator<< <n> (ostream &, const Matrix<n> &);
private:
typedef float Zeile[n];
Zeile m[n];
Matrix<n-1> coMatrix ( unsigned int x, unsigned int y ) {
Matrix<n-1> res;
for ( unsigned int i=0; i<n-1; ++i)
for ( unsigned int j=0; j<n-1; ++j)
res[i][j] = m[i<x?i:(i+1)][j<y?j:(j+1)];
return res;
}
public:
Zeile & operator[] ( unsigned int i ) { return m[i]; }
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
float det () {
if (n == 1) return m[0][0];
else {
float res = 0.0;
int f = -1;
for ( unsigned int i=0; i<n; ++i) {
f = f*-1;
res = f*m[i][0]*(coMatrix(i,0)).det() + res;
}
return res;
}
}
};
template<>
class Matrix<1> {
friend istream & operator>> (istream &, Matrix<1> &);
friend ostream & operator<< (ostream &, const Matrix<1> &);
private:
typedef float Zeile[1];
Zeile m[1];
public:
Zeile & operator[] ( unsigned int i ) { return m[i]; }
float det () { return m[0][0]; }
};
template<unsigned int n>
istream & operator>> (istream &is, Matrix<n> &m) {
for (unsigned int i = 0; i<n; ++i)
for (unsigned int j = 0; j<n; ++j)
is >> m.m[i][j];
return is;
}
template<unsigned int
ostream & operator<<
for (unsigned int i
for (unsigned int
os << m.m[i][j]
os << endl;
}
return os;
}
n>
(ostream &os, const Matrix<n> &m) {
= 0; i<n; ++i) {
j = 0; j<n; ++j)
<< " ";
int main() {
Matrix<5> mat;
cin >> mat;
cout << "Die Determinante von\n" << mat << "ist" << endl;
cout << mat.det() << endl;
}
349
Programmierung II
350
B.7
Lösungshinweise zu Kapitel 7
Aufgabe 1
Das Programm
class Basis {
public:
Basis () : x(10) {}
void f () { ++x; }
int x;
};
class Abgeleitet : public Basis {
public:
Abgeleitet () {}
void f () { --x; }
};
int main() {
Abgeleitet * pa = new Abgeleitet;
Basis
* pb = pa;
pa->f();
// Aufruf von Abgeleitet::f
pb->f();
// Aufruf von Basis::f
cout << pa->x << endl; // Ausgabe: 10
cout << pb->x << endl; // Ausgabe: 10
}
erzeugt die Ausgabe 10, 10. pa und pb zeigen zwar auf das gleiche Objekt, aber mit pa->f() und pb->f()
werden unterschiedliche Methoden dieses Objekts aktiviert (es hat zwei Varianten von f!). Der Typ von pa und
pb entscheidet über die jeweils zu aktivierende Methode.
Aufgabe 2
Die Klasse ist OK und entspricht:
class Deque : public Queue {
public:
Deque () {}
Deque (const Deque &d) : Queue(d) {}
Deque & operator= (const Deque &d) {
if ( this != &d)
this->Queue::operator= (d);
return *this;
}
int getLast () { return l.back(); }
};
Aufgabe 3
Das Programm ist korrekt und erzeugt die Ausgabe:
1111, 1112
111, 112, 113
a.print() aktiviert Abgeleitet::print für a. a wird mit seinem Defaultkonstruktor initialisiert auf
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
a:
351
Basis-Anteil
Basis::x 111
Basis::y 112
Basis::z 113
Abgeleitet-Anteil
Abgeleitet::x 1111
Abgeleitet::y 1112
Man beachte, dass a je zwei Komponenten mit den Namen x und y besitzt. a.print() gibt die abgeleiteten
Anteile aus. pa zeigt auf das gleiche Objekt. Da pa aber vom Typ Basis* ist, wird mit pa->print() die
Methode Basis::print aktiviert und die gibt den Basis–Anteil von a aus.
Aufgabe 4
Der Kopierkonstruktor von Abgeleitet ist nicht korrekt. Die korrekte Lösung mit Zuweisungsoperator ist:
class Basis {
public:
Basis (int x) : _x(x) {}
Basis () : _x(-1) {}
Basis (const Basis & b) : _x(b._x) {}
Basis & operator= (const Basis & b) {
if ( &b != this) _x = b._x;
return *this;
}
private:
int _x;
};
class Abgeleitet : public Basis {
public:
Abgeleitet (int x, int y) : Basis(x), _y(y) {}
Abgeleitet() {}
Abgeleitet(const Abgeleitet & a) : Basis(a), _y(a._y) {}
Abgeleitet & operator= (const Abgeleitet & a) {
if ( &a != this) {
Basis::operator=(a);
_y = a._y;
}
return *this;
}
private:
int _y;
};
Zuweisungsoperator und Kopierkonstruktor hätten nicht definiert werden müssen. Die automatisch erzeugten der
beiden Klassen sind korrekt.
Aufgabe 5
Ein selbst definierter Zuweisungsoperator kopiert den Basisanteil nur dann, wenn dies explizit so programmiert
wird. Also wenn er definiert wird, dann aber auch richtig:
class Ab : public Basis {
...
Ab & operator= (const Ab & p_ab) {
if ( this != &p_ab ) {
Programmierung II
352
Basis::operator=(p_ab);
a = p_ab.a;
}
return *this;
}
...
};
Ein automatisch generierter Zuweisungsoperator hätte das Richtige getan.
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.8
353
Lösungshinweise zu Kapitel 8
Aufgabe 1
1. In der angegeben Form erzeugt das Programm die Ausgabe 10, 10.
2. Wenn die Methode Basis::f als virtuelle Methode definiert
class Basis {
public:
Basis () : x(10) {}
virtual void f () { ++x; }
int x;
};
class Abgeleitet : public Basis {
public:
Abgeleitet () {}
void f () { --x; }
};
int main() {
Abgeleitet * pa = new Abgeleitet;
Basis
* pb = pa;
pa->f();
// Abgeleitet::f
pb->f();
// Abgeleitet::f
cout << pa->x << endl; // Ausgabe 8
cout << pb->x << endl; // Ausgabe 8
}
wird, dann kommt es beim Aufruf pb->f() auf den Typ an, den *pb zur Laufzeit hat. Da das Objekt auf
den pb zeigt den Typ Abgeleitet hat, wird Abgeleitet::f aktiviert. Die Ausgabe ist dann 8, 8 (x
zweimal erniedrigen, statt einmal erniedrigen und einmal erhöhen).
3. Wenn Basis::f als rein virtuelle Methode definiert wird ergibt sich das gleiche Verhalten. (Im Programm
werden keine Objekte vom Typ Basis erzeugt und Basis::f wird nicht aufgerufen.)
Aufgabe 2
1. Selbstverständlich!
2. Der Defaultkonstruktor von Manager belegt sein Objekt mit leeren Strings (die vom Defaultkonstruktor
von string erzeugt werden).
3. Nein, die Klassen erzeugen Speicherlöcher: delete mitarbeiter[i] räumt nur den Angestellt–
Anteil von *mitarbeiter[i] weg – *mitarbeiter[i] hat ja den Typ Angestellt. Wenn
mitarbeiter[i] aber auf einen Manager zeigt, dann bleibt der abt–String im Speicher liegen. Lösung:
Angestellt sollte einen virtuellen Destuktor haben:
class Angestellt {
public:
Angestellt(string name) : _name(name) {}
Angestellt() {}
Angestellt() {}
private:
string _name;
};
...
Programmierung II
354
Aufgabe 3
1. Die Zuweisung *b1 = *b2; hat nicht zur Folge, dass b1 und b2 auf gleiche Objekte zeigen! b1 und b2
sind vom Typ Basis*, also wird der Zuweisungsoperator Basis::operator= aktiviert! Dieser kopiert
aber nur den Basis–Anteil: das b, a bleibt unverändert. (Dies ist ein Grund mit Umschlagklassen und
clone–Funktionen zu arbeiten!)
2. Wenn print nicht virtuell ist, erzeugt das Programm die Ausgabe:
b= 1
b= 1
Es wird ja stets die Basisvariante aufgerufen.
Aufgabe 4
Das Programm frisst den Speicher, obwohl jedes Objekt nach seiner Erzeugung sofort wieder vernichtet wird.
Da die Zeiger vom Basistyp sind, wird der Destruktor des Basistyps aufgerufen. Die in der Ableitung angelegten Ints (*y) werden nicht weg geräumt und füllen den Speicher unaufhaltsam. Der Fehler ist schnell behoben:
Der Destruktor des Basistyps muss lediglich als virtuell erklärt werden. Damit ruft delete den Destruktor der
abgeleiteten Klasse Ab und dieser ruft (implizit) den Destruktor der Basisklasse.
Aufgabe 5
Die typisch polymorphe Methode ist write. Wertausdrücke und Operatorausdrücke sollen ausgegeben werden
werden können. Wir haben einen Zeiger, der entweder auf die eine oder die andere Art von Ausdruck zeigt und
egel was es nun gerade ist, mit write soll es ausgegeben werden. Konstrurktoren und Zuweisungsoperatoren und
freie Funktionen sind nicht polymorph.
// Ausdruck.h -----------------------------------------------#ifndef AUSDRUCK_H_
#define AUSDRUCK_H_
#include <string>
#include <iostream>
class Ausdruck {
friend std::ostream & operator<< (std::ostream &, const Ausdruck &);
friend std::istream & operator>> (std::istream &, Ausdruck &);
public:
Ausdruck ();
Ausdruck (const Ausdruck &);
Ausdruck & operator=(const Ausdruck &);
˜Ausdruck ();
private:
class Knoten;
class WertKnoten;
class OpKnoten;
Knoten *
ak;
};
std::ostream & operator<< (std::ostream &, const Ausdruck &);
std::istream & operator>> (std::istream &, Ausdruck &);
#endif
// ----------------------------------------------------------
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
355
// Ausdruck.cc ---------------------------------------------#include <Ausdruck.h>
#include <stack>
#include <Zahl.h>
class Ausdruck::Knoten {
public:
virtual ˜Knoten () {}
virtual std::ostream & write (std::ostream &) const = 0;
virtual Knoten * clone () const = 0;
};
class Ausdruck::WertKnoten : public Ausdruck::Knoten {
public:
WertKnoten
(const Zahl & p_wert) : wert(p_wert) {}
std::ostream &
write (std::ostream &os ) const;
WertKnoten * clone
()
const { return new WertKnoten (wert); }
private:
Zahl
wert;
};
class Ausdruck::OpKnoten : public Ausdruck::Knoten {
public:
OpKnoten (char p_op, Knoten *p_l, Knoten *p_r) : op(p_op), l(p_l), r(p_r) {}
˜OpKnoten () { delete l; delete r; }
OpKnoten (const OpKnoten &);
OpKnoten & operator= (const OpKnoten &);
OpKnoten * clone () const { return (new OpKnoten (*this)); }
std::ostream & write (std::ostream &) const;
private:
char
op;
Knoten *l, *r;
};
Ausdruck::Ausdruck () : ak(0) {}
Ausdruck::Ausdruck (const Ausdruck &a) : ak( a.ak->clone() ) {}
Ausdruck::˜Ausdruck () { delete ak; }
Ausdruck & Ausdruck::operator=(const Ausdruck &a) {
if ( &a != this ) {
delete ak;
ak = a.ak->clone();
}
return *this;
}
Ausdruck::OpKnoten::OpKnoten (const OpKnoten & k) {
op = k.op;
l = k.l->clone();
r = k.r->clone();
}
Ausdruck::OpKnoten & Ausdruck::OpKnoten::operator=(const OpKnoten & k) {
if (&k != this) { // keine Zuweisung an sich selbst
delete(l);
delete(r);
l = k.l->clone();
Programmierung II
356
r = k.r->clone();
}
return *this;
}
std::ostream & Ausdruck::WertKnoten::write(std::ostream & os) const {
return os << ’[’ << wert << ’]’;
}
std::ostream & Ausdruck::OpKnoten::write(std::ostream & os) const {
if ( (l==0 ) || (r==0) ){ cerr << "FEHLERHAFTE STRUKTUR 3\n"; exit (1); }
os << ’(’;
l->write(os);
os << op;
r->write(os);
os << ’)’;
return os;
}
std::ostream & operator<< (std::ostream &os, const Ausdruck &a) {
return a.ak->write(os);
}
std::istream & operator>> (std::istream &is, Ausdruck &a) {
stack<char>
oSt;
stack<Ausdruck::Knoten *> aSt;
char
z;
Ausdruck::Knoten *
a1,
*
a2;
char
op;
do {
if (!(is >> z)) break;
switch (z){
case ’(’ :
break;
case ’)’ : {
op = oSt.top();
oSt.pop();
a2 = aSt.top();
aSt.pop();
a1 = aSt.top();
aSt.pop();
aSt.push (new Ausdruck::OpKnoten(op, a1, a2));
break;
}
case ’+’: case ’-’:
oSt.push (z);
break;
case ’[’: {
Zahl r; char tmp;
is >> r;
is >> tmp;
if ( tmp != ’]’ ) { cerr << "AusdrucksFEHLER 1\n"; exit (1); }
aSt.push (new Ausdruck::WertKnoten(r));
break;
}
default: cerr << "ucksfehler 2\n"; exit (1);
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
} while (true);
if ( !aSt.empty() ) {
delete a.ak;
a.ak = aSt.top();
return is;
} else {
cerr << "AUSWERTFEHLER\n"; exit(1);
}
}
// ----------------------------------------------------------
Aufgabe 6
Wenn praemie in der Basisklasse virtuell ist, kann man sich alle dynamic cast Operationen sparen.
Aufgabe 7
1. Kein Lösungshinweis.
2. Hinweis, Umwandlung des Orginals in Template–Form:
class NTupel {
public:
virtual ˜NTupel () {}
virtual NTupel * clone()
= 0;
virtual ostream & schreib (ostream &) = 0;
};
template<class T>
class Paar : public NTupel {
public:
Paar (T p_i, T p_j) : i(p_i), j(p_j) {}
NTupel * clone() {
return new Paar(*this);
}
ostream & schreib (ostream & os) {
return os << "<" << i << "," << j << ">";
}
private:
T i, j;
};
template<class T>
class Tripel : public NTupel {
public:
Tripel (T p_i, T p_j, T p_k) : i(p_i), j(p_j), k(p_k) {}
NTupel * clone() {
return new Tripel(*this);
}
ostream & schreib (ostream & os) {
return os << "<" << i << "," << j << "," << k << ">";
}
private:
T i, j, k;
};
357
Programmierung II
358
template<class T>
class Tupel {
friend istream & operator>> <T> (istream &, Tupel<T> &);
friend ostream & operator<< <T> (ostream &, const Tupel<T> &);
public:
Tupel(T i, T j)
: b(new Paar<T>(i,j))
{}
Tupel(T i, T j, T k) : b(new Tripel<T>(i,j,k)) {}
˜Tupel() { delete b; }
Tupel(const Tupel &p_tupel) : b(p_tupel.b->clone()) {}
Tupel & operator= (const Tupel & p_tupel) {
if ( this != &p_tupel ) {
delete (b);
if ( p_tupel.b == 0 ) b = 0;
else b = p_tupel.b->clone();
}
return *this;
}
private:
NTupel * b;
};
template<class T>
istream & operator>> (istream & is, Tupel<T> & t) {
....
t.b = new Paar<T>(a[0], a[1]);
...
t.b = new Tripel<T>(a[0], a[1], a[2]);
...
}
template<class T>
ostream & operator<< (ostream & is, const Tupel<T> & t) {
return t.b->schreib(is);
}
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
B.9
359
Lösungshinweise zu Kapitel 9
Aufgabe 1
1.
#include <string>
#include <fstream>
int main (int argc, const char * argv[]) {
if ( argc != 3 ) { cerr << "Falscher Aufruf" << endl; return 1; }
ifstream ifs (argv[1]);
if (!ifs) { cerr << "kann Datei " << argv[1] << " nicht oeffnen\n"; return 1;}
ofstream ofs (argv[2]);
if (!ofs) { cerr << "kann Datei " << argv[2] << " nicht oeffnen\n"; return 1;}
string zeile;
int i = 0;
getline (ifs, zeile);
while (ifs) {
++i; ifs.width (3);
ofs << i << ": " << zeile << endl;
getline (ifs, zeile);
}
ifs.close();
}
2.
#include <fstream>
#include <string>
#include <sstream>
class Wort {
friend istream & operator>> (istream &, Wort &);
public:
Wort () {}
private:
string v;
};
istream & operator>> (istream &s, Wort &w) {
static const string weiseZeichen = " \t";
string str;
//Wortanfang und erstes Zeichen hinter dem Wort
string::size_type pos_1, pos_2;
do {
s >> str;
pos_1 = str.find_first_not_of (weiseZeichen, 0);
if (pos_1 != string::npos)
break;
} while (s);
if (!s) return s;
pos_2 = str.find_first_of (weiseZeichen, pos_1);
if (pos_2 == string::npos)
pos_2 = str.length();
w.v = str.substr (pos_1, pos_2-pos_1);
Programmierung II
360
return s;
}
int main (int argc, const char * argv[]) {
if (argc != 2) {cerr << "Falscher Aufruf\n"; return 1;}
ifstream ifs (argv[1]);
if (!ifs) {cerr << "kann Datei nicht oeffnen\n"; return 1;}
int cZeilen = 0, cWorte = 0, cZeichen = 0;
string zeile;
getline (ifs, zeile);
while (ifs) {
++cZeilen;
cZeichen = cZeichen + zeile.length();
istringstream istr (zeile);
Wort w;
while (istr >> w) {
++cWorte;
}
getline (ifs, zeile);
}
cout << argv[1] << "\n\tZeichen: \t" << cZeichen
<< " (ohne Zeilenvorschub)"
<< endl
<< "\tWorte : \t" << cWorte
<< endl
<< "\tZeilen: \t" << cZeilen
<< endl;
}
3. Die Größe einer Datei kann man feststellen, indem auf das Ende positioniert wird und dann die Position
ausgegeben wird (seekg und tellg). Siehe Skript.
4. Siehe Beispiel im Skript.
5.
int main (int argc, char *argv[]) {
ifstream datei;
istream & input =
(argc < 2) ? cin
: (datei.open(argv[1]),
static_cast<istream &>(datei));
...
input >> ...;
...
}
Aufgabe 2
1. Die Operatoren können mit allen von istream bzw. ostream abgeleiteten Typen (z.B. Dateien) verwendet werden. Sie sind damit wesentlich vielseitiger.
2. Siehe Skript.
3. Mit endl wird der Puffer geleert und die zeichen auf das externe Medium geschrieben.
4. Die Breite der Ausgabe wird mit width gesetzt. Die Genausigkeit der Ausgabe kann mit precision
eingestellt werden.
#include <iostream>
float a[10][10];
using namespace std;
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
361
int main (int argc, const char * argv[]) {
for (int i=0; i<10; ++i)
for (int j=0; j<10; ++j)
a[i][j] = 10*i+j+0.1*j+0.001*j;
// Ausgabe im Standard-Format fuer Fliesskomma
for (int i=0; i<10; ++i) {
for (int j=0; j<10; ++j) {
cout.width(7);
// 7 Zeichen fuer die naechste Ausgabe
cout.precision(4);
// Genauigkeit: 4 Stellen pro Zahl
cout.fill(’ ’);
// Mit Blank auffuellen (Default)
cout << a[i][j];
}
cout << endl;
}
cout << endl;
// Ausgabe im fixed-Format fuer Fliesskomma
for (int i=0; i<10; ++i) {
for (int j=0; j<10; ++j) {
cout.width(6);
// 6 Zeichen fuer die naechste Ausgabe
cout.setf(ios::fixed);
// Ausgabe im fixed-Format
cout.precision(2);
// Genauigkeit: 2 NACHKOMMA-Stellen
cout.fill(’0’);
// Mit 0 auffuellen
cout << a[i][j]<<" ";
// Zwischenraeume ausgeben
}
cout << endl;
}
}
Aufgabe 3
Mengen halten ihre Elemente nach Größe sortiert. Multimengen sind Mengen in denen Elemente mehrfach vorkommen können. Mengen und Multimengen können mit Iteratoren durchlaufen werden.
#include
#include
#include
#include
<fstream>
<string>
<sstream>
<set>
class Wort {
friend istream & operator>> (istream &, Wort &);
friend ostream & operator<< (ostream &, const Wort &);
public:
Wort () {}
bool Wort::operator< (const Wort &w) const { return v
bool Wort::operator> (const Wort &w) const { return v
bool Wort::operator== (const Wort &w) const { return v
bool Wort::operator<= (const Wort &w) const { return v
bool Wort::operator>= (const Wort &w) const { return v
private:
string v;
};
ostream & operator<< (ostream &s, const Wort &w) {
return s << w.v;
}
istream & operator>> (istream &s, Wort &w) {
<
>
==
<=
>=
w.v;
w.v;
w.v;
w.v;
w.v;
}
}
}
}
}
Programmierung II
362
static const string weiseZeichen = " \t";
string str;
//Wortanfang und erstes Zeichen hinter dem Wort
string::size_type pos_1, pos_2;
do {
s >> str;
pos_1 = str.find_first_not_of (weiseZeichen, 0);
if (pos_1 != string::npos)
break;
} while (s);
if (!s) return s;
pos_2 = str.find_first_of (weiseZeichen, pos_1);
if (pos_2 == string::npos)
pos_2 = str.length();
w.v = str.substr (pos_1, pos_2-pos_1);
return s;
}
multiset<Wort> warteSchlange;
int main (int argc, const char * argv[]) {
if (argc != 2) {cerr << "Falscher Aufruf\n"; return 1;}
ifstream ifs (argv[1]);
if (!ifs) {cerr << "kann Datei nicht oeffnen\n"; return 1;}
string zeile;
getline (ifs, zeile);
while (ifs) {
istringstream istr (zeile);
Wort w;
while (istr >> w) {
warteSchlange.insert(w);
}
getline (ifs, zeile);
}
for (multiset<Wort>::iterator i = warteSchlange.begin();
i != warteSchlange.end();
++i)
cout << *i << endl;
}
Aufgabe 4
Kein Lösungshinweis.
Aufgabe 5
Kein Lösungshinweis.
Index
cout, 39, 269
Cursor, 299
Abbildung, 307
Adresse, 60
Adressoperator, 60
ADT, 46
ar, 124
Archiv, 124
argc, 101
argv, 101
ASCII, 289
Assembler, 105
atoi, 101, 277, 297
Ausgabe
-selbst definierter Typen, 39, 269
Datei, 110, 263
-Abstraktion, 293
-Flag, 266, 283
-Header, 296
-Modus, 285
-Offnen, 263, 283
-Position, 265, 281, 299
-Schliesen, 265
-Template, 296
-Verwaltung von Satzen, 300
-Zustand, 266
-clear, 283
-eof, 267
-flush, 285
-get, 267
-positionieren, 282
-put, 267
-read, 284
-seek, 282
-tell, 282
-write, 284
Binar-, 288
getline, 268
Konstruktor und-, 265
Offnen einer Binar-, 290
Satz loschen-, 300
sequentielle, 263
und Iterator, 299
Datenkomponente
statische- eines Templates, 175
Datentyp
Ableitung als konkreter-, 212
abstrakter-, 46
konkreter-, 46, 49, 138
delete, 73
-und Destruktoren, 83
-und Felder, 76
Dereferenzierung, 63
Dereferenzierungs–Operator, 63
Destruktor, 18, 23, 83, 138
-und Vererbung, 206
virtueller-, 227
Direktive
bedingte-, 109
Definitions-, 109
Inklusions-, 106
DLL, 127
Dump-Tool, 292
dynamic cast, 243
bad, 266
badbit, 266
Basisklasse
abstrakte-, 230
Baum
Ableitungs-, 152
abstrakter Syntax-, 153
Syntax-, 152, 153, 214
Behälterklasse, 144
Benutzer, 4
Beziehung
-zwischen Klassen, 53
Bibliothek, 124
dynamische-, 127
Objekt-, 124
binary, 291
Binder, 105, 111, 124
dynamischer-, 127
Bindung
-dynamische, 227
-statische, 227
externe-, 112, 116, 118
interne-, 112, 120
Byteordnung, 292
Byteposition, 296
C–Datei, 108
C-String, 276, 290
Cast
dynamischer, 243
statischer, 292
cerr, 269
cin, 39, 269
Compileroption, 108, 125
-Bibliotheksverzeichnis, 125
-Inklusionsverzeichnis, 108
const, 26–28, 85, 88
-und Initialisierer, 27
363
Programmierung II
364
Eingabe
-selbst definierter Typen, 39, 269
failbit setzen bei der-, 273
zurück setzen, 272
endl, 285
eof, 266
eofbit, 266
explicit, 16
fail, 266
failbit, 266, 273
Feld, 76, 286
-Index, 78
-als Referenzparameter, 79
-als Wertparameter, 78
-im Heap, 76
-mehrdimensionales, im Heap, 81
flush, 285
Freund, 9
Funktion als Freund, 9, 271
I/O-Operator als-, 271
Klasse als Freund, 10, 149
friend, 9, 150
fstream, 285
Funktionstemplate
-als Freund eines Klassentemplates, 174
Geheimnisprinzip, 7
generisch, 252
getline, 268, 269
Getrennte Ubersetzung, 112
-einer Funktion, 114
-und Klassendefinitionen, 117
-und Konstanten, 120
-und Typdefinitionen, 114
-und globale Variablen, 118
good, 266
goodbit, 266
Grammatik, 152
H–Datei, 108
Heap, 72
ifstream, 263, 265, 267
Implementierung, 4
include, 106
-Verzeichnis, 108
Indexberechnung, 78
Initialisierer, 18, 20, 27
Initialisierung, 14
-reihenfolge, 18
Initialisierungsliste, 17
Inklusionswächter, 109
inline, 13
Interface, 231
istream iterator, 278
istringstream, 102, 274
istrstream, 274
-und istringstream, 277
Iterator, 144
-und Datei, 277
konstanter-, 150
Kapselung, 7, 137
Klasse, 1
-Komponente, 1
-und Verbund, 5
abgeleitete-, 192
Basis-, 192
Behälter-, 141, 172
Behalter-, 151
geschachtelte-, 133
konstante Datenkomponente-, 26
konstante Methode-, 27
statische Datenkomponente-, 23, 120
statische Methode-, 25
variablenorientierte-, 138
wertartige-, 138
wertorientierte-, 40
zustandsorientierte-, 40
Klassenvariable, 24
-und getrennte Übersetzung, 120
Kommandozeile, 100, 101
Komposition, 54
Konstante, 26, 87, 122
interne Bindung einer-, 120
Konstruktor, 14, 82
-Aufruf, 16
-als Konversionsoperation, 16
-und Vererbung, 204
Comiler-generierter-, 22
Default-, 16, 47, 138, 174
expliziter-, 16
Klassen ohne-, 17
Kopier-, 47, 135, 136, 138
uberladener-, 15
und Polymorphismus-, 227
Vererbung und Kopier-, 207
Konversion
-int nach string, 276
-string nach int, 276
Darstellungs-, 273
Typ-, 243
Kopie
flache-, 135, 233
tiefe-, 135
Kopplung, 8, 213
l-Wert, 38, 49
Lader, 111
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
Lebensdauer, 72
list, 305
Liste, 132, 305
-mit Position, 142
Kopie einer-, 135
sortierte-, 212
verkettete-, 75, 132
Zuweisung einer-, 135
Listeniterator, 144
main, 111
Make, 122, 125
-Makro, 126
Makedatei, 122
map, 307
Menge, 137, 152, 306
Methode
konstante-, 27
rein virtuelle-, 230
statische-, 25
virtuelle-, 223
Mischen, 280
Modul, 293
Multimenge, 306
Multimethode, 248
multiset, 306
new, 73
-und Felder, 76
-und Konstruktoren, 82
-und mehrdimensionale Felder, 81
next permutation, 186
Null–Zeiger, 70
OD, 292
ofstream, 263, 265, 266
Operator, 11
-*, 147
-++, 147
-Template als Freund, 271
-als Freund, 179
-als Methode, 11
-als freie Funktion, 11
-binarer, 11
-template, 179
-unarer, 11
-und statische Methoden, 26
-<<, 39, 269, 273
-<< und Templates, 179
->>, 39, 102, 269, 273
Adress-, 60, 63
Adresss-, 52
Ausgabe-, 39, 270
Bereichs-, 211
delete-, 72
365
Dereferenzierungs-, 63
Eingabe-, 39, 270
new-, 72
Pfeil-, 69
Shift-, 39
Stern-, 49
typeid-, 245
unärer, 12
Vererbung und Zuweisungs-, 209
Vergleichs-, 47, 49
vordefinierter-, 52
Zuweisungs-, 47, 48, 52, 135, 136, 138
Option, 108, 125
ostringstream, 274
ostrstream, 274
-und ends, 277
Packen, 294
Parameter
konstanter-, 28, 88
Referenz-, 28, 37
Typ-, 163
Wert-, 28, 37
Permutation, 185
Persistenz, 285
Pfeiloperator, 69
Pointer, 60
Polymorphismus, 224
parametrischer-, 252
Präprozessor, 105, 106
Konstante, 109
private, 1, 203
-und Vererbung, 199
Problem des Handlungsreisenden, 186
Programm, 110
-Argumente, 100
-Aufteilung, 112
-Ergebnis, 100
-Status, 100
protected, 201, 203
public, 1, 192, 203
-und Vererbung, 199
putback, 272
r-Wert, 38, 60
ranlib, 125
read, 284
Record, 286
Referenz, 37, 38, 60
-Ergebnis, 37
-Initialisierung, 38
-Parameter, 37
-Variable, 38
Relation
Benutzt-, 53
Programmierung II
366
Enthalt-, 54
Satz, 286
Schablone, 163
-Instanzierung, 164
-und Freunde, 174
Funktions-, 163
Schnittstelle, 4, 41, 231
Seiteneffekt, 138
set, 306
setstate, 266
Sichtbarkeit
-privater Klassenkomponenten, 2
-und Vererbung, 199
Sichtbarkeitsregeln, 4
Sortieren, 165
Speicherallokation, 73
Speicherbereich, 72
statischer-, 72
sstream, 102, 274
Stack, 72
Standardausgabe, 269
Standardbibliothek, 183
Standardeingabe, 269
Standardfehlerausgabe, 269
Stapel, 42, 72, 132
static, 23, 25
static cast, 292
STL, 141, 183
-Abbildung, 185
-Listen, 183
-Vektor, 184
strcmp, 81
strcpy, 81
Stream
String-, 273, 274
String, 276
-Stream, 102, 273, 290
-literal, 80
C-, 80, 101
getline, 269
Konversion von, nach-, 275
stringstream, 274
strlen, 81
strstream, 274
Suchpfad
Bibliotheks-, 125
Verzeichnis-, 125
Syntax
-Baum, 152, 214
Systemaufruf, 263
Template, 163
-Ein- / Ausgabe, 271
-Instanzierung, 164
-Parameter, 165
-Spezialisierung, 166, 169, 170
-und Freunde, 174
-und Vererbung, 252
Datei-, 296
Funktions-, 163, 174, 179, 271
Klassen-, 170
Listen-, 172, 180
Vektor-, 171
Template-Freund, 174
Funktionstemplate als gebundener-, 179, 271
gebundener-, 176, 181
nicht Template als-, 174
ungebundener-, 177
this, 48, 148
type info, 246
typeid, 245
Übersetzungseinheit, 106, 110, 124
getrennte-, 111
UML, 7
Ableitung in-, 192
Abstrakte Basisklasse in-, 231
Aggregation in-, 90
Benutztrelation in-, 54
Klassendiagramm in-, 56
Komposition in-, 54
Templates in-, 174
unget, 272
Variable
externe Bindung einer-, 119
globale-, 72
statische-, 72
vector, 305
Vektor, 305
Verbund, 1
Vererbung, 194, 269
-und Kopierkonstruktor, 207
-und Zuweisung, 209
öffentliche, 203
geschützte-, 203
Implementierungs-, 203
Mehrfach-, 204
private-, 203
Typen der-, 203
Verfugbarkeitsliste, 301
Verweis, 60
virtuell, 223
Wahlfreier Zugriff, 281
Warteschlange, 132
Wiederverwendung, 139
write, 284
Th Letschert, Fachbereich MNI, FH Giessen–Friedberg
Zeiger, 60
-und Felder, 77
-und const, 85
auf Funktionen, 66
Zeigertyp, 60
Zeilenvorschub
-und Dateityp, 290
Zustand, 42
Zuweisung
-und Polymorphismus, 228
-und Vererbung, 209
367
Herunterladen