Sandini Bib
Objektorientiertes Programmieren in C++
Sandini Bib
Programmer’s Choice
Sandini Bib
Nicolai Josuttis
Objektorientiertes Programmieren in C++ Ein Tutorial f¨ur Ein- und Umsteiger 2., aktualisierte und v¨ollig u¨ berarbeitete Auflage
An imprint of Pearson Education München • Boston • San Francisco • Harlow, England Don Mills, Ontario • Sydney • Mexico City Madrid • Amsterdam
Sandini Bib
Die Deutsche Bibliothek – CIP–Einheitsaufnahme Ein Titeldatensatz fur ¨ diese Publikation ist bei Der Deutschen Bibliothek erh¨altlich.
Die Informationen in diesem Produkt werden ohne Ru¨ cksicht auf einen eventuellen Patentschutz ver¨offentlicht. Warennamen werden ohne Gew¨ahrleistung der freien Verwendbarkeit benutzt. Bei der Zusammenstellung von Texten und Abbildungen wurde mit gr¨oßter Sorgfalt vorgegangen. Trotzdem k¨onnen Fehler nicht vollst¨andig ausgeschlossen werden. Verlag, Herausgeber und Autoren k¨onnen f¨ur fehlerhafte Angaben und deren Folgen weder eine juristische Verantwortung noch irgendeine Haftung u¨ bernehmen. F¨ur Verbesserungsvorschl¨age und Hinweise auf Fehler sind Verlag und Herausgeber dankbar. Alle Rechte vorbehalten, auch die der fotomechanischen Wiedergabe und der Speicherung in elektronischen Medien. Die gewerbliche Nutzung der in diesem Produkt gezeigten Modelle und Arbeiten ist nicht zul¨assig. Fast alle Hardware- und Softwarebezeichnungen, die in diesem Buch erw¨ahnt werden, sind gleichzeitig auch eingetragene Warenzeichen oder sollten als solche betrachtet werden. Umwelthinweis: Dieses Buch wurde auf chlorfrei gebleichtem Papier gedruckt. Die Einschrumpffolie – zum Schutz vor Verschmutzung – ist aus umweltvertr¨aglichem und recyclingf¨ahigem PE-Material.
10 9 8 7 6 5 4 3 2 1 03 02 01
ISBN 3-8273-1771-1
c 2001 Addison–Wesley Verlag, ein Imprint der Pearson Education Deutschland GmbH, Martin-Kollar-Straße 10–12, 81829 M¨unchen/Germany Alle Rechte vorbehalten Einbandgestaltung: Christine Rechl, M¨unchen Titelbild: Euphorbia pithyus, Wolfsmilch, c Karl Blossfeld Archiv – Ann und J¨urgen Wilde, Z¨ulpich/VG Bild-Kunst Bonn, 2001 Lektorat: Susanne Spitzer,
[email protected] Korrektorat: Friederike Daenecke, Z¨ulpich Herstellung: TYPisch M¨uller, Arcevia, Italien,
[email protected] Satz: Nicolai Josuttis, Braunschweig Druck und Verarbeitung: K¨osel, Kempten (www.KoeselBuch.de) Printed in Germany
Sandini Bib
Inhaltsverzeichnis Vorwort zur zweiten Auflage
1
Vorwort zur ersten Auflage
2
1
2
¨ Uber dieses Buch
3
1.1
Warum dieses Buch? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
1.2
Voraussetzungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4
1.3
Systematik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4
1.4
Wie liest man dieses Buch? . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6
1.5
Zugriff auf die Quellen zu den Beispielen . . . . . . . . . . . . . . . . . . . . .
6
1.6
Anregungen und Kritik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6
Einleitung: C++ und objektorientierte Programmierung
9
2.1
Die Sprache C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 2.1.1 Designkriterien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 2.1.2 Sprachversionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.2
C++ als objektorientierte Programmiersprache 2.2.1 Objekte, Klassen und Instanzen . . . . 2.2.2 Klassen in C++ . . . . . . . . . . . . 2.2.3 Datenkapselung . . . . . . . . . . . . 2.2.4 Vererbung . . . . . . . . . . . . . . . 2.2.5 Polymorphie . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
10 11 13 15 17 18
2.3
Weitere Konzepte von C++ . . 2.3.1 Ausnahmebehandlung . 2.3.2 Templates . . . . . . . 2.3.3 Namensbereiche . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
20 20 21 22
2.4
Terminologie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
. . . .
. . . .
. . . .
v
. . . .
. . . .
. . . .
. . . .
. . . .
Sandini Bib
vi 3
Inhaltsverzeichnis Grundkonzepte von C++-Programmen . . . . . . .
25
3.1
Das erste Programm . . . . . . 3.1.1 Hallo, Welt!“ . . . . . ” 3.1.2 Kommentare in C++ . 3.1.3 Hauptfunktion main() 3.1.4 Ein-/Ausgaben . . . . . 3.1.5 Namensbereiche . . . . 3.1.6 Zusammenfassung . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
26 26 27 28 29 30 31
3.2
Datentypen, Operatoren, Kontrollstrukturen . . . . . . . . . 3.2.1 Ein erstes Programm, das wirklich etwas berechnet 3.2.2 Fundamentale Datentypen . . . . . . . . . . . . . 3.2.3 Operatoren . . . . . . . . . . . . . . . . . . . . . . 3.2.4 Kontrollstrukturen . . . . . . . . . . . . . . . . . . 3.2.5 Zusammenfassung . . . . . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
33 33 36 39 45 49
3.3
Funktionen und Module . . . . . . . . . . . 3.3.1 Headerdateien . . . . . . . . . . . . 3.3.2 Quelldatei mit der Implementierung 3.3.3 Quelldatei mit dem Aufruf . . . . . ¨ 3.3.4 Ubersetzen und binden . . . . . . . 3.3.5 Dateiendungen . . . . . . . . . . . 3.3.6 Systemdateien . . . . . . . . . . . . 3.3.7 Pr¨aprozessor . . . . . . . . . . . . . 3.3.8 Namensbereiche . . . . . . . . . . . 3.3.9 Das Schl¨usselwort static . . . . . 3.3.10 Zusammenfassung . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
50 50 52 52 54 54 55 55 59 60 62
3.4
Strings 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5
. . . . . . . . . . . . . . . . . . . . . . . . . . . . Ein erstes einfaches Beispielprogramm mit Strings Ein weiteres Beispielprogramm mit Strings . . . . ¨ String-Operationen im Uberblick . . . . . . . . . . Strings und C-Strings . . . . . . . . . . . . . . . . Zusammenfassung . . . . . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
63 63 66 71 72 72
3.5
Verarbeitung von Mengen . . . . . . . . . . . . . . . . 3.5.1 Beispielprogramm mit Vektoren . . . . . . . . 3.5.2 Beispielprogramm mit Deques . . . . . . . . . 3.5.3 Vektor versus Deque . . . . . . . . . . . . . . 3.5.4 Iteratoren . . . . . . . . . . . . . . . . . . . . 3.5.5 Beispielprogramm mit einer Liste . . . . . . . 3.5.6 Beispielprogramme mit assoziativen Containern 3.5.7 Algorithmen . . . . . . . . . . . . . . . . . . . 3.5.8 Algorithmen mit mehreren Bereichen . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
74 74 76 77 78 80 81 86 90
. . . . . . . . . . .
. . . . . . .
. . . . . . . . . . .
. . . . . . .
. . . . . . . . . . .
. . . . . . .
. . . . . . . . . . .
. . . . . . .
. . . . . . . . . . .
. . . . . . .
. . . . . . . . . . .
. . . . . . .
. . . . . . . . . . .
. . . . . . . . .
. . . . . . . . .
Sandini Bib
Inhaltsverzeichnis
vii
3.5.9 Stream-Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94 3.5.10 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96 3.5.11 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
4
3.6
Ausnahmebehandlung . . . . . . . . . . . . . . . . . . . . . . 3.6.1 Motivation f¨ur das Konzept der Ausnahmebehandlung . 3.6.2 Das Konzept der Ausnahmebehandlung . . . . . . . . 3.6.3 Standard-Ausnahmeklassen . . . . . . . . . . . . . . . 3.6.4 Ausnahmebehandlung am Beispiel . . . . . . . . . . . 3.6.5 Behandlung nicht abgefangener Ausnahmen . . . . . . 3.6.6 Hilfsfunktionen zur Behandlung von Ausnahmen . . . 3.6.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
98 98 100 101 101 106 107 108
3.7
Zeiger, Felder und C-Strings 3.7.1 Zeiger . . . . . . . 3.7.2 Felder . . . . . . . 3.7.3 C-Strings . . . . . 3.7.4 Zusammenfassung .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
110 110 112 115 119
3.8
Freispeicherverwaltung mit new und delete . . . . 3.8.1 Der Operator new . . . . . . . . . . . . . . 3.8.2 Der Operator delete . . . . . . . . . . . . 3.8.3 Dynamische Speicherverwaltung f¨ur Felder 3.8.4 Fehlerbehandlung f¨ur new . . . . . . . . . . 3.8.5 Zusammenfassung . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
120 121 121 122 124 125
3.9
Kommunikation mit der Außenwelt . . . . . . 3.9.1 Argumente aus dem Programmaufruf . 3.9.2 Zugriff auf Umgebungsvariablen . . . 3.9.3 Abbruch von Programmen . . . . . . 3.9.4 Aufruf von weiteren Programmen . . 3.9.5 Zusammenfassung . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
126 126 127 128 129 129
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . . .
. . . . .
. . . . . .
. . . . . .
Programmieren von Klassen 4.1
Die erste Klasse: Bruch . . . . . . . . . . . . 4.1.1 Vor¨uberlegungen zur Implementierung 4.1.2 Deklaration der Klasse Bruch . . . . 4.1.3 Die Klassenstruktur . . . . . . . . . . 4.1.4 Elementfunktionen . . . . . . . . . . 4.1.5 Konstruktoren . . . . . . . . . . . . . ¨ 4.1.6 Uberladen von Funktionen . . . . . . 4.1.7 Implementierung der Klasse Bruch . . 4.1.8 Anwendung der Klasse Bruch . . . . 4.1.9 Erzeugung tempor¨arer Objekte . . . . 4.1.10 UML-Notation . . . . . . . . . . . . 4.1.11 Zusammenfassung . . . . . . . . . . .
131 . . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
. . . . . . . . . . . .
132 132 135 136 139 139 141 142 147 153 153 154
Sandini Bib
viii
Inhaltsverzeichnis 4.2
Operatoren f¨ur Klassen . . . . . . . . . . . . . . . . . 4.2.1 Deklaration von Operatorfunktionen . . . . . 4.2.2 Implementierung von Operatorfunktionen . . 4.2.3 Anwendung von Operatorfunktionen . . . . . 4.2.4 Globale Operatorfunktionen . . . . . . . . . 4.2.5 Grenzen bei der Definition eigener Operatoren 4.2.6 Besonderheiten spezieller Operatoren . . . . 4.2.7 Zusammenfassung . . . . . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
156 156 159 166 168 169 170 174
4.3
Laufzeit- und Codeoptimierungen . . . . . . . . . . 4.3.1 Die Klasse Bruch mit ersten Optimierungen 4.3.2 Default-Argumente . . . . . . . . . . . . . 4.3.3 Inline-Funktionen . . . . . . . . . . . . . . 4.3.4 Optimierungen aus Anwendersicht . . . . . 4.3.5 Using-Direktiven . . . . . . . . . . . . . . 4.3.6 Deklarationen zwischen Anweisungen . . . 4.3.7 Copy-Konstruktoren . . . . . . . . . . . . . 4.3.8 Zusammenfassung . . . . . . . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
175 175 178 180 182 183 185 186 187
4.4
Referenzen und Konstanten . . . . . . . . . . . . . 4.4.1 Copy-Konstruktor und Parameter¨ubergabe . 4.4.2 Referenzen . . . . . . . . . . . . . . . . . 4.4.3 Konstanten . . . . . . . . . . . . . . . . . 4.4.4 Konstanten-Elementfunktionen . . . . . . . 4.4.5 Die Klasse Bruch mit Referenzen . . . . . 4.4.6 Zeiger auf Konstanten und Zeigerkonstanten 4.4.7 Zusammenfassung . . . . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
189 189 190 193 195 195 199 201
4.5
Ein- und Ausgabe mit Streams . . . . . . . . . 4.5.1 Streams . . . . . . . . . . . . . . . . 4.5.2 Umgang mit Streams . . . . . . . . . 4.5.3 Zustand von Streams . . . . . . . . . 4.5.4 I/O-Operatoren f¨ur eigene Datentypen 4.5.5 Zusammenfassung . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
202 202 203 210 212 223
4.6
Freunde und andere Typen . . . . . . . . . . . . . . . . . . 4.6.1 Automatische Typumwandlungen . . . . . . . . . . 4.6.2 Schl¨usselwort explicit . . . . . . . . . . . . . . 4.6.3 Friend-Funktionen . . . . . . . . . . . . . . . . . 4.6.4 Konvertierungsfunktionen . . . . . . . . . . . . . . 4.6.5 Probleme bei der automatischen Typumwandlung . 4.6.6 Andere Anwendungen des Schl¨usselworts friend . 4.6.7 friend kontra objektorientierte Programmierung . 4.6.8 Zusammenfassung . . . . . . . . . . . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
224 224 226 227 234 235 238 239 240
. . . . . .
. . . . . .
. . . . . .
Sandini Bib
Inhaltsverzeichnis 4.7
5
ix
Ausnahmebehandlung f¨ur Klassen . . . . . . . . . . . . . . . . . . . . 4.7.1 Motivation f¨ur eine Ausnahmebehandlung in der Klasse Bruch 4.7.2 Ausnahmebehandlung am Beispiel der Klasse Bruch . . . . . 4.7.3 Fehlerklassen . . . . . . . . . . . . . . . . . . . . . . . . . . 4.7.4 Weitergabe von Fehlern . . . . . . . . . . . . . . . . . . . . . 4.7.5 Ausnahmen in Destruktoren . . . . . . . . . . . . . . . . . . 4.7.6 Ausnahmen in Schnittstellen-Deklarationen . . . . . . . . . . 4.7.7 Hierarchien von Fehlerklassen . . . . . . . . . . . . . . . . . 4.7.8 Design von Fehlerklassen . . . . . . . . . . . . . . . . . . . . 4.7.9 Standard-Ausnahmen ausl¨osen . . . . . . . . . . . . . . . . . 4.7.10 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
Vererbung und Polymorphie
241 241 242 250 251 251 252 253 257 258 259 261
5.1
Einfache Vererbung . . . . . . . . . . . . . . . . . . . . 5.1.1 Die Klasse Bruch als Basisklasse . . . . . . . . . 5.1.2 Vor¨uberlegungen zur abgeleiteten Klasse KBruch 5.1.3 Deklaration der abgeleiteten Klasse KBruch . . . 5.1.4 Vererbung und Konstruktoren . . . . . . . . . . . 5.1.5 Implementierung von abgeleiteten Klassen . . . . 5.1.6 Anwendung von abgeleiteten Klassen . . . . . . 5.1.7 Konstruktoren f¨ur Objekte der Basisklasse . . . . 5.1.8 Zusammenfassung . . . . . . . . . . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
263 263 266 267 270 273 276 278 279
5.2
Virtuelle Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . ¨ 5.2.1 Probleme beim Uberschreiben von Funktionen der Basisklasse 5.2.2 Statisches und dynamisches Binden von Funktionen . . . . . . ¨ ¨ 5.2.3 Uberladen kontra Uberschreiben . . . . . . . . . . . . . . . . 5.2.4 Zugriff auf Parameter der Basisklasse . . . . . . . . . . . . . 5.2.5 Virtuelle Destruktoren . . . . . . . . . . . . . . . . . . . . . . 5.2.6 Vererbung richtig angewendet . . . . . . . . . . . . . . . . . ¨ 5.2.7 Weitere Fallen beim Uberschreiben von Funktionen . . . . . . 5.2.8 Private Vererbung und reine Zugriffsdeklarationen . . . . . . . 5.2.9 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
280 280 283 288 289 290 291 297 299 303
5.3
Polymorphie . . . . . . . . . . . . . . . . . . . . . . . 5.3.1 Was ist Polymorphie? . . . . . . . . . . . . . . 5.3.2 Polymorphie in C++ . . . . . . . . . . . . . . . 5.3.3 Polymorphie in C++ an einem Beispiel . . . . . 5.3.4 Die abstrakte Basisklasse GeoObj . . . . . . . 5.3.5 Anwendung von Polymorphie in Klassen . . . . 5.3.6 Polymorphie ist keine feste Fallunterscheidung 5.3.7 R¨uckumwandlung eines Objekts in seine Klasse 5.3.8 Design by Contract . . . . . . . . . . . . . . . 5.3.9 Zusammenfassung . . . . . . . . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
304 304 305 306 310 319 325 326 330 331
. . . . . . . . . .
. . . . . . . . .
. . . . . . . . . .
. . . . . . . . .
. . . . . . . . . .
. . . . . . . . .
. . . . . . . . . .
. . . . . . . . .
. . . . . . . . . .
. . . . . . . . .
. . . . . . . . . .
. . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
Sandini Bib
x
6
Inhaltsverzeichnis 5.4
Mehrfachvererbung . . . . . . . . . . . . . . . . 5.4.1 Beispiel f¨ur Mehrfachvererbung . . . . 5.4.2 Virtuelle Basisklassen . . . . . . . . . . 5.4.3 Das Problem der Identit¨at . . . . . . . . 5.4.4 Dieselbe Basisklasse mehrfach ableiten 5.4.5 Zusammenfassung . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
333 333 339 343 346 347
5.5
Design-Fallen bei der Vererbung . . . . . . . . . . . . . 5.5.1 Vererbung kontra Verwendung . . . . . . . . . 5.5.2 Design-Fehler: Einschr¨ankende Vererbung . . . 5.5.3 Design-Fehler: Wertver¨andernde Vererbung . . 5.5.4 Design-Fehler: Wertinterpretierende Vererbung 5.5.5 Vermeide Vererbung!“ . . . . . . . . . . . . . ” 5.5.6 Zusammenfassung . . . . . . . . . . . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
. . . . . . .
348 348 348 350 351 352 353
Dynamische und statische Komponenten
355
6.1
Dynamische Komponenten . . . . . . . . . . . . . . . . . . . . . . . . . 6.1.1 Implementierung der Klasse String . . . . . . . . . . . . . . . 6.1.2 Konstruktoren bei dynamischen Komponenten . . . . . . . . . . 6.1.3 Implementierung eines Copy-Konstruktors . . . . . . . . . . . . 6.1.4 Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.1.5 Implementierung des Zuweisungsoperators . . . . . . . . . . . 6.1.6 Weitere Operatoren . . . . . . . . . . . . . . . . . . . . . . . . 6.1.7 Einlesen eines Strings . . . . . . . . . . . . . . . . . . . . . . . 6.1.8 Kommerzielle Implementierung von String-Klassen . . . . . . . 6.1.9 Weitere Anwendungsm¨oglichkeiten dynamischer Komponenten 6.1.10 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
. . . . . . . . . . .
356 356 362 364 364 365 367 370 372 374 376
6.2
Weitere Aspekte dynamischer Komponenten . . . . . . . . . . . 6.2.1 Dynamische Komponenten bei konstanten Objekten . . . 6.2.2 Konvertierungsfunktionen f¨ur dynamische Komponenten 6.2.3 Konvertierungsfunktionen f¨ur Bedingungen . . . . . . . 6.2.4 Konstanten werden zu Variablen . . . . . . . . . . . . . 6.2.5 Vordefinierte Funktionen verbieten . . . . . . . . . . . . 6.2.6 Proxy-Klassen . . . . . . . . . . . . . . . . . . . . . . . 6.2.7 Ausnahmebehandlung mit Parametern . . . . . . . . . . 6.2.8 Zusammenfassung . . . . . . . . . . . . . . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
. . . . . . . . .
377 377 380 382 385 388 389 391 395
6.3
Vererbung von Klassen mit dynamischen Komponenten . . . . . . . . 6.3.1 Die Klasse Bsp::String als Basisklasse . . . . . . . . . . . 6.3.2 Die abgeleitete Klasse FarbString . . . . . . . . . . . . . . 6.3.3 Ableiten von Friend-Funktionen . . . . . . . . . . . . . . . . 6.3.4 Quelldatei der abgeleiteten Klasse FarbString . . . . . . . . 6.3.5 Anwendung der Klasse FarbString . . . . . . . . . . . . . . 6.3.6 Ableiten der Spezialfunktionen f¨ur dynamische Komponenten 6.3.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
397 397 399 402 405 406 407 409
. . . . . . . . .
. . . . . . . . .
Sandini Bib
Inhaltsverzeichnis
7
xi
6.4
Klassen verwenden Klassen . . . . . . . . . . . . 6.4.1 Objekte als Komponenten anderer Klassen 6.4.2 Implementierung der Klasse Person . . . 6.4.3 Zusammenfassung . . . . . . . . . . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
410 410 410 417
6.5
Statische Komponenten und Hilfstypen . . . . . . . . . . 6.5.1 Statische Klassenkomponenten . . . . . . . . . . 6.5.2 Typdeklarationen innerhalb von Klassen . . . . . 6.5.3 Aufz¨ahlungstypen als statische Klassenkonstanten 6.5.4 Eingebettete und lokale Klassen . . . . . . . . . 6.5.5 Zusammenfassung . . . . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
418 418 424 427 428 429
Templates
431
7.1
Motivation f¨ur Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 431 7.1.1 Terminologie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 432
7.2
Funktionstemplates . . . . . . . . . . . . . . . . . . . . . 7.2.1 Definition von Funktionstemplates . . . . . . . . 7.2.2 Aufruf von Funktionstemplates . . . . . . . . . . 7.2.3 Praktische Hinweise zum Umgang mit Templates 7.2.4 Automatische Typumwandlung bei Templates . . ¨ 7.2.5 Uberladen von Templates . . . . . . . . . . . . . 7.2.6 Lokale Variablen . . . . . . . . . . . . . . . . . 7.2.7 Zusammenfassung . . . . . . . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
433 433 434 435 435 436 439 439
7.3
Klassentemplates . . . . . . . . . . . . . . . . . . . . 7.3.1 Implementierung des Klassentemplate Stack 7.3.2 Anwendung des Klassentemplate Stack . . . 7.3.3 Spezialisieren von Klassentemplates . . . . . 7.3.4 Default Template-Parameter . . . . . . . . . 7.3.5 Zusammenfassung . . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
440 440 444 445 448 451
7.4
Werte als Template-Parameter . . . . . . . . . . . . . . . . 7.4.1 Beispiel f¨ur die Verwendung von Werte-Parametern 7.4.2 Einschr¨ankungen bei Werte-Parametern . . . . . . 7.4.3 Zusammenfassung . . . . . . . . . . . . . . . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
452 452 454 455
7.5
Weitere Aspekte von Templates . . . . . . . . 7.5.1 Das Schl¨usselwort typename . . . . . 7.5.2 Komponenten als Templates . . . . . 7.5.3 Statische Polymorphie mit Templates . 7.5.4 Zusammenfassung . . . . . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
456 456 457 460 464
7.6
Templates in der Praxis . . . . . . . . ¨ 7.6.1 Ubersetzen von Template-Code 7.6.2 Fehlerbehandlung . . . . . . . 7.6.3 Zusammenfassung . . . . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
465 465 470 472
. . . .
. . . .
. . . .
. . . .
. . . . . .
. . . . . .
Sandini Bib
xii 8
9
Inhaltsverzeichnis Die Standard-I/O-Bibliothek im Detail
473
8.1
Die Standard-Stream-Klassen . . . . . . . . . 8.1.1 Stream-Klassen und -Objekte . . . . . 8.1.2 Fehlerzust¨ande, Stream-Status . . . . 8.1.3 Standardoperatoren . . . . . . . . . . 8.1.4 Standardfunktionen . . . . . . . . . . 8.1.5 Manipulatoren . . . . . . . . . . . . . 8.1.6 Formatdefinitionen . . . . . . . . . . 8.1.7 Setzen und Abfragen von Formatflags 8.1.8 Internationalisierung . . . . . . . . . 8.1.9 Zusammenfassung . . . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
474 474 476 479 480 484 486 486 496 499
8.2
Dateizugriff . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8.2.1 Stream-Klassen f¨ur Dateien . . . . . . . . . . . . . . . . . . 8.2.2 Beispiel f¨ur die Verwendung der Stream-Klassen f¨ur Dateien 8.2.3 Datei-Flags . . . . . . . . . . . . . . . . . . . . . . . . . . ¨ 8.2.4 Explizites Offnen und Schließen . . . . . . . . . . . . . . . 8.2.5 Wahlfreier Zugriff . . . . . . . . . . . . . . . . . . . . . . . 8.2.6 Umleiten der Standardkan¨ale in Dateien . . . . . . . . . . . 8.2.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
. . . . . . . .
500 500 500 503 504 506 508 510
8.3
Stream-Klassen f¨ur Strings . . . 8.3.1 String-Stream-Klassen 8.3.2 Lexical-Cast-Operator . 8.3.3 char*-Stream-Klassen 8.3.4 Zusammenfassung . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . . . . . . .
. . . . .
. . . . . . . . . .
. . . . .
. . . . . . . . . .
. . . . .
. . . . . . . . . .
. . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
511 511 514 516 518
9.1
Weitere Details zur Standardbibliothek . . . . . . . . . 9.1.1 Operationen von Vektoren . . . . . . . . . . . 9.1.2 Gemeinsame Operationen aller STL-Container . 9.1.3 Liste aller STL-Algorithmen . . . . . . . . . . 9.1.4 Numerische Limits . . . . . . . . . . . . . . . 9.1.5 Zusammenfassung . . . . . . . . . . . . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
520 520 526 528 533 538
9.2
Definition besonderer Operatoren 9.2.1 Smart-Pointer . . . . . . 9.2.2 Funktionsobjekte . . . . 9.2.3 Zusammenfassung . . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
539 539 543 547
9.3
Weitere Aspekte von new und delete . . . . . . . 9.3.1 Nothrow-Versionen von new und delete 9.3.2 Placement-New . . . . . . . . . . . . . . 9.3.3 New-Handler . . . . . . . . . . . . . . . ¨ 9.3.4 Uberladen von new und delete . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
548 548 548 549 554
Weitere Sprachmittel und Details
519
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
. . . .
Sandini Bib
Inhaltsverzeichnis 9.3.5 9.3.6
xiii Operator new mit zus¨atzlichen Parametern . . . . . . . . . . . . . . . . 557 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 558
9.4
Funktions- und Komponentenzeiger . . . . . . . . . . . . 9.4.1 Funktionszeiger . . . . . . . . . . . . . . . . . . 9.4.2 Komponentenzeiger . . . . . . . . . . . . . . . . 9.4.3 Komponentenzeiger f¨ur Schnittstellen nach außen 9.4.4 Zusammenfassung . . . . . . . . . . . . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
559 559 560 563 565
9.5
Kombination mit C-Code . . . . . . 9.5.1 Externe Bindung . . . . . . 9.5.2 Headerdateien f¨ur C und C++ ¨ 9.5.3 Ubersetzen von main() . . . 9.5.4 Zusammenfassung . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
566 566 567 567 567
9.6
Weitere Schl¨usselw¨orter . . . . . . . 9.6.1 Varianten mit union . . . . 9.6.2 Aufz¨ahlungstypen mit enum 9.6.3 Das Schl¨usselwort volatile 9.6.4 Zusammenfassung . . . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
. . . . .
568 568 568 570 570
10 Zusammenfassung
571
10.1 Hierarchie der C++-Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . . 571 10.2 Klassenspezifische Eigenschaften von Operationen . . . . . . . . . . . . . . . . 574 10.3 Regeln zur automatischen Typumwandlung . . . . . . . . . . . . . . . . . . . . 575 10.4 Sinnvolle Programmierrichtlinien und Konventionen . . . . . . . . . . . . . . . 575 Literaturverzeichnis
579
Glossar
583
Index
589
Sandini Bib
Sandini Bib
Vorwort zur zweiten Auflage Eigentlich wollte ich das Buch ja nur mal eben auf den aktuellen Stand bringen (nach sieben Jahren wird es ja auch wirklich mal Zeit daf¨ur). Es sollte alle Eigenschaften und Vorteile des C++-Standards verwenden und nicht mehr auf C als Voraussetzung aufsetzen. ¨ Herausgekommen ist eine vollst¨andige Uberarbeitung dieses Buchs. Sie verdeutlicht, dass der neue C++-Standard auch zu einer neuen Form der Programmierung mit C++ gef¨uhrt hat. Statt Zeiger und Felder braucht man nun zun¨achst einmal Strings und Container. Somit richtet sich dieses Buch nun an alle Einsteiger und Umsteiger, die vom Potential der standardisierten Sprache C++ und ihrer Standardbibliothek profitieren m¨ochten. Im ersten Teil wird verdeutlicht, wie man in C++ als Anwendungsprogrammierer Klassen zu einem Programm kombinieren kann. Im zweiten Teil werden dann alle Aspekte zur Implementierung eigener Klassen und Klassenhierarchien vorgestellt. Abgerundet wird das Buch durch ein umfangreiches Kapitel u¨ ber Templates, das deren Potential – auch im Rahmen der objektorientierten Programmierung – verdeutlicht. Ich w¨unsche allen Lesern so viel Spaß, wie ich beim Schreiben gehabt habe.
Danke Erneut m¨ochte ich allen Personen danken, die durch unterschiedlichstes Feedback zu dieser ¨ Uberarbeitung beigetragen haben. Dazu geh¨oren vor allem viele Reviewer, die den neuen Text schon vorab gelesen haben und maßgeblich zur Qualit¨at des Buchs beigetragen haben. Vielen Dank an Johannes Hampel, Peter Heilscher, Olaf Janßen, Jan Langer, Bernd Mohr, Olaf Petzold, Michael Pitz, Andr´e P¨onitz, Daniel Rakel, Kurt Watzka, Andreas Wetjen und Sascha Zapf. Dazu geh¨oren aber auch die vielen Leser, die mir R¨uckmeldungen zur vorherigen Auflage gegeben haben. Ein geh¨origer Dank geb¨uhrt auch meinen Ansprechpartnern von Addison Wesley. Susanne ¨ Spitzer, Margrit Seifert und Friederike Daenecke haben mich bei der Realisierung dieser Uberarbeitung maßgeblich geholfen. Schließlich danke ich nat¨urlich meiner Familie, die dieses Projekt wie immer mit viel Geduld und Verst¨andnis unterst¨utzt haben (auch wenn ich, wie beim Formulieren dieser Zeilen, mal wieder zu sp¨at zum Essen kam). Ganz herzlichen Dank, Ulli, Lucas, Anica und Frederic. Hondelage, April 2001 Nicolai Josuttis
1
Sandini Bib
Vorwort zur ersten Auflage Lang, lang hat es gedauert, bis dieses Buch fertig geworden ist. Aber nun ist es doch so weit. Ich habe meine Erfahrungen mit C++ und der objektorientierten Programmierung, die ich in mehrj¨ahriger Projekt- und Schulungsarbeit gewonnen habe, in einem Buch f¨ur C++-Programmierer festgehalten. ¨ Dabei hat der versp¨atete Termin der Fertigstellung den Vorteil, dass die neuesten Anderungen der Sprachspezifikation in das Buch integriert werden konnten. Außerdem konnten die wesentlichen Sprachmittel, Templates und Ausnahmebehandlung, mit getesteten Beispielen aufgezeigt und durch praktische Erfahrungen erg¨anzt werden.
Danke Ich m¨ochte mich hiermit bei allen Personen bedanken, die mir dabei geholfen haben, dieses Buch zu schreiben. Besonders danke ich den Mitarbeitern der Firma BREDEX. Achim Brede, Ulrich Obst, Achim L¨orke, Bernhard Witte und die anderen Mitarbeiter haben mir mit viel zeitlichem und materiellen Aufwand sehr geholfen. Weiter m¨ochte ich allen Personen danken, mit denen ich Themen und Inhalt des Buchs diskutiert habe. Dazu geh¨oren insbesondere zahlreiche Personen, die das Buch zur Probe gelesen und wertvolle Korrekturhinweise gegeben haben (ich kann sie gar nicht alle aufz¨ahlen). Dazu geh¨oren aber auch sehr, sehr viele Personen, mit denen ich zum Teil weltweit (¨uber das UNIX-Netzwerk Internet) verschiedene Aspekte der Sprache diskutiert habe. Außerdem danke ich Susanne Spitzer und Judith Muhr von Addison-Wesley f¨ur die konstruktive Zusammenarbeit und die große Geduld. Schließlich danke ich meiner Familie, Ulli, Lucas und Anica, denen ich aufgrund dieses Buchs eine Weile viel zu selten zur Verf¨ugung stand. Braunschweig, M¨arz 1994 Nicolai Josuttis
2
Sandini Bib
Kapitel 1
¨ Uber dieses Buch Dieses Kapitel erl¨autert die Motivation und den konzeptionellen Ansatz dieses Buchs und gibt ¨ einen Uberblick u¨ ber den Inhalt.
1.1 Warum dieses Buch? Objektorientiert“, das ist das Schlagwort, das heutzutage zu jeder guten Software geh¨ort. C++ ” hat mit zu diesem Durchbruch von Objektorientierung beigetragen. Doch ist C++ keine reine objektorientierte Sprache. Als Weiterentwicklung von C handelt es sich um einen Sprach-Hybriden, der objektorientierte und nicht-objektorientierte Programmierung erm¨oglicht. Diese Flexibilit¨at hat Vor- und Nachteile: Durch die Unterst¨utzung verschiedener Programmierparadigmen kann man mit Hilfe von C++ auf fast jedes Problem angemessen reagieren. Daf¨ur f¨allt es Einstei¨ gern und Umsteigern schwer, einen Uberblick u¨ ber die angemessene Verwendung von C++ zu erhalten. Dieses Buch f¨uhrt bewusst in die objektorientierte Programmierung mit C++“ ein. C++ ” soll in der vollen M¨achtigkeit des objektorientierten Paradigmas genutzt werden. Neben der Einf¨uhrung neuer Sprachmittel bedeutet das f¨ur viele Leser auch, dass Sie eine neue Begriffswelt und ein neues Denken kennen lernen werden. Deshalb werden die verschiedenen objektorientierten Sprachmittel mit entsprechenden Problemen aus der Praxis motiviert und durch die Diskussion von Design-Aspekten abgerundet. Doch das ist nicht alles. Das Buch stellt auch die wesentlichen nicht-objektorientierten Elemente von C++ vor. Auch diese haben in der Praxis ihre Berechtigung. So stellt z.B. das Sprachmittel der Templates ein wesentliches Konzept f¨ur generische Probleme dar. Auch darauf wird mit entsprechenden Beispielen ausf¨uhrlich eingegangen. Außerdem werden ausgew¨ahlte Komponenten aus der Standardbibliothek vorgestellt. Das Ziel besteht darin, den Leser Schritt f¨ur Schritt in die Welt von C++ einzuf¨uhren und dabei vor allem auf eine objektorientierte Sicht der Sprachmittel zu achten. Dabei besteht trotzdem der Anspruch auf Vollst¨andigkeit. Es werden also alle f¨ur die Praxis relevanten Sprachmittel vorgestellt.
3
Sandini Bib
¨ Kapitel 1: Uber dieses Buch
4
1.2 Voraussetzungen Voraussetzung f¨ur das Verst¨andnis des Buchs ist es, dass der Leser mit den Konzepten h¨oherer Programmiersprachen vertraut ist. Begriffe wie Schleife, Prozedur oder Funktion, Anweisung, Variable und so weiter werden als bekannt vorausgesetzt. Kenntnisse der Konzepte der objektorientierten Programmierung sind nicht notwendig. Diese Konzepte werden implizit bei der Vorstellung der Sprachmittel, die diese Konzepte in C++ realisieren, vermittelt. Sicherlich sind vorhandene Kenntnisse der objektorientierten Konzepte aber von Vorteil, da das Buch diese zwangsl¨aufig ein wenig durch die C++-Brille betrachtet. Den Lesern, die bereits C oder Java kennen, werden vor allem die fundamentalen Sprachmittel bereits bekannt sein. Doch Vorsicht, auch hier gibt es im Detail relevante Unterschiede, auf die jeweils auch explizit hingewiesen wird.
1.3 Systematik Der große Vorteil von C++ besteht in der M¨oglichkeit, dass man fast jede Art von Datentypen, Klassen, Funktionen (Prozeduren) oder Bibliotheken implementieren kann. Damit kann man f¨ur fast jede Problematik eine geeignete einfache Schnittstelle definieren. Dies hat den Vorteil, dass Teilprobleme nur einmal gel¨ost und Dienstleistungen nur einmal implementiert werden m¨ussen. Anwendungs- und Systemprogrammierer Aus diesem Grund kann man C++ aus verschiedenen Blickwinkeln betrachten:
Auf der einen Seite gibt es die Sichtweise der Anwendungsprogrammierer, die Datentypen und Klassen mit Hilfe von Funktionen und Kontrollstrukturen zu laufenden Programmen kombinieren. Auf der anderen Seite gibt es die Sichtweise der Systemprogrammierer, die neue Schnittstellen in Form von Funktionen, Datentypen und Klassen zur Verf¨ugung stellen.
F¨ur die Sichtweise der Systemprogrammierer sollte man alle Details der Sprache kennen und beherrschen. F¨ur die Sichtweise der Anwendungsprogrammierer reicht es, die Schnittstellen zu kennen und einfach anzuwenden. Das vorliegende Buch ist entsprechend der Bandbreite dieser beiden Sichtweisen organisiert. Wir bewegen uns didaktisch immer weiter von der Sichtweise des Anwendungsprogrammierers zur Sichtweise des Systemprogrammierers. Nat¨urlich lassen sich diese beiden Sichtweisen in der Praxis nicht v¨ollig trennen. Dies gilt insbesondere deshalb, weil man im Zuge der Abstrahierung von Problemen immer wieder Datentypen und Klassen verwendet, um h¨ohere Datentypen und Klassen zu programmieren. Doch wird im Buch darauf geachtet, dass man zun¨achst die generellen Konzepte und die Anwendungssicht kennen lernt. Die hinter den Datentypen und Klassen verborgene Flexibilit¨at, die C++ so wertvoll macht, wird erst nach und nach erl¨autert.
Sandini Bib
1.3 Systematik
5
Aufbau des Buchs Der Aufbau des Buchs basiert konzeptionell auf zahlreichen Schulungen, die ich zu diesem Thema bereits gehalten habe. Dabei wird grunds¨atzlich darauf geachtet, dass keine spezielle Sprachversion, sondern die Sprache anhand ihrer derzeitigen Spezifikation vorgestellt wird. Im Einzelnen behandeln die nachfolgenden Kapitel folgende Themen:
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung bietet eine prinzipielle Einf¨uhrung in das Thema und f¨uhrt die Grundbegriffe ein, die f¨ur das Verst¨andnis von C++ erforderlich sind. Kapitel 3: Grundkonzepte von C++-Programmen stellt die Grundkonzepte von C++ aus dem Blickwinkel der Anwendungsprogrammierer vor. Neben der Einf¨uhrung der fundamentalen Datentypen, Funktionen, Operatoren und Kontrollstrukturen werden auch die wichtigsten Komponenten der Standardbibliothek eingef¨uhrt. Kapitel 4: Programmieren mit Klassen stellt die beiden objektorientierten Grundkonzepte Klassen und Datenkapselung mit allen damit verbundenen Sprachelementen vor. Kapitel 5: Vererbung und Polymorphie stellt die objektorientierten Grundkonzepte Vererbung und Polymorphie mit allen damit verbundenen Sprachelementen vor. Kapitel 6: Dynamische und statische Komponenten stellt alle speziellen Aspekte vor, die bei der Programmierung komplizierterer Klassen mit dynamischen und statischen Komponenten beachtet werden m¨ussen. Kapitel 7: Templates stellt alle Aspekte der generischen Programmierung mit Hilfe von Templates vor. Kapitel 8: Die Standard-I/O-Bibliothek im Detail beschreibt die Standardbibliothek f¨ur Ein- und Ausgaben im Detail. Dazu geh¨ort auch der Umgang mit Dateien. Kapitel 9: Weitere Sprachmittel und Details geht auf alle dann noch offenen und f¨ur die Praxis wichtigen Sprachmittel und Standardkomponenten von C++ ein. Kapitel 10: Zusammenfassung enth¨alt eine zusammenfassende Schlussbetrachtung von C++. Im Anhang befinden sich: – ein Literaturverzeichnis – ein Glossar – ein Index
Sandini Bib
¨ Kapitel 1: Uber dieses Buch
6
1.4 Wie liest man dieses Buch? Da dieses Buch alle Sprachmittel und deren Anwendung nach und nach einf¨uhrt, sollte der Leser, der in die Thematik einsteigt, das Buch einfach von vorn bis hinten durchlesen. Vorw¨artsverweise ließen sich allerdings nicht ganz vermeiden, sollten aber kein allzu großes Problem darstellen. Gerade f¨ur den Leser, der neu in die Materie einsteigt, stellt sich das Problem, dass eine F¨ulle von neuen Begriffen eingef¨uhrt und verwendet wird, die erst einmal verstanden und auseinander gehalten werden m¨ussen. Dabei kommt erschwerend hinzu, dass es bei der objektorientierten Programmierung und speziell in C++ eine gewisse Begriffsverwirrung gibt. Das Glossar im Anhang soll hier Klarheit verschaffen. Es erl¨autert die wichtigsten Begriffe und stellt auch gleich andere deutsche und englischsprachige Bezeichnungen f¨ur den gleichen Sachverhalt vor, um Querverweise zu anderer Literatur zu erleichtern. Leser, die sofort in die eigentliche objektorientierte Programmierung mit Klassen einsteigen wollen, k¨onnen auch sofort mit Kapitel 4 beginnen. Dieser Teil ist der Einstieg in die Sichtweise der Systemprogrammierer und betrachtet die dazugeh¨origen Sprachmittel auch unter dem Blickwinkel ihrer Verwendung. Insofern wird die prinzipielle Sicht der Anwendungsprogrammierer noch einmal mit entsprechendem Hintergrund erl¨autert. Wer das Buch sp¨ater oder von vornherein als Nachschlagewerk f¨ur die Programmierung mit C++ verwendet, kann am besten u¨ ber das Inhaltsverzeichnis oder u¨ ber den Index Einstiegspunkte f¨ur die einzelnen Themenbereiche finden. Ich habe mich bem¨uht, beides relativ ausf¨uhrlich zu gestalten. Wie vermeidet man es, das Buch zu lesen? Diese vielleicht etwas unerwartete Frage bezieht sich auf die Tatsache, dass viele Menschen einen Sachverhalt lieber anhand eines Beispiels als durch trockenes Lesen eines Buchs lernen (ich geh¨ore auch dazu). Diesen Lesern empfehle ich, die einzelnen Beispiele durchzugehen. Außerdem besitzt jeder Abschnitt, in dem neue Sprachmittel eingef¨uhrt werden, am Ende eine kurze Zusammenfassung, die man auch dazu verwenden kann, um zu entscheiden, ob das Kapitel im Detail gelesen werden sollte.
1.5 Zugriff auf die Quellen zu den Beispielen Die Quellen der wichtigsten Beispiele stehen im Internet auf der Webseite zu diesem Buch zur Verf¨ugung. Diese Webseite hat folgende Adresse (URL):
http://www.josuttis.de/cppbuch/ Dort sind die Beispiele unter dem jeweils am Anfang eines Listings angegebenen Dateinamen zu finden.
1.6 Anregungen und Kritik Es gibt immer etwas zu verbessern. F¨ur Anregungen und Kritik zu diesem Buch w¨are ich deshalb dankbar. Meine Adresse lautet:
Sandini Bib
1.6 Anregungen und Kritik
7
Nicolai Josuttis Berggarten 9 D–38108 Braunschweig Tel: 05309 / 57 47 Fax: 05309 / 57 74 Am einfachsten bin ich per E-Mail zu erreichen. Die E-Mail-Adresse zu diesem Buch lautet:
[email protected] Damit alle Leser von R¨uckmeldungen profitieren, werden eventuelle Verbesserungen auf der oben genannten Webseite zu diesem Buch aufgelistet.
Sandini Bib
Sandini Bib
Kapitel 2
Einleitung: C++ und objektorientierte Programmierung Dieses Kapitel bietet eine prinzipielle Einf¨uhrung in das Thema und f¨uhrt die Grundbegriffe f¨ur das Verst¨andnis von C++ ein. Nach einer Einf¨uhrung in die Geschichte und die Design-Aspekte von C++ werden dabei in einer Art Schnelldurchlauf“ die wichtigsten Sprachmittel von C++ im ” ¨ Uberblick kurz eingef¨uhrt, bevor es dann in den folgenden Kapiteln in die Details geht.
2.1 Die Sprache C++ C++ ist eine objektorientierte Sprache, die in relativ kurzer Zeit eine große Verbreitung gefunden hat und heute eine der Standardsprachen f¨ur die objektorientierte Programmierung darstellt. Zu dieser Verbreitung hat haupts¨achlich eine der wesentlichsten Eigenschaften von C++ beigetragen: die Kompatibilit¨at zu C. Entwickelt von AT&T unter der Federf¨uhrung von Bjarne Stroustrup galt es, eine objektorientierte Weiterf¨uhrung von C zu entwickeln, die es erlaubt, vorhandenen C-Code so weit wie m¨oglich weiterverwenden zu k¨onnen.
2.1.1
Designkriterien
C++ wurde als objektorientierte Weiterentwicklung von C entworfen. Die Sprache C sollte um objektorientierte Sprachmittel erweitert werden. Da die Kompatibilit¨at zu C einen sehr hohen Stellenwert hatte, kann im Prinzip jedes C-Programm als C++-Programm u¨ bersetzt werden (kleine Modifikationen wie etwa bei Namenskonflikten durch neue Schl¨usselw¨orter ausgenommen). Dies bedeutet aber, dass C++ keine rein objektorientierte Sprache, sondern ein Hybride ist. Man kann mit C++ sowohl datenflussorientiert als auch objektorientiert programmieren. Jeder Programmierer muss deshalb bewusst darauf achten, dass er C++ als objektorientierte Sprache verwendet. Auf der anderen Seite hat er den Vorteil, dass er bei Problemen, f¨ur die keine objektorientierte L¨osung angemessen ist, nicht gleich die Sprache wechseln muss.
9
Sandini Bib
10
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
2.1.2
Sprachversionen
C++ ist eine inzwischen standardisierte Sprache. Ein ANSI/ISO-Komitee1 hat 1998 eine weltweit einheitliche Sprachspezifikation verabschiedet. Dieses Dokument ist im Internet unter der Nummer ISO/IEC 14882-1998 im Electronic Standards Store, ESS, unter
http://www.ansi.org f¨ur $ 18,– als PDF-Datei erh¨altlich. In Deutschland ist der Standard bei DIN2 ebenfalls verf¨ugbar, kostet dort aber knapp DM 500,–. Da die Compilerbauer neue Spezifikationen der Sprache immer erst implementieren m¨ussen, hinken die Compiler der aktuellen Spezifikation zur Zeit leider immer noch etwas hinterher. Dies f¨uhrt allerdings nur in wenigen Einzelf¨allen zu Einschr¨ankungen. So kann man inzwischen davon ausgehen, dass im Prinzip alle hier im Buch vorgestellten Programme mit den neuesten Versionen der verschiedenen Compiler u¨ bersetzbar und lauff¨ahig sind.
2.2 C++ als objektorientierte Programmiersprache Der Begriff objektorientiert“ ist inzwischen zum G¨utesiegel geworden, ohne dass immer klar ” ist, was darunter zu verstehen ist. Es zeigt sich sogar, dass es verschiedene Interpretationen des Begriffs gibt und dass der Begriff mitunter auch missbraucht wird, um Software mit diesem G¨utesiegel zu versehen. Genauso umstritten wie der Begriff ist auch die Frage, ob und wann etwas objektorientiert ist. Es gibt zwar verschiedene Kennzeichen objektorientierter Sprachen, aber es ist nicht eindeutig festgelegt, ob alle Kennzeichen oder nur eine Auswahl davon vorhanden sein m¨ussen. C++ unterst¨utzt alle Sprachmittel, die nach vorwiegender Meinung objektorientierte Sprachen kennzeichnen. Trotzdem gibt es zum Teil Diskussionen dar¨uber, ob C++ eine echte objektorientierte Sprache ist. Dies liegt daran, dass C++ im Gegensatz zu Sprachen wie Smalltalk oder Java keine rein objektorientierte Sprache ist. C++ unterst¨utzt zwar eine objektorientierte Programmierung, durch die Aufw¨artskompatibilit¨at zu C kann man aber auch C++-Programme schreiben, die die objektorientierten Sprachmittel nicht nutzen. Hinzu kommt die M¨oglichkeit, mit Hilfe von Templates auch generisch zu programmieren. Bevor diese Frage in einen Glaubenskrieg ausartet, sollen nun aber die wesentlichen Kennzeichen von objektorientierter Programmierung vorgestellt werden. Diese Kennzeichen lassen sich im Grunde auf vier Begriffe zur¨uckf¨uhren:
1 2
Klassen (abstrakte Datentypen) Datenkapselung Vererbung Polymorphie ANSI: American National Standard Institute, ISO: International Organization for Standardization DIN: Deutsches Institut f¨ur Normung e.V.
Sandini Bib
2.2 C++ als objektorientierte Programmiersprache
2.2.1
11
Objekte, Klassen und Instanzen
Ein g¨angiges Mittel, Komplexit¨at zu reduzieren, ist die Abstraktion. Dinge und Vorg¨ange werden auf das Wesentliche reduziert bzw. zusammengefasst oder mit einem Oberbegriff versehen. Auf diese Weise werden auch komplexe Dinge handhabbar. Ein Beispiel f¨ur eine in mehrfacher Hinsicht durchgef¨uhrte Abstrahierung ist der Begriff Auto“: ” Ein Auto ist die Zusammenfassung verschiedener Einzelteile, wie Motor, Karosserie, vier R¨ader und so weiter.
Ein Auto ist aber auch ein Oberbegriff f¨ur verschiedene Auto-Typen, wie VW, Opel und Trabbi oder auch Sportwagen, Limousine und Gel¨andewagen.
Solange die Einzelteile von Autos oder Unterschiede einzelner Autos nicht von Interesse sind, wird zusammenfassend der Begriff Auto verwendet. Man baut Autotransporter oder f¨ahrt mit einem Auto von A nach B. Strukturen Ein wesentlicher Fortschritt in der Geschichte der Programmiersprachen war die M¨oglichkeit, mehrere Daten zu einem Datenverbund zusammenzufassen und so verschiedene zusammengeh¨orende Eigenschaften in einen Datentyp zu b¨undeln. Solche Strukturen oder Records erlauben es, dass eine Variable alle Daten enth¨alt, die zu dem von der Variablen repr¨asentierten Sachverhalt geh¨oren. Strukturen bieten damit die M¨oglichkeit eine m¨ogliche Art der Abstrahierung, n¨amlich die Zusammenfassung verschiedener Einzelteile, in Programmen auszudr¨ucken. Eine Struktur f¨ur den Datentyp Auto besteht aus den Einzelelementen (Komponenten) wie Motor, Karosserie und vier R¨adern. Objekte Im Mittelpunkt der objektorientierten Programmierung steht das Objekt. Ein Objekt ist etwas, das betrachtet wird, das verwendet wird, das eine Rolle spielt. Wenn man objektorientiert programmiert, versucht man, die Objekte, die im Problemfeld des Programms eine Rolle spielen, zu ermitteln und zu implementieren. Dabei steht zun¨achst nicht der interne Aufbau eines Objekts im Vordergrund. Wichtig ist, dass in einem Programm ein Objekt, wie z.B. ein Auto, eine Rolle spielt. Je nach Aufgabenstellung sind verschiedene Aspekte eines Objekts von Interesse. Ein Auto kann sich z.B. aus Einzelteilen wie Motor und Karosserie zusammensetzen oder durch Eigenschaften wie Kilometerstand und H¨ochstgeschwindigkeit beschrieben werden. Diese Attribute kennzeichnen die Objekte. Entsprechend kann auch eine Person als Objekt betrachtet werden, von dem verschiedene Attribute von Interesse sind. Je nach Problemstellung k¨onnen das Vorund Nachname, Adresse, Personalnummer, Haarfarbe und/oder das Gewicht sein. Ein Objekt muss also nicht unbedingt etwas Konkretes, Greifbares sein. Es kann beliebig abstrakt sein und z.B. auch einen Vorgang beschreiben. Ein stattfindendes Fußballspiel kann z.B. als Objekt betrachtet werden. Die Attribute dieses Objekts k¨onnten die Spieler, der Spielstand und die abgelaufene Zeit sein.
Sandini Bib
12
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
Strukturen erm¨oglichen es, Objekte in Programmen zu verwalten. Sie bieten die M¨oglichkeit, ein Objekt letztlich in die einzelnen Attribute aufzuspalten und mit Hilfe der von einer Programmiersprache unterst¨utzten Datentypen zu programmieren. Abstrakte Datentypen In Strukturen oder Records k¨onnen die einzelnen Eigenschaften von Objekten jeweils in den Komponenten festgehalten werden. Wenn Objekte betrachtet werden, interessiert allerdings nicht nur, wie sie aufgebaut sind bzw. was sie repr¨asentieren, sondern genauso wichtig ist das, was man mit ihnen machen kann, also die Operationen, die f¨ur ein Objekt aufgerufen werden k¨onnen. Hier kommt der Begriff abstrakter Datentyp ins Spiel: Ein abstrakter Datentyp beschreibt nicht nur die Attribute eines Objekts, aus denen es sich zusammensetzt, sondern auch dessen Verhalten (die Operationen). Dazu kann z.B. auch eine Beschreibung geh¨oren, welche Zust¨ande das Objekt u¨ berhaupt annehmen kann. Auf das Beispiel des Fußballspiels bezogen, wird ein solches Objekt nicht nur durch die Spieler, den Spielstand und die abgelaufene Zeit beschrieben. Dazu geh¨oren auch Vorg¨ange, die mit dem Objekt passieren k¨onnen (Einwechseln eines Spielers, Erzielen eines Tors, Abpfeifen), oder Randbedingungen (das Spiel startet mit dem Spielstand 0:0 und dauert 90 Minuten). Dieser Sachverhalt wird aber in der strukturierten Programmierung nur unzureichend unterst¨utzt. Es gibt zwar eine Unterst¨utzung f¨ur den Sachverhalt, dass sich ein Objekt aus Attributen zusammensetzt, es gibt aber keine Unterst¨utzung daf¨ur, dass auch Operationen und Randbedingungen dazugeh¨oren. Diese m¨ussen als Erg¨anzung separat programmiert werden. Klassen Zur Realisierung von abstrakten Datentypen hat man in der objektorientierten Programmierung den Begriff Klasse eingef¨uhrt. Eine Klasse ist die Implementierung eines abstrakten Datentyps. Sie beschreibt f¨ur ein Objekt im Gegensatz zur Struktur nicht nur dessen Attribute (Daten), sondern auch dessen Operationen (Verhalten). Eine Klasse Auto definiert z.B., dass ein Auto aus Motor, Karosserie und vier R¨adern besteht und dass man in ein Auto einsteigen, damit fahren und daraus aussteigen kann. Eine Klasse kann also als Umsetzung des Begriffs abstrakter Datentyp“ betrachtet werden. ” Dabei ist der genaue Unterschied allerdings umstritten. Manchmal werden die Begriffe als Synonyme verwendet. Manchmal werden die Begriffe dahingehend unterschieden, dass Eigenschaften einer Klasse im Gegensatz zu einem abstrakten Datentyp an andere Klassen vererbbar sind. Speziell in der C++-Welt wird der Begriff abstrakter Datentyp auch gern als Abgrenzung zu dem Begriff fundamentaler Datentyp und somit gleichbedeutend mit (selbst definierter) Klasse verwendet. Instanzen Eine Klasse beschreibt ein Objekt. Es ist also im Sinne von Programmen wirklich ein Datentyp. Von diesem Datentyp k¨onnen dann mehrere Variablen erzeugt werden. In der objektorientierten Programmierung nennt man diese Variablen Instanzen. Instanzen sind also die Realisierung oder Auspr¨agung der Objekte, die in einer Klasse beschrieben werden. Diese Instanzen bestehen aus den in der Klasse beschriebenen Daten oder Elementen und k¨onnen mit den dort definierten Operationen manipuliert werden.
Sandini Bib
2.2 C++ als objektorientierte Programmiersprache
13
Oft (und vor allem in C++) werden die Begriffe Objekt und Instanz synonym verwendet. Wenn eine Variable vom Typ (von der Klasse) Auto deklariert wird, wird ein Auto-Objekt (eine Instanz der Klasse Auto) angelegt. In manchen Sprachen wird allerdings zwischen Objekt und Instanz unterschieden, da auch eine Klasse als Objekt betrachtet werden kann. Objekt ist dann mehr der Oberbegriff f¨ur Klasse und Instanz. Methoden In der objektorientierten Sprachwelt nennt man die f¨ur Objekte definierten Operationen auch Methoden. Jede Operation, die f¨ur ein Objekt aufgerufen wird, wird als Nachricht an das Objekt interpretiert, zu deren Verarbeitung es eine bestimmte Methode verwendet. Letztlich wird ein objektorientiertes Programm also durch Nachrichten an Objekte realisiert, die jeweils wieder Nachrichten an andere Objekte ausl¨osen k¨onnen. Wenn f¨ur ein Auto die Operation fahren“ aufgerufen wird, wird dem Auto die Nachricht fahren“ gesendet, die mit der ” ” entsprechenden Methode verarbeitet wird. Bei dieser Denkweise wird das Objekt in den Vordergrund gestellt. Beim objektorientierten Design macht man sich Gedanken u¨ ber die im Problembereich vorhandenen Objekte. Abh¨angig von ihrer jeweiligen Rolle werden ihr Aufbau und ihr Verhalten beschrieben. Daraus ergibt sich der Programmablauf fast von selbst.
2.2.2
Klassen in C++
Bei der Umsetzung der objektorientierten Konzepte in C++ muss immer ber¨ucksichtigt werden, dass C++ keine rein objektorientierte Sprache, sondern ein Sprach-Hybride ist. Dies spiegelt sich schon in der Namensgebung wider. Methoden werden auch weiterhin als Funktionen und Instanzen weiterhin als Variablen oder auch einfach nur als konkrete Objekte bezeichnet. Das bereits in der objektorientierten Welt herrschende Begriffschaos ist in C++ perfekt (ich gehe in Abschnitt 2.4 noch genauer darauf ein). In C++ ist eine Klasse eine Struktur, die als Komponenten auch Funktionen (Methoden) enthalten kann. Dies wird nachfolgend am Beispiel der Klasse Auto verdeutlicht. Auto als C++-Klasse In C++ wird ein Auto z.B. wie folgt definiert:
class Auto { // Baujahr als ganzzahliger Wert int baujahr; float kmstand; // aktueller Kilometerstand als Gleitkommazahl string kennzeichen; // aktuelles Kennzeichen int lieferBaujahr (); // Baujahr abfragen void fahren (float km); // km Kilometer fahren float lieferKMStand (); // Kilometerstand abfragen ...
};
void Auto(); void ~Auto()
// automatische Initialisierung // automatisches Aufr¨aumen bei der Zerst¨orung
Sandini Bib
14
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
Sowohl der Aufbau als auch das Verhalten, einschließlich Initialisierung und Zerst¨orung, ist in C++ Teil der Klassenstruktur:
Intern besitzt ein Auto z.B. folgende Attribute: – baujahr definiert das Baujahr des Autos. Der dazu verwendete Datentyp int steht in C++ f¨ur ganzzahlige Werte zur Verf¨ugung. – kmstand definiert den aktuellen Kilometerstand des Autos. Der dazu verwendete Datentyp float steht in C++ f¨ur Gleitkommawerte zur Verf¨ugung.
– kennzeichen definiert das aktuelle Kennzeichen des Autos. Der dazu verwendete Datentyp string steht in C++ f¨ur Strings (Zeichenfolgen) zur Verf¨ugung. Folgende Operationen sind definiert: – lieferBaujahr() dient dazu, das Baujahr abzufragen und als ganzzahligen Wert zur¨uckzuliefern. – fahren() dient dazu, mit dem Auto eine als Gleitkommawert km u¨ bergebene Strecke zur¨uckzulegen.
– lieferKMStand() dient dazu, den aktuellen Kilometerstand des Autos abzufragen und als Gleitkommawert zur¨uckzuliefern. Hinzu kommen spezielle Funktionen, mit denen man das Verhalten beim Erzeugen und Zerst¨oren eines Autos beeinflussen kann: – Auto() definiert, was beim Erzeugen eines Autos automatisch passieren soll. Dazu geh¨ort z.B., dass Baujahr, Kilometerstand und Kennzeichen entsprechend initialisiert werden. – ~Auto() definiert, was beim Zerst¨oren von einem Auto passieren soll. Hier kann z.B. definiert werden, dass intern speziell angelegter Speicherplatz automatisch freigegeben wird.
Eine derartige Definition hat mehrere Vorteile:
Die Operationen (Funktionen/Methoden) belegen nicht den globalen Namensbereich. Der einzige globale Datentyp ist der Datentyp Auto. Ein Parameter Auto entf¨allt f¨ur Operationen. Da die Funktionen grunds¨atzlich nur f¨ur Autos aufgerufen werden k¨onnen, ist damit automatisch immer ein dazugeh¨origes Auto vorhanden. Weder Initialisierungs- noch Aufr¨aumungsarbeiten m¨ussen explizit aufgerufen werden. Damit kann der Anwender diese Dinge nicht vergessen.
Ein entsprechendes Anwendungsprogramm sieht in C++ wie folgt aus:
void autotest () { // Auto a anlegen und initialisieren Auto a; a.fahren(77);
// 77 Kilometer fahren
...
float stand = a.lieferKMStand(); // Kilometerstand abfragen
Sandini Bib
2.2 C++ als objektorientierte Programmiersprache
15
...
if (a.lieferBaujahr() < 1900) { }
// Baujahr abfragen und auswerten
...
}
// automatisches Aufr¨aumen und Zerst¨oren am Blockende
Mit der Deklaration von a wird eine Instanz (ein konkretes Objekt) als Auto angelegt und automatisch initialisiert. Der Zugriff erfolgt dann u¨ ber die f¨ur Autos definierten Funktionen (Methoden). Dabei ist ein Funktionsaufruf ein Zugriff auf die Komponente der Klassenstruktur, der u¨ ber den Punkt-Operator erfolgt. Am Blockende, wenn das Objekt seinen G¨ultigkeitsbereich verl¨asst, wird eine f¨ur das Aufr¨aumen definierte Funktion automatisch aufgerufen.
2.2.3
Datenkapselung
Ein Problem von herk¨ommlichen Strukturen besteht darin, dass jederzeit auf alle Komponenten einer solchen Struktur zugegriffen werden kann. Jedes Programmst¨uck bietet also f¨ur alle Anwender die M¨oglichkeit, den Zustand eines Objekts beliebig zu ver¨andern. Damit besteht die Gefahr, bei Anwendung einer Klasse Inkonsistenzen und Fehler zu produzieren. So k¨onnte ein Anwendungsprogramm von Auto z.B. einfach das Baujahr a¨ ndern oder den Kilometerstand herabsetzen. Daraus entstand die Idee der Datenkapselung (englisch: encapsulation3 ): Um sicherzustellen, dass nicht jeder Anwender mit einem Objekt machen kann, was er will, wird einfach der Zugriff auf ein solches Objekt auf eine wohldefinierte Schnittstelle reduziert. Jeder Anwender, der mit einem solchen Objekt umgeht, soll nur die Operationen durchf¨uhren k¨onnen, die der Designer des entsprechenden Datentyps f¨ur einen o¨ ffentlichen Zugriff vorgesehen hat. Auf Interna, die zur Verwaltung des Objekts und seiner Daten im Programm dienen, besteht kein Zugriff. In C++ kann der Zugriff auf die Komponenten einer Klassenstruktur u¨ ber spezielle Zugriffsschl¨usselw¨orter definiert werden:4
class Auto { private: // Baujahr als ganzzahliger Wert int baujahr; float kmstand; // aktueller Kilometerstand als Gleitkommazahl string kennzeichen; // aktuelles Kennzeichen public: // Baujahr abfragen int lieferBaujahr (); void fahren (float km); // km Kilometer fahren 3
Manchmal wird statt encapsulation auch der Begriff information hiding verwendet. Dies ist aber – zumindest auf C++ bezogen – nicht korrekt, da die internen Daten nur vor einem Zugriff von außen gesperrt, nicht aber versteckt werden. 4 Ohne die Zugriffsschl¨usselw¨orter ist f¨ur alle Komponenten kein Zugriff von außen m¨oglich (private: ist Default). Insofern funktioniert das zuvor vorgestellte Anwendungsprogramm erst mit dieser Version.
Sandini Bib
16
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
float lieferKMStand (); // Kilometerstand abfragen ...
};
void Auto(); void ~Auto()
// automatische Initialisierung // automatisches Aufr¨aumen bei Zerst¨orung
Die nach private: deklarierten Komponenten sind vor dem Zugriff durch den Anwender einer Klasse gesch¨utzt. Der Anwender kann f¨ur die Objekte einer Klasse nur auf die hinter public: deklarierten o¨ ffentlichen Komponenten zugreifen bzw. die entsprechenden Funktionen aufrufen. In diesem Fall ist es einem Anwender der Klasse also nicht mehr m¨oglich, auf baujahr oder kmstand direkt zuzugreifen. Zugriff besitzen nur die Funktionen, die in der Klassenstruktur deklariert werden. Es ist typisch, dass der Zugriff auf Attribute nur u¨ ber Funktionen m¨oglich ist. Auf diese Art und Weise kann bei jedem Zugriff genau gepr¨uft werden, ob die Art des Zugriffs in der aktuellen Situation und mit den aktuellen Parametern sinnvoll ist. Trennung von Schnittstelle und Implementierung Das Konzept der Datenkapselung basiert letztlich auf der Idee, dass es f¨ur den Anwender eines Objekts unerheblich ist, wie dieses intern repr¨asentiert wird. Entscheidend ist nur, dass das Richtige gemacht wird. Im Beispiel Auto ist es f¨ur den Anwender z.B. nicht von Interesse, auf welche Weise das Baujahr und der Kilometerstand intern verwaltet werden. Entscheidend ist, dass ein Auto erzeugt werden kann, dass man damit fahren kann und bestimmte Attribute abfragen kann. Abbildung 2.1 verdeutlicht diese Schnittstelle grafisch. Dabei wird die f¨ur die objektorientierte Programmierung u¨ bliche UML-Notation verwendet.
A u t o l i e f e r B a u j a h r ( ) :
i n t
f a h r e n ( k m : f l o a t ) l i e f e r K M S t a n d ( ) : f l o a t
¨ Abbildung 2.1: Offentliche Schnittstelle von Autos
F¨ur den Anwender steht nur das WAS im Vordergrund. Die o¨ ffentliche Schnittstelle legt fest, was mit einem Objekt gemacht werden kann. Das WIE interessiert nur den, der diese Klasse schließlich implementiert. Bei gleich bleibender Schnittstelle kann die Implementierung beliebig ver¨andert werden, ohne dass dies Auswirkungen auf den Anwendungscode h¨atte. Auf diese Weise kann eine erste Implementierung z.B. sp¨ater aus Performance-Gr¨unden verbessert werden.
Sandini Bib
2.2 C++ als objektorientierte Programmiersprache
2.2.4
17
Vererbung
Wenn man das Verhalten von Objekten beschreibt, gibt es oft Eigenschaften, die verschiedenartige Objekte gemeinsam haben. Im nat¨urlichen Sprachgebrauch werden daf¨ur Oberbegriffe verwendet. Ein blauer PKW Baujahr 1984 mit Dieselmotor wird schlicht zu einem Auto, solange seine Details nicht von Interesse sind. Er kann sogar nur als Fahrzeug betrachtet werden, wenn die Tatsache, dass es sich um ein Auto handelt, zeitweise keine Rolle spielt. Eine derartige Verallgemeinerung findet z.B. bei einem Autotransporter statt. F¨ur diesen ist es unerheblich, was f¨ur Autos transportiert werden; allenfalls die geometrischen Maße der Autos sind von Interesse. Dennoch werden verschiedene Autos transportiert, und deren Unterschiede k¨onnen nach dem Ausladen der Autos sehr wohl wieder von Interesse werden. F¨ur dieses Abstraktionsmittel von Generalisierung und Spezialisierung gibt es in den herk¨ommlichen Programmiersprachen kein a¨ quivalentes Datenmodell. In der objektorientierten Programmierung gibt es daf¨ur das Konzept der Vererbung und der Polymorphie. Verschiedene Klassen k¨onnen in einer hierarchischen Beziehung zueinander stehen. Dabei gilt, dass eine abgeleitete“ Klasse immer eine Spezialisierung ihrer Basisklasse“ ist. Umge” ” kehrt ist die Basisklasse die Generalisierung der abgeleiteten Klasse. Das bedeutet, dass alle Eigenschaften (Attribute und Operationen) der Basisklasse jeweils u¨ bernommen (geerbt) und in der Regel durch genauere Eigenschaften erg¨anzt werden. Abbildung 2.2 zeigt ein entsprechendes Beispiel. Die Klasse Fahrzeug beschreibt als Basisklasse, welche Eigenschaften ein Fahrzeug hat. Dazu geh¨oren z.B. das Attribut baujahr oder die Operation lieferBaujahr(). F¨ur alle davon abgeleiteten Klassen wie Auto, Fahrrad oder Laster gelten diese Eigenschaften ebenfalls. Die Klasse Fahrzeug ist also der Oberbegriff, unter dem sich die gemeinsamen Eigenschaften zusammenfassen lassen. Die unterschiedlichen Eigenschaften, die speziell Autos oder speziell Laster haben, werden dann in den jeweils davon abgeleiteten Klassen implementiert. So besitzen Fahrzeuge mit Tacho z.B. zus¨atzlich das Attribut kmstand sowie die Operationen fahren() und lieferKMStand(). Laster haben zus¨atzlich die Operationen beladen() und ausladen(). Auf diese Weise kann immer weiter verfeinert werden. Es entsteht eine Klassenhierarchie. Grunds¨atzliches Kennzeichen der Vererbung ist dabei die is-a-Beziehung. Ein Laster ist ein Fahrzeug mit Tacho, ein Fahrzeug mit Tacho ist ein Fahrzeug. Was sind nun die Vorteile der Vererbung? Zun¨achst dient sie der Konsistenz und zur Einsparung von Code. Gemeinsame Eigenschaften verschiedener Klassen m¨ussen nur einmal implementiert und gegebenenfalls auch nur an einer Stelle ge¨andert werden. Der andere Vorteil ist, dass das Abstraktionsmittel des Oberbegriffs unterst¨utzt wird. Dies wird in Abschnitt 2.2.5 bei der Einf¨uhrung in die Polymorphie noch verdeutlicht. In der Praxis kann es auch notwendig sein, einzelne geerbte Eigenschaften zu a¨ ndern. Auch dies ist mit Einschr¨ankungen m¨oglich. Die Vererbung wird nat¨urlich sinnlos, wenn nichts mehr u¨ bernommen wird. In diesem Fall sollte man eine separate Klasse implementieren. Dabei gibt es nat¨urlich Grenzf¨alle.
Sandini Bib
18
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
F a h r z e u g b a u j a h r :
i n t
l i e f e r B a u j a h r ( ) :
i n t
F a h r r a d
F a h r z e u g M i t T a c h o k m s t a n d : f a h r e n
f l o a t
( k m :
f l o a t )
l i e f e r K M S t a n d ( ) :
A u t o
a u f p u m p e n ( )
f l o a t
B u s
L a s t e r
k o f f e r r a u m O e f f n e n ( )
b e l a d e n ( )
s i t z p l a e t z e :
i n t
a u s l a d e n ( )
Abbildung 2.2: Beispiel f¨ur eine Klassenhierarchie
2.2.5
Polymorphie
Von den Vorteilen, die sich aus der Vererbung ergeben, wurden bisher nur die Konsistenz und die Einsparung von Code aufgef¨uhrt. Die Vererbung ist aber außerdem eine wichtige Grundvoraussetzung, um Polymorphie zu erm¨oglichen. Die Arbeit mit Oberbegriffen wird n¨amlich dann erst sinnvoll, wenn sich Objekte zeitweise auf ihren Oberbegriff reduzieren lassen. Nehmen wir an, es soll eine Menge von Autos z.B. f¨ur eine KFZ-Werkstatt betrachtet werden. Das k¨onnen v¨ollig verschiedene Autos sein, unterschiedliche Fabrikate, unterschiedliche Modelle, unterschiedliche Ausf¨uhrungen. Es handelt sich also um eine inhomogene Menge“ ” von Autos. Wenn nun bei verschiedenen Autos der Motor gewechselt werden soll, so wird zwar im Prinzip das gleiche gemacht, aber je nach Auto sieht dies unter Umst¨anden sehr verschieden aus. Die im t¨aglichen Sprachgebrauch verwendete Aufforderung Wechsle bei den Autos den ” Motor“ wird also zu unterschiedlichen Aktionen f¨uhren. Um diesen Sachverhalt widerzuspiegeln, kann man nun f¨ur jede Klasse, die eine Art von Autos beschreibt, eine eigene Version des Motor-Wechselns implementieren. Das Besondere an der Polymorphie ist nun, dass f¨ur eine solche inhomogene Menge von verschiedenen Autos jeweils die Operation motorWechseln() aufgerufen werden kann und automatisch je nach Auto-Typ
Sandini Bib
2.2 C++ als objektorientierte Programmiersprache
19
die richtige Funktion aufgerufen wird. Polymorphie bedeutet letztlich die F¨ahigkeit, dass eine Operation vom Objekt selbst interpretiert wird, und zwar unter Umst¨anden erst zur Laufzeit, da beim Kompilieren nicht unbedingt bekannt ist, welche Operation gemeint ist. Es gibt verschiedene M¨oglichkeiten, Polymorphie zu implementieren. Den radikalsten Weg geht der Prototyp aller objektorientierten Sprachen, Smalltalk. Dort sind Variablen an keinen Typ gebunden und k¨onnen somit jede Art von Objekt repr¨asentieren. Wenn f¨ur eine Variable eine Operation aufgerufen wird, wird zur Laufzeit festgestellt, um welche Art von Objekt es sich bei der Variablen in dem Moment handelt. Abh¨angig davon wird entweder die zum Objekt geh¨orende Operation aufgerufen (die von der entsprechenden Klasse definierte Methode durchgef¨uhrt), oder es wird ausgegeben, dass die Operation nicht sinnvoll ist. In C++ nutzt man zur Realisierung der Polymorphie im Normalfall die Vererbung.5 Wenn a¨ hnliche Klassen die gleiche Operation verschieden implementieren, dann kann diese bereits in einer gemeinsamen Basisklasse deklariert werden, ohne dass sie dort unbedingt implementiert werden muss. Verwendet man eine inhomogene Menge von Objekten verschiedener Klassen, deklariert man diese Menge mit dem Typ der gemeinsamen Basisklasse. Wenn f¨ur ein Element in der Menge dann eine Operation aufgerufen wird, kann veranlasst werden, dass erst zur Laufzeit gepr¨uft wird, zu welcher Klasse das Objekt tats¨achlich geh¨ort, und die dazugeh¨orende Funktion aufgerufen werden. Ein Anwendungsprogramm k¨onnte entsprechend eine inhomogene Menge von Autos deklarieren, die verschiedenen Objekte zuweisen und f¨ur alle Objekte die Funktion motorWechseln() aufrufen. In dem Fall wird automatisch die richtige Funktion aufgerufen, die sich aus der tats¨achlichen Klasse des jeweiligen Autos ergibt:
AutoMenge autos; // Menge von Autos VW v; // Objekt der Klasse VW // Objekt der Klasse Opel Opel o; autos.insert(v); autos.insert(o);
// Auto-Menge enth¨alt VW // Auto-Menge enth¨alt Opel
for (i=0; i
} 5
autos[i].motorWechseln();
Man kann Polymorphie auch mit Hilfe von Templates implementieren. Darauf wird in Abschnitt 7.5.3 eingegangen.
Sandini Bib
20
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
Dabei ist zu beachten, dass es f¨ur die Implementierung unerheblich ist, was es f¨ur abgeleitete Klassen von Auto gibt. Es gibt keine fest verdrahtete Fallunterscheidung. Wenn man sich entschließt, eine neue Auto-Klasse als abgeleitete Klasse von Auto zu implementieren, so kann autos auch diese enthalten, ohne neu kompiliert werden zu m¨ussen.
2.3 Weitere Konzepte von C++ In C++ gibt es drei weitere wesentliche Konzepte, die die objektorientierten Sprachmittel unterst¨utzen bzw. erg¨anzen: die Ausnahmebehandlung, Templates und Namensbereiche.
2.3.1
Ausnahmebehandlung
Auch bei abstrakten Datentypen kann es zu unplanm¨aßigen Problemen kommen. So kann z.B. eine Deklaration einer Menge nicht gelingen, wenn der daf¨ur anzulegende Speicherplatz nicht vorhanden ist. Deklarationen besitzen allerdings keinen R¨uckgabewert. In einem solchen Fall gibt es zun¨achst die M¨oglichkeit, das Programm mit einer Fehlermeldung zu beenden oder eine entsprechende Warnung auszugeben und fortzufahren. Ob und wie weit das sinnvoll ist, h¨angt allerdings sehr oft von der Situation ab und kann nicht generell sinnvoll festgelegt werden. Aus diesem Grund wurde ein Konzept f¨ur die Ausnahmebehandlung, die so genannte Ausnahmebehandlung (englisch: exception handling), in C++ integriert. Wenn eine unerwartete Situation eintritt, wird eine entsprechende Ausnahme definiert, die vom Anwendungsprogramm abgefangen und ausgewertet werden kann. Auf diese Weise wird insbesondere die Erkennung einer Ausnahmesituation von deren Behandlung getrennt. Erkannt wird eine Ausnahme in Funktionen oder Klassen. Behandelt wird sie vom Anwender der Funktion oder der Klasse. Dieser kennt die Situation, in der die Ausnahme entstand, und kann entsprechend sinnvoll reagieren. Da das Konzept der Ausnahmebehandlung nicht Parameter und R¨uckgabewerte von Funktionen verwendet, ergibt sich noch ein anderer Vorteil dieser Art von Fehlerbehandlung: Der normale Datenfluss wird von der Fehlerbehandlung entkoppelt. In herk¨ommlichen Programmen m¨ussen oft R¨uckgabewerte auf Fehlerf¨alle gepr¨uft werden und dienen damit nicht nur dem Zweck des normalen Datenflusses. Das hat zur Folge, dass der normale Datenfluss st¨andig von Anweisungen f¨ur den Fehlerfall unterbrochen wird. Mit der Ausnahmebehandlung wird sauber zwischen normalem Datenfluss und Fehlerbehandlung getrennt. Da wir uns in einer objektorientierten Sprachumgebung befinden, ist es nur konsequent, eine solche Ausnahme als Objekt zu definieren. Entsprechend gibt es Klassen, die gleichartige Objekte, also gleichartige Ausnahmen mit deren Eigenschaften, definieren. Sie k¨onnen sogar in einer hierarchischen Beziehung zueinander stehen, wodurch verschiedene Typen von Ausnahmen verallgemeinert oder spezialisiert und entsprechend auch allgemein oder speziell behandelt werden k¨onnen.
Sandini Bib
2.3 Weitere Konzepte von C++
2.3.2
21
Templates
Manche objektorientierten Sprachen besitzen Typfreiheit. Das bedeutet, eine Variable ist nicht von einem bestimmten Typ, sondern kann f¨ur alles M¨ogliche stehen. So kann einer Variable z.B. einmal ein ganzzahliger Wert, danach eine Menge und dann ein String zugewiesen werden. In C++ gibt es diese Typfreiheit nicht. Variablen besitzen in C++ also immer einen bestimmten Typ. Dies ist nicht unbedingt ein Nachteil, da dadurch immer schon beim Kompilieren sichergestellt wird, dass Typen nicht vermischt werden und eine Operation f¨ur ein Objekt auch aufrufbar ist. Eine Typbindung kann aber auch sehr hinderlich sein, wie das Beispiel der Automenge zeigt. F¨ur alle m¨oglichen Datentypen, f¨ur die eine Menge gebraucht wird, muss auch eine entsprechende Mengenklasse implementiert werden. Um diese Nachteile der Typbindung zu umgehen, wurden in C++ Templates (Schablonen) eingef¨uhrt. Damit k¨onnen einzelne Funktionen oder ganze Klassen ohne Festlegung auf einen bestimmten Typ implementiert werden. Es wird eine Schablone f¨ur einen oder mehrere angenommene Typen implementiert. Je nach Bedarf kann eine solche Schablone dann f¨ur verschiedene Typen zu realem Programmcode werden. Auf diese Weise l¨asst sich z.B. eine Klasse Menge zur Verwaltung aller m¨oglichen Objektarten implementieren:6
template class Menge { private: T* elems; unsigned anzahl;
// Schablone f¨ur angenommenen Datentyp T
// dynamisches Feld (Array) von Ts // aktuelle Anzahl
public: // Objekt vom Typ T einf¨ugen void insert(T); T operator[](int); // Indexoperator definieren int anzahl(); // Anzahl der Elemente liefern
};
void Menge(); void ~Menge()
// automatische Initialisierung // automatisches Aufr¨aumen bei Zerst¨orung
Der Anwender kann dann definieren, f¨ur welche Typen er eine Menge verwenden will:
Menge autos; // Menge von Autos VW v; // Objekt der Klasse VW // Objekt der Klasse Opel Opel o; autos.insert(v); 6
// Auto-Menge enth¨alt VW
Die hier vorgestellte Implementierung l¨asst noch einige f¨ur das wirkliche Funktionieren notwendige Details außer Acht.
Sandini Bib
22
Kapitel 2: Einleitung: C++ und objektorientierte Programmierung
autos.insert(o);
// Auto-Menge enth¨alt Opel
for (i=0; i
}
autos[i].motorWechseln();
Der Vorteil von Templates liegt vor allem darin, dass Datenstrukturen und Algorithmen, auch wenn sie f¨ur verschiedene Typen angewendet werden, nur einmal implementiert werden m¨ussen. Damit sind beliebige andere Mengen verwendbar:
Menge autos; // Menge von Autos Menge intmenge; // Menge von ganzzahligen Werten Menge<string> stringmenge; // Menge von Strings Das spart nicht nur Code, sondern unterst¨utzt auch die Wiederverwendbarkeit. Bei einer Neuimplementierung besteht die Gefahr, dass neue Fehler implementiert werden. Bei der Wiederverwendung einer existierenden, getesteten Mengen-Implementierung besteht diese Gefahr nicht.
2.3.3
Namensbereiche
Mit dem Konzept der Namensbereiche kann man Symbole logisch gruppieren. Damit werden einerseits Namenskonflikte vermieden, und andererseits wird deutlich gemacht, welche Symbole logisch zusammengeh¨oren (ein Paket oder eine Komponente bilden). So sind z.B. alle Symbole der Standardbibliothek im Namensbereich std definiert. Um einen String aus der Standardbibliothek zu verwenden, muss man diesen Datentyp also genau genommen noch entsprechend qualifizieren:
std::string s;
// String der Standardbibliothek
Entsprechend sollte man alle selbst definierten Symbole in entsprechenden eigenen Namensbereichen definieren.
2.4 Terminologie In der objektorientierten Sprachwelt herrscht ein gewisses begriffliches Chaos. Ein und derselbe Sachverhalt wird mit unterschiedlichen Begriffen belegt, und ein Begriff wird mitunter sogar verschieden angewendet. In C++ wird dieses Chaos noch durch die Tatsache erweitert, dass objektorientierte Begriffe mit Begriffen der herk¨ommlichen prozeduralen Programmierung, speziell mit C-Begriffen, vermischt werden. Ich werde deshalb im Folgenden die wichtigsten Begriffe f¨ur dieses Buch abgrenzen. Eine Instanz wird, da es sich um ein konkretes Objekt einer Klasse handelt, oft auch einfach nur als Objekt bezeichnet. Dies widerspricht allerdings der Tatsache, dass Objekt manchmal als Oberbegriff verstanden wird, der mehr meint. Auch eine Klasse kann man als Objekt betrachten. In der Sprache C++ gibt es allerdings keinen Unterschied zwischen den Begriffen Instanz und Objekt. In der Sprachspezifikation wird der Begriff Instanz u¨ berhaupt nicht verwendet. Um-
Sandini Bib
2.4 Terminologie
23
so verwirrender ist es, dass es im Zusammenhang mit Templates den Begriff instantiieren gibt. Damit ist dann nicht das Anlegen eines Objekts einer Klasse, sondern das Generieren des eigentlichen zu kompilierenden Codes aus Template-Code gemeint. Schon aus diesem Grund schließe ich mich der g¨angigen Sprachregelung von C++ an und bezeichne Instanzen einer Klasse in der Regel einfach nur als Objekte der Klasse. Eine Klasse ist in C++ eine Typbeschreibung. Insofern wird auch oft formuliert, dass ein Objekt einen bestimmten Typ besitzt, womit dann dessen Klasse gemeint ist. Man k¨onnte festlegen, dass nur der Begriff Klasse verwendet werden darf. Es gibt aber in C++ auch Typen, die keine Klassen sind. Wenn in C++ etwas f¨ur verschiedene Typen“ gilt, sind nicht nur Klassen, sondern ” auch fundamentale Datentypen gemeint. Deshalb werde auch ich den Begriff Typ oder Datentyp im Sinne von irgendein Datentyp“, der auch eine Klasse sein kann, verwenden. ” Bei den Elementen von Klassen gibt es die gr¨oßte Sprachverwirrung. Sie werden im Umfeld der objektorientierten Programmierung und im Umfeld von C++ Elemente, Komponenten, Member, Attribute, Daten usw. genannt. Die f¨ur Objekte aufrufbaren Operationen bezeichnet man als Methoden, Elementfunktionen oder Memberfunktionen. Ich verwende in diesem Buch f¨ur Klassenelemente als Oberbegriff die Bezeichnung Komponente mit der Unterscheidung in Datenelement und Elementfunktion. Man kann u¨ ber diese Namensgebung sicherlich streiten. Ein Leser, der aus der objektorientierten Sprachwelt kommt, fragt sich z.B. mit Recht, warum Elementfunktion statt Methode und Objekt statt Instanz verwendet wird. Ich habe mich f¨ur die C++-Sprachwelt entschieden, da weniger das Konzept der objektorientierten Programmierung am Beispiel von C++, sondern mehr die Verwendung der Sprache C++ aus objektorientierter Sicht im Vordergrund steht. Auch bei anderen Begriffen gibt es verschiedene Bezeichnungen. So existieren z.B. oft u¨ bernommene angloamerikanische parallel zu eingedeutschten Bezeichnungen. Im Zweifelsfall verwende ich den Begriff, der meiner Meinung nach verbreiteter ist, wor¨uber man aber auch ohne ¨ Ende streiten kann. So heißt es in diesem Buch Compiler statt Ubersetzer und Template statt Schablone, aber R¨uckgabewert statt Returnwert und Ausnahme statt Exception. Letztlich ist die Namensgebung auch einfach eine Frage des Geschmacks und der Gewohnheit, die die Lesbarkeit des Buchs hoffentlich nicht allzu sehr beeinflusst. Und eines ist auch klar: Eine Beibehaltung aller angloamerikanischen Begriffe ist genauso wenig hilfreich wie eine ¨ Ubersetzung aller Begriffe. Wie meinte doch ein Reviewer“ so richtig: Kein Weltraum links ” ” auf dem Ger¨at“ ist wesentlich unverst¨andlicher als No space left on device“ ;-). ”
Sandini Bib
Sandini Bib
Kapitel 3
Grundkonzepte von C++-Programmen C++ kann man aus verschiedenen Blickwinkeln betrachten. Auf der einen Seite gibt es die Sichtweise der Anwendungsprogrammierer, die Datentypen und Klassen mit Hilfe von Funktionen und Kontrollstrukturen zu laufenden Programmen kombinieren. Auf der anderen Seite gibt es die Sichtweise der Systemprogrammierer, die neue Datentypen und Klassen zur Verf¨ugung stellen und damit Details abstrahieren. Viele Programmierer haben in der Praxis oft beide Rollen. Dies gilt insbesondere deshalb, weil sie im Zuge der Abstrahierung von Problemen immer wieder Datentypen und Klassen verwenden, um h¨ohere Datentypen und Klassen zu programmieren. In diesem Kapitel beschr¨anken wir uns zun¨achst auf die Grundkonzepte, die f¨ur die Sichtweise der Anwendungsprogrammierer relevant sind. Dazu geh¨oren der prinzipielle Aufbau von C++-Programmen, die Syntax von Funktionen und Funktionsaufrufen, die Verwendung der fundamentalen Datentypen, die Aufteilung von Programmen in Module und die Verwendung der wichtigsten Datentypen und Klassen aus der Standardbibliothek. In den folgenden Kapiteln werden wir dann vor allem sehen, wie man eigene Datentypen und Klassen implementiert.
25
Sandini Bib
26
Kapitel 3: Grundkonzepte von C++-Programmen
3.1 Das erste Programm Dieser Abschnitt enth¨alt das erste C++-Programm. Anhand dieses Beispiels werden die dazugeh¨origen grundlegenden Sprachmittel eingef¨uhrt.
3.1.1
Hallo, Welt!“ ”
Das folgende erste Programm gibt einfach den String Hallo, Welt!“ aus:1 ”
// allg/hallo.cpp
/* Das erste C++-Programm * - gibt einfach nur ”Hallo, Welt!” aus */
#include
// Deklarationen f¨ur Ein-/Ausgaben
int main () // Hauptfunktion main() { / * ”Hallo, Welt!” auf Standard-Ausgabekanal std::cout * gefolgt von einem Zeilenende (std::endl) ausgeben */ std::cout << "Hallo, Welt!" << std::endl; } Grob formuliert arbeitet das Programm wie folgt:
Mit
#include
werden alle notwendigen Deklarationen f¨ur Ein- und Ausgaben eingebunden. Damit besteht Zugriff auf spezielle Symbole und Operationen f¨ur Ein- und Ausgaben. Eine derartige Anweisung befindet sich typischerweise in allen Programmen, die Ein-/Ausgaben t¨atigen. Der Programmablauf wird in der Hauptfunktion main() definiert:
int main() { ...
} 1
Falls sich dieses Programm nicht u¨ bersetzen l¨asst, kann dies daran liegen, dass ein C++-Compiler verwendet wird, der noch nicht standardkonform ist. In dem Fall k¨onnte eine modifizierte Form dieses Programms funktionieren, die in Abschnitt 3.1.5 auf Seite 31 vorgestellt wird.
Sandini Bib
3.1 Das erste Programm
27
Eine Funktion mit diesem Namen muss in allen C++-Programmen vorhanden sein. Sie wird zum Programmstart automatisch aufgerufen. Mit dem Verlassen dieser Funktion wird das Programm beendet. Innerhalb von main() gibt es nur eine Anweisung, die wie jede Anweisung mit einem Semikolon endet:
std::cout << "Hallo, Welt!" << std::endl; Diese Anweisung gibt auf den Standard-Ausgabekanal std::cout nacheinander den String "Hallo, Welt!" und das Symbol std::endl aus. Das Symbol std::endl steht f¨ur En” de der Zeile“ ( endline“). Es sorgt daf¨ur, dass ein Zeilenumbruch ausgegeben wird und der ” Ausgabepuffer geleert wird. Erg¨anzt wird das Programm von verschiedenen Kommentaren, die entweder durch /* und */ eingegrenzt werden oder von // bis zum Zeilenende reichen. Die folgenden Abschnitte gehen auf die hier verwendeten Sprachmittel genauer ein.
3.1.2
Kommentare in C++
In C++ sind zwei Arten der Kommentierung m¨oglich:
Die eine (von C u¨ bernommene) Art erlaubt eine Kommentierung u¨ ber beliebig viele Zeilen. Sie wird durch die Zeichenfolgen /*“ und */“ eingegrenzt: ” ” /* Dies ist ein Kommentar u¨ ber mehrere Zeilen */ Die andere Art (die in C++ neu hinzugekommen ist) ist die M¨oglichkeit, den Rest einer Zeile auszukommentieren. Zwei Schr¨agstriche //“ f¨uhren (solange sie sich nicht in einem String ” befinden) einen Kommentar ein, der bis zum Zeilenende reicht:
// Dies ist ein Kommentar, der bis zum Zeilenende reicht Die beiden Arten der Kommentierung sind voneinander unabh¨angig. Ein mit //“ begonnener ” Kommentar wird mit */“ nicht beendet: ” // Dieser Kommentar */ reicht wirklich bis zum Zeilenende
/* Dieser Kommentar mit // darin endet erst hier: */ Kommentare k¨onnen nicht geschachtelt werden:
/* Der hier begonnene Kommentar /* endet also genau hier: */ Die verschiedenen M¨oglichkeiten der Kommentierung f¨uhren nat¨urlich zu der Frage, welche Art der Kommentierung wof¨ur einzusetzen ist. Dies ist im Wesentlichen Geschmackssache. Als kleine Richtlinie kann vielleicht die Empfehlung dienen, mehrzeiligen Kommentar durch /* und */ einzugrenzen und f¨ur einzeiligen Kommentar // zu verwenden. Dabei bevorzuge ich
Sandini Bib
28
Kapitel 3: Grundkonzepte von C++-Programmen
eine mehrzeilige Kommentarform, bei der jede weitere Kommentarzeile jeweils mit einem Stern beginnt:
/* erste Kommentar-Zeile * weitere Kommentar-Zeile * ... * letzte Kommentar-Zeile */ Diese Form hebt sich vom restlichen Code hervorragend ab.
3.1.3
Hauptfunktion main()
Ein C++-Programm setzt sich aus verschiedenen Funktionen zusammen. Der Begriff Funktion wird dabei f¨ur jede Art von Unterprogramm“ (Sub-Routine, Prozedur, oder welche Bezeichnung ” man auch immer aus anderen Programmiersprachen kennt) verwendet. Einer Funktion k¨onnen Argumente als Parameter u¨ bergeben werden und sie kann (muss aber nicht) einen R¨uckgabewert zur¨uckliefern. Eine Prozedur ist in C++ also eine Funktion, die nichts zur¨uckliefert (daf¨ur existiert der R¨uckgabetyp void). In jedem C++-Programm existiert eine besondere Funktion, die Funktion main(). Diese Funktion muss in einem C++-Programm immer genau einmal vorhanden sein. Zum Programmstart wird diese Funktion automatisch aufgerufen. Mit dem Beenden von main() wird das Programm als Ganzes beendet. main() stellt somit die Klammer um den gesamten Programmablauf dar. Am Beispiel von main() kann man den Aufbau von Funktionen erkennen:
Der Funktionskopf besteht aus – dem R¨uckgabetyp (dem Datentyp des R¨uckgabewerts), – dem Funktionsnamen und
– der Parameterliste, die durch runde Klammern eingeschlossen wird. Der Funktionsrumpf besteht aus einem Block von Anweisungen. Dieser Block wird durch geschweifte Klammern eingeschlossen. Innerhalb des Blocks befinden sich Anweisungen, die jeweils mit einem Semikolon abgeschlossen werden. Im Unterschied zu anderen Sprachen muss auch die letzte Anweisung jeweils mit einem Semikolon enden. Der Block selbst wird aber nicht mit einem Semikolon abgeschlossen.
Der R¨uckgabetyp von main() ist immer int. int steht f¨ur integer“ und repr¨asentiert einen ” ganzzahligen Datentyp (siehe die Liste der Datentypen auf Seite 36). Der dazugeh¨orige R¨uckgabewert wird vom Programm an die aufrufende Umgebung zur¨uckgeliefert. main() liefert also einen ganzzahligen Wert an die Aufrufumgebung von main() zur¨uck. Damit kann in der Aufrufumgebung des Programms auswertet werden, ob das Programm erfolgreich gelaufen ist. Als Konvention bedeutet der R¨uckgabewert 0 bei main(), dass das Programm erfolgreich gelaufen ist; jeder andere Wert bedeutet, dass das Programm nicht erfolgreich gelaufen ist. Dabei kann jeder Programmierer selbst festlegen, welche von 0 verschiedenen R¨uckgabewerte von main() was bedeuten.
Sandini Bib
3.1 Das erste Programm
29
Eigentlich m¨usste jede Funktion, die einen R¨uckgabewert deklariert, auch eine dazugeh¨orige Return-Anweisung besitzen, mit der der Wert zur¨uckgeliefert wird. Doch main() bildet hier eine Ausnahme. Am Ende von main() befindet sich immer eine implizite Anweisung, die 0 zur¨uckliefert:
return 0; Jedes Programm ist also als Default immer erfolgreich gelaufen. Man kann die Anweisung nat¨urlich auch explizit hinschreiben:2
#include
// Deklarationen f¨ur Ein-/Ausgaben
int main () // Hauptfunktion main() { std::cout << "Hallo, Welt!" << std::endl; // bei main() eigentlich unn¨otig return 0; } Hier unterscheidet sich C++ von C. In C gibt es kein implizites return 0 am Ende von main(). Dies bedeutet, dass C-Programme, die in main() keine Return-Anweisung enthalten, im Gegensatz zu C++-Programmen einen undefinierten Wert (im Sinne von irgendeinen beliebigen“ ” Wert) an die Aufrufumgebung des Programms liefern. Einem Programm k¨onnen vom Aufrufer auch Argumente u¨ bergeben werden, die in main() als Parameter ankommen. Darauf wird in Abschnitt 3.9.1 auf Seite 126 eingegangen.
3.1.4
Ein-/Ausgaben
Die Funktionalit¨at von Ein-/Ausgaben ist in C++ kein eigentliches Sprachmittel, sondern eine Anwendung von Elementen, die mit Hilfe der Sprachmittel programmiert wurden und u¨ ber eine Standardbibliothek angeboten werden. F¨ur die Ein-/Ausgabe werden also Sprachmittel angewendet, die in diesem Buch erst noch vermittelt werden. Aus diesem Grund wird an dieser Stelle nur ganz rudiment¨ar auf die Grundkonzepte der Ein-/Ausgabe eingegangen, so dass man einfache Ein- und Ausgaben formulieren kann. Der Mechanismus ist aus Anwendersicht relativ einfach. Zun¨achst m¨ussen die standardisierten Sprachmittel von C++ f¨ur Ein- und Ausgaben im Programm bekannt gemacht werden. Dies geschieht mit Hilfe einer Include-Anweisung:
#include Diese Include-Anweisung wird vom so genannten Pr¨aprozessor bearbeitet. Dieser f¨ugt die in iostream befindlichen Deklarationen ein, als w¨aren sie direkt hier von Hand hingeschrieben worden. Mit der Einbindung der Deklarationen zu den IOStreams (Datenstr¨ome zur Ein- und Ausgabe) werden unter anderem die in Tabelle 3.1 aufgef¨uhrten Symbole definiert. Falls Ihr Compiler meint, dass im ersten Beispiel am Ende von main() eine entsprechende ReturnAnweisung fehlt, ist er leider noch nicht standardkonform.
2
Sandini Bib
30
Kapitel 3: Grundkonzepte von C++-Programmen Symbol
std::cin std::cout std::cerr std::endl
Bedeutung Standard-Eingabekanal (typischerweise die Tastatur) Standard-Ausgabekanal (typischerweise der Bildschirm) Standard-Fehlerausgabekanal (typischerweise der Bildschirm) Symbol f¨ur die Ausgabe eines Zeilenumbruchs und das wirkliche Absenden der Ausgabe ( newline“ und flushing“) ” ” Tabelle 3.1: Fundamentale Symbole der Standard-Ein-/Ausgabe
Die Eingabe erfolgt u¨ ber den Standard-Eingabekanal, dem im Allgemeinen die Tastatur zugeordnet ist. Die Ausgabe erfolgt auf den Standard-Ausgabekanal, dem im Allgemeinen der Bildschirm zugeordnet ist. Beides kann aber vom Betriebssystem oder mit Hilfe von Systemfunktionen auch umgelenkt“ werden. ” F¨ur Fehlermeldungen existiert zus¨atzlich der Standard-Fehlerausgabekanal, dem im Allgemeinen ebenfalls der Bildschirm zugeordnet ist. Durch Trennung von normalen und Fehlerausgaben k¨onnen diese Ausgaben von der Umgebung, in der das Programm l¨auft, unterschiedlich behandelt werden. Auf diese Weise k¨onnen z.B. Fehlermeldungen in einer Datei protokolliert werden. Um Daten auszugeben, m¨ussen diese einfach mit dem Operator << an den Ausgabekanal gesendet“ werden. Dabei ist es m¨oglich, mehrere Ausgaben zu verketten: ”
#include ...
std::cout << "Jetzt kommt eine Zahl: " << 42 << std::endl; Die ganze Zahl 42 wird dabei automatisch in die Zeichenfolge 4“ gefolgt von 2“ konvertiert, ” ” und std::endl h¨angt ein Zeilenende an und sorgt daf¨ur, dass der Ausgabepuffer auch geleert wird. Entsprechend kann man mit dem Operator >> von einem Eingabekanal lesen. Dazu folgen noch Beispiele.
3.1.5
Namensbereiche
Beide Symbole der Ein-/Ausgabe-Bibliothek beginnen mit std::. Dieses Pr¨afix legt fest, dass cout und endl jeweils im Namensbereich std definiert sind. Dieser Namensbereich steht f¨ur die Standardbibliothek von C++. Mit dem Konzept der Namensbereiche kann man Symbole logisch gruppieren. Damit werden einerseits Namenskonflikte vermieden und andererseits wird deutlich gemacht, welche Symbole logisch zusammengeh¨oren (ein Paket oder eine Komponente bilden). Mit std::cout verwenden wir also das Symbol cout der Komponente mit dem Namen std. Das Konzept der Namensbereiche wurde erst im Rahmen der Standardisierung von C++ eingef¨uhrt. In den ersten Versionen von C++ gab es kein derartiges Konzept. Entsprechend hatten die Headerdateien, die Symbole f¨ur die Anwendung von existierenden Bibliotheken und Komponenten definieren, einen anderen Aufbau. Insofern kann es vorkommen, dass man auf Programme
Sandini Bib
3.1 Das erste Programm
31
trifft, die einen leicht modifizierten Aufbau haben. Die fr¨uhere Version von main() sah in etwa wie folgt aus:
// allg/halloalt.cpp /* Das erste C++-Programm * - Version f¨ur noch nicht standardkonforme Umgebungen */
#include // Deklarationen f¨ur Ein-/Ausgaben int main () // Hauptfunktion main() { /* ”Hallo, Welt!” auf Standard-Ausgabekanal cout * gefolgt von einem Zeilenende (endl) ausgeben */ cout << "Hallo, Welt!" << endl; } Im Wesentlichen gibt es folgende Unterschiede:
Alle Symbole der Standardbibliothek wurden global und nicht in einem besonderen Namensbereich definiert. Die Standard-Ausgabe wird somit mit cout statt mit std::cout angesprochen. Entsprechend wurden andere Headerdateien verwendet, die die Endung .h besitzen. Statt
#include wurde also
#include eingebunden.
3.1.6
Zusammenfassung
C++-Programme besitzen die Hauptfunktion main(). Diese Funktion wird zum Programmstart automatisch aufgerufen, und mit dem Verlassen dieser Funktion wird das Programm beendet.
main() kann mit return einen ganzzahligen Wert zur¨uckliefern, der angibt, ob das Programm erfolgreich gelaufen ist. Die Anweisung return 0; steht f¨ur ein Programmende nach einem erfolgreichen Programmverlauf; jeder andere Wert steht f¨ur einen nicht erfolgreichen Programmverlauf.
Sandini Bib
32
Kapitel 3: Grundkonzepte von C++-Programmen
main() besitzt am Ende ein implizites return 0;
Jede Anweisung muss grunds¨atzlich mit einem Semikolon abgeschlossen werden. Durch #include sind die Variablen std::cout und std::endl definiert, die mit Hilfe des Operators << Ausgaben erm¨oglichen. Alle Symbole der Standardbibliothek befinden sich im Namensbereich std, was durch die Qualifizierung mit std:: verdeutlicht wird.
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
33
3.2 Datentypen, Operatoren, Kontrollstrukturen Dieser Abschnitt stellt die elementaren Sprachmittel von C++ vor, die in jeder h¨oheren Programmiersprache angeboten werden: fundamentale Datentypen, Operatoren und Kontrollstrukturen. Zun¨achst wird dazu ein weiteres Programm vorgestellt. Im Anschluss werden die darin vorkommenden einzelnen Sprachmittel generell erl¨autert.
3.2.1
Ein erstes Programm, das wirklich etwas berechnet
Zur Einf¨uhrung der elementaren Sprachmittel soll zun¨achst ein einfaches Programm dienen. Dieses Programm berechnet Folgendes: Es gibt einige wenige vierstellige Zahlen, die, wenn man die ersten beiden Stellen von den letzten beiden Stellen abtrennt, die abgetrennten zweistelligen Zahlen quadriert und dann wieder addiert, wieder die urspr¨ungliche vierstellige Zahl ergeben. Dies trifft z.B. auf die Zahl 1233 zu: 2 2 1233 ist das Gleiche wie 12 + 33 Hier folgt das Programm, das diese Zahlen berechnet:
// allg/vierstellig.cpp #include int main () { int zaehler = 0;
// C++-Headerdatei f¨ur Ein-/Ausgaben
// aktuelle Anzahl der gefundenen Zahlen
// f¨ur jede Zahl zahl von 1000 bis 9999
for (int zahl=1000; zahl<10000; ++zahl) { // die vorderen und hinteren beiden Ziffern abspalten int vorn = zahl/100; // die ersten beiden Ziffern int hinten = zahl%100; // die letzten beiden Ziffern // Falls die Summe der Quadrate die urspr¨ungliche Zahl ergibt, // Zahl ausgeben und Z¨ahler inkrementieren
}
if (vorn*vorn + hinten*hinten == zahl) { std::cout << zahl << " == " << vorn << "*" << vorn << " + " << hinten << "*" << hinten << std::endl; ++zaehler; }
// Anzahl der gefundenen Zahlen ausgeben
}
std::cout << zaehler << " Zahlen gefunden" << std::endl;
Sandini Bib
34
Kapitel 3: Grundkonzepte von C++-Programmen
Auch in diesem Programm wird das Verhalten des Programms innerhalb der Funktion main() programmiert:
int main() { ...
} Dort wird zun¨achst eine Variable definiert, die dazu dient zu z¨ahlen, wie viele vierstellige Zahlen gefunden wurden, die die geforderte Eigenschaft besitzen:
int zaehler = 0; Der Datentyp int steht f¨ur ganzzahlige Werte. Es handelt sich um einen fundamentalen Datentyp der Sprache, der von C u¨ bernommen wurde. zaehler ist der Name der Variablen. Eine Variable ist ab dem Zeitpunkt ihrer Deklaration bis zum Ende des Blocks, in dem sie deklariert wird, bekannt. Ein Block wird jeweils durch geschweifte Klammern eingeschlossen. zaehler ist also bis zum Ende der Funktion main() bekannt. zaehler wird mit 0 initialisiert. Gibt es keine derartige Initialisierung, dann ist der initiale Wert dieser Variablen nicht definiert! Es gibt f¨ur derartige lokale Variablen also nicht unbedingt immer einen Default-Wert. Aus diesem Grund sollte man den Startwert einer Variablen, sofern dieser relevant ist, immer explizit angeben. Den Hauptteil von main() bildet eine For-Schleife: // f¨ur jede Zahl zahl von 1000 bis 9999
for (int zahl=1000; zahl<10000; ++zahl) { ...
} For-Schleifen sind ein in C++ verallgemeinerter Mechanismus f¨ur Schleifen, die u¨ ber bestimmte Werte iterieren. Eigentlich ist eine For-Schleife nichts anderes als eine Kombination von drei Anweisungen: 1. Die erste Anweisung wird am Anfang einmal durchgef¨uhrt:
int zahl=1000
// Laufvariable zahl mit 1000 initialisieren
2. Die mittlere Anweisung definiert die Bedingung, unter der die Schleife l¨auft:
zahl<10000
// so lange zahl kleiner als 10000 ist,
3. Die letzte Anweisung wird nach jedem Schleifendurchlauf einmal durchgef¨uhrt:
++zahl
// zahl inkrementieren (um eins erh¨ohen)
Man k¨onnte die gesamte Schleife auch wie folgt schreiben:
{
int zahl=1000; while (zahl<10000) { ...
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
}
}
35
++zahl;
Die Schleife initialisiert also eine Laufvariable zahl mit dem Wert 1.000 und l¨asst diese Variable f¨ur jeden Wert, der kleiner als 10.000 ist, die entsprechenden Anweisungen in der Schleife durchf¨uhren. Die Schleife wird also f¨ur alle Werte von 1.000 bis 9.999 aufgerufen, wobei der jeweils aktuelle Wert in zahl steht. Innerhalb der Schleife wird jeweils untersucht, ob das Quadrat der beiden vorderen Ziffern plus das Quadrat der beiden hinteren Ziffern wieder die urspr¨ungliche Zahl ergibt. Dazu werden zun¨achst die beiden vorderen und hinteren Ziffern abgespalten:
int vorn = zahl/100; int hinten = zahl%100;
// die ersten beiden Ziffern // die letzten beiden Ziffern
Der Operator / ist der Divisionsoperator und teilt zahl ganzzahlig durch Hundert. Der Operator % ist der Modulo-Operator und liefert den Rest einer ganzzahligen Division. Mit einer If-Anweisung wird dann gepr¨uft, ob die Summe der Quadrate dieser beiden Zahlen wieder die urspr¨ungliche Zahl ergibt:
if (vorn*vorn + hinten*hinten == zahl) { ...
} Der Operator * ist der Multiplikationsoperator, und + bildet die Summe. Ungew¨ohnlich mag die Verwendung von zwei Gleichheitszeichen zum Vergleichen sein. Dies liegt daran, dass das einfache Gleichheitszeichen f¨ur Zuweisungen verwendet wird:
a = b;
// a bekommt den Wert von b
Die Designer von C hatten f¨ur Zuweisungen das einfache Gleichheitszeichen gew¨ahlt, da dieser Operator mit Abstand am h¨aufigsten vorkommt und es somit unn¨otig Zeit und Platz kostet, daf¨ur durch eine Schreibweise wie := zwei Zeichen zu verwenden. Ist die Bedingung erf¨ullt, wird eine entsprechende Meldung ausgegeben:
std::cout << zahl << " == " << vorn << "*" << vorn << " + " << hinten << "*" << hinten << std::endl; In dieser mehrzeiligen Anweisung werden nacheinander der Wert von zahl, gefolgt von den Zeichen == “, gefolgt von dem Wert von vorn, gefolgt von dem Zeichen *“, nochmal gefolgt ” ” von dem Wert von vorn und so weiter ausgegeben. Eine Ausgabezeile sieht z.B. wie folgt aus:
1233 == 12*12 + 33*33 Anschließend wird der Z¨ahler f¨ur das Auftreten der Eigenschaft mit dem Inkrement-Operator um eins erh¨oht:
++zaehler;
Sandini Bib
36
Kapitel 3: Grundkonzepte von C++-Programmen
Am Ende der Schleife wird dessen Wert dann ausgegeben:
std::cout << zaehler << " Zahlen gefunden" << std::endl; Die folgenden Abschnitte gehen auf die hier verwendeten Sprachmittel wie Variablen, fundamentale Datentypen und Kontrollstrukturen im Einzelnen ein.
3.2.2
Fundamentale Datentypen
C++ verf¨ugt u¨ ber die in Tabelle 3.2 aufgelisteten fundamentalen Datentypen (FDTs). Bis auf bool wurden diese Datentypen von C u¨ bernommen. Die folgenden Unterabschnitte geben spezielle Hinweise zu den einzelnen Datentypen. Datentyp
int float double char bool enum void
Bedeutung ganzzahliger Wert (typische Wortgr¨oße des Rechners) Gleitkommawert einfacher Genauigkeit Gleitkommawert doppelter Genauigkeit Zeichen (kann auch als ganzzahliger Wert verwendet werden) Boolescher Wert (true oder false) f¨ur Aufz¨ahlungstypen (Namen, die ganzzahlige Werte repr¨asentieren) nichts“ (f¨ur Funktionen ohne R¨uckgabewert ” und leere Parameterlisten in ANSI-C) Tabelle 3.2: Liste der fundamentalen Datentypen
Numerische Datentypen Die numerischen Datentypen (int, float, double) k¨onnen auf verschiedenen Plattformen eine unterschiedliche Gr¨oße und damit einen unterschiedlichen Wertebereich besitzen. Die tats¨achliche Gr¨oße der numerischen Datentypen ist in C++ also nicht festgelegt. Dahinter steckt das von C u¨ bernommene Konzept, dass int die f¨ur die darunter liegende Hardware typische Gr¨oße besitzt. Bei einem 32-Bit-Rechner h¨atte int also typischerweise 32 Bits, bei einem 64-Bit-Rechner entsprechend typischerweise 64 Bits. Allerdings kann die Gr¨oße und auch die Frage, ob es sich um einen vorzeichenfreien oder vorzeichenbehafteten Datentyp handelt, durch die in Tabelle 3.3 aufgelisteten Attribute genauer qualifiziert werden. Attribut
short long unsigned signed
Bedeutung eventuell kleiner als normal“ ” eventuell gr¨oßer als normal“ ” vorzeichenfrei vorzeichenbehaftet
Tabelle 3.3: Qualifizierende Attribute zu fundamentalen Datentypen
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
37
Bei der Angabe eines qualifizierenden Attributs kann die Angabe des eigentlichen fundamentalen Datentyps dann sogar entfallen. In diesem Fall wird int angenommen:
int x1; // Integer mit normalem Wertebereich long int x2; // Integer mit eventuell gr¨oßerem Wertebereich long x3; // dito unsigned x4; // vorzeichenfreier Integer mit normalem Wertebereich Unter Standard-C++ kann dabei von den in Tabelle 3.4 aufgelisteten Mindestgr¨oßen ausgegangen werden. Datentyp
char short int int long int float double long double
Mindestgr¨oße 1 Byte (8 Bits) 2 Byte (16 Bits) 2 Byte (16 Bits) 4 Byte (32 Bits) 6 Stellen bis 1037 10 Stellen bis 1037 10 Stellen bis 1037
Tabelle 3.4: Mindestgr¨oßen f¨ur fundamentale Datentypen
Die genauen Gr¨oßen werden mit Hilfe der numeric_limits in der Headerdateien mit zahlreichen weiteren Informationen zum Datentyp definiert (siehe Abschnitt 9.1.4 auf Seite 533). Zeichen F¨ur einzelne Zeichen wird der Datentyp char verwendet. Zeichen-Literale werden jeweils in einfache Anf¨uhrungsstriche eingeschlossen. 'a' steht also f¨ur das Zeichen a“. Zwischen den ” Anf¨uhrungsstrichen darf jedes beliebige Zeichen bis auf einen Zeilenumbruch stehen. Manche Zeichen m¨ussen allerdings mit einem R¨uckstrich (Backslash) maskiert werden. Außerdem k¨onnen mit Hilfe des R¨uckstrichs Sonderzeichen verwendet werden (siehe Tabelle 3.5). Das Literal '\n' steht also f¨ur einen Zeilenumbruch, das Literal '\'' steht f¨ur den einfachen Anf¨uhrungsstrich, und das Literal '\\' steht f¨ur den R¨uckstrich selbst. Diese Sonderzeichen d¨urfen auch in String-Literalen verwendet werden. Der String
"Einige Sonderzeichen: \"\t\'\n\\\100\x3F\n" steht also f¨ur folgende Zeichenfolge (im ASCII-Zeichensatz hat @ den oktalen Wert 100 und das Fragezeichen den hexadezimalen Wert 3F):
Einige Sonderzeichen: " \@?
'
Zeichen werden intern wie ganzzahlige Werte verwaltet. Genau genommen ist '\n' nur eine andere Repr¨asentation f¨ur den Wert des Zeichens f¨ur den Zeilenumbruch im aktuellen Zeichensatz. Man k¨onnte den Typ char somit auch f¨ur Werte mit der Gr¨oße 1-Byte verwenden. Mit dem qua-
Sandini Bib
38
Kapitel 3: Grundkonzepte von C++-Programmen Zeichen
\' \" \\ \n \t \oct-ziffern \xhex-ziffern \b \f \r
Bedeutung einfacher Anf¨uhrungsstrich doppelter Anf¨uhrungsstrich R¨uckstrich (Backslash) Zeilenumbruch / Newline Tabulator-Zeichen Zeichen mit oktalem Wert aus ein bis drei Ziffern Zeichen mit hexadezimalem Wert Backspace Form-Feed Carriage-Return Tabelle 3.5: Sonderzeichen
lifizierenden Attribut signed oder unsigned k¨onnte man dabei festlegen, ob der Wertebereich von -128 bis 127 oder von 0 bis 255 geht.3 Ohne Qualifizierung ist dies nicht definiert. Mit diesem Wissen kann man ein einfaches Programm schreiben, das den aktuellen Zeichensatz ausgibt. Das folgende Programm gibt alle Zeichen mit den Werten 32 bis 126 aus (die Werte unter 32 und der Wert 127 sind im ASCII-Zeichensatz Sonderzeichen):
// allg/charset.cpp /* Zeichensatz ausgeben */ #include
// Deklarationen f¨ur Ein-/Ausgaben
int main () {
// f¨ur jedes Zeichen c mit einem Wert von 32 bis 126 for (unsigned char c=32; c<127; ++c) { // Wert als Zahl und als Zeichen ausgeben:
}
}
std::cout << "Wert: " << static_cast(c) << " Zeichen: " << c << std::endl;
Im Programm wird c als ganzzahliger Wert verwendet, der u¨ ber die Werte ab 32 iteriert. Innerhalb der Schleife wird c einmal als Zahl und einmal als Zeichen ausgegeben:
std::cout << ... << static_cast(c) << ... << c << ...; Da vom Standard nur die Mindestgr¨oße von chars festgelegt wird, kann der tats¨achliche Wertebereich auch dar¨uber hinausgehen.
3
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
39
Da c den Datentyp char hat, wird es standardm¨aßig als Zeichen ausgegeben. Durch den Ausdruck
static_cast(c) wird c explizit in den Datentyp int umgewandelt. static_cast ist ein Operator, der Grunds¨atzlich zur logischen Typumwandlung verwendet werden kann, sofern diese Typumwandlung auch m¨oglich ist. Auf diese Weise wird sichergestellt, dass der ganzzahlige Wert von c ausgegeben wird. Die Ausgabe lautet (bei einem ASCII-Zeichensatz):
Wert: Wert: Wert: Wert: Wert: Wert: Wert:
32 33 34 35 36 37 38
Zeichen: Zeichen: Zeichen: Zeichen: Zeichen: Zeichen: Zeichen:
! " # $ % &
...
Wert: Wert: Wert: Wert: Wert: Wert: Wert:
120 121 122 123 124 125 126
Zeichen: Zeichen: Zeichen: Zeichen: Zeichen: Zeichen: Zeichen:
x y z { | } ~
Boolesche Werte F¨ur Boolesche Werte gab es lange Zeit in C++ wie in C keinen speziellen Datentyp. Sie wurden mit Hilfe von ganzzahligen Werten verwaltet. Dabei steht 0 f¨ur false und jeder andere Wert f¨ur true. In C++ wurde f¨ur Boolesche Werte der Datentyp bool mit den Literalen true und false eingef¨uhrt. Um R¨uckw¨artskompatibilit¨at zu gew¨ahrleisten, k¨onnen neben den Konstanten true und false auch weiterhin noch ganzzahlige Werte verwendet werden. Dabei steht 0 grunds¨atzlich f¨ur false und jeder andere Wert f¨ur true. Bedingungen in Kontrollstrukturen sind somit immer dann erf¨ullt, wenn der darin stehende Ausdruck den Wert true oder einen von null verschiedenen ganzzahligen Wert besitzt.
3.2.3
Operatoren
C++ bietet eine ungew¨ohnlich große Anzahl von Operatoren. Die grundlegenden Operatoren, die von C u¨ bernommen wurden, werden hier zun¨achst kurz vorgestellt. Im weiteren Verlauf des Buchs werden alle anderen Operatoren eingef¨uhrt. Auf Seite 571 befindet sich eine Liste aller C++-Operatoren.
Sandini Bib
40
Kapitel 3: Grundkonzepte von C++-Programmen
Grundlegende Operatoren Tabelle 3.6 listet die grundlegenden Operatoren auf, die man in jeder Programmiersprache zur Verkn¨upfung von Werten wiederfindet. Operator
+ * / % < <= > >= == != && || !
Bedeutung Addition, positives Vorzeichen Subtraktion, negatives Vorzeichen Multiplikation Division Modulo-Operator (Rest nach Division) kleiner kleiner gleich gr¨oßer gr¨oßer gleich gleich ungleich logisches UND (Auswertung bis zum ersten false) logisches ODER (Auswertung bis zum ersten true) logische Negation Tabelle 3.6: Grundlegende Operatoren
Bemerkenswert ist dabei der Aspekt, dass der Test auf Gleichheit mit zwei Gleichheitszeichen erfolgt. Ein einzelnes Gleichheitszeichen wird als Zuweisungsoperator verwendet. Vorsicht: Wenn man bei Bedingungen versehentlich nur ein Gleichheitszeichen verwendet, ist dies durchaus g¨ultiger Code; er macht nur nicht, was er soll. Mit der Zeile
if (x = 42)
// korrekter Code, aber unerw¨unschte Semantik
wird nicht etwa getestet, ob x den Wert 42 hat, sondern es wird x der Wert 42 zugewiesen und die Bedingung wird als erf¨ullt betrachtet. Da eine Zuweisung den zugewiesenen Wert liefert, liefert der Ausdruck x = 42“ insgesamt den Wert 42, was ungleich null ist und somit als true ” interpretiert wird. Insofern handelt es sich um korrekten Code, der immer x den Wert 42 zuweist und bei dem die Bedingung unabh¨angig vom vorherigen Wert von x immer erf¨ullt ist. Von manchen Programmierern wird diese M¨oglichkeit ausgenutzt, um besonders pr¨agnanten Code zu schreiben, u¨ ber dessen Lesbarkeit man streiten kann. So ist es durchaus nicht un¨ublich, folgende Bedingungen zu formulieren:
if (x = f())
// weist x den R¨uckgabewert von f() zu und // testet gleichzeitig, ob dieser Wert ungleich 0 ist
Derartiger Code ist sicherlich ein Grund daf¨ur, dass C und C++ manchmal einen etwas zweifelhaften Ruf haben. Man kann sehr pr¨agnant formulieren, diesen Code aber auch als sehr unleserlich empfinden. Dies liegt aber auch daran, dass man derartige M¨oglichkeiten aus anderen Sprachen nicht gewohnt ist. Man sollte also mit einer Verdammung derartiger Formulierungen
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
41
warten, bis man sich in C++ etwas zu Hause f¨uhlt. Das bedeutet aber nicht, dass man sich an alles gew¨ohnen sollte. Oberstes Ziel ist nach wie vor die Lesbarkeit des Codes. Zuweisungen Wie im vorigen Absatz schon erw¨ahnt wurde, ist eine Zuweisung ein Operator, der durch ein Gleichheitszeichen formuliert wurde.4 Doch C++ definiert wie C nicht nur einen Zuweisungsoperator. Neben der reinen Zuweisung kann eine Variable mit Hilfe kombinierter Zuweisungsoperatoren relativ zu ihrem aktuellen Wert modifiziert werden. Tabelle 3.7 listet alle Zuweisungsoperatoren auf. Operator
= *= /= %= += -= <<= >>= &= ^= |=
Bedeutung einfache Zuweisung
a op= b“ entspricht im Allgemeinen a = a op b“ ”
”
Tabelle 3.7: Zuweisungsoperatoren
F¨ur alle Zuweisungsoperatoren gilt, dass eine Zuweisung Teil eines gro¨ ßeren Ausdrucks sein kann. Ein Ausdruck mit einem Zuweisungsoperator liefert den Wert bzw. die Variable, der ein neuer Wert zugewiesen wurde. Damit sind insbesondere verkettete Zuweisungen m¨oglich:
x = y = 42;
// x und y erhalten den Wert 42
Der Ausdruck wird als
x = (y = 42); ausgewertet. In der Klammer wird y also zun¨achst 42 zugewiesen. Der Ausdruck in der Klammer liefert dann y, was schließlich x zugewiesen wird. Auf diese Weise erh¨alt also auch x den Wert 42. Entsprechend kann man eine Zuweisung zu einem Teil eines gr¨oßeren Ausdrucks machen:
if ((x = f()) < 42)
4
// x den R¨uckgabewert von f() zuweisen und // diesen Wert mit 42 vergleichen
Die Designer von C meinten mit Recht, dass es eigentlich keinen Sinn machte, dem mit Abstand am h¨aufigsten vorkommenden Operator einen Namen zu geben, der wie in anderen Programmiersprachen u¨ blich aus zwei Zeichen (wie etwa :=) besteht.
Sandini Bib
42
Kapitel 3: Grundkonzepte von C++-Programmen
Die anderen Zuweisungsoperatoren sind abk¨urzende Schreibweisen f¨ur die Verkn¨upfung zweier Operatoren: dem Operator, der vor dem Gleichheitszeichen befindlichen Zeichen, und der Zuweisung. Dabei gilt f¨ur fundamentale Datentypen immer:
x op= y
x = x op y
entspricht
Diese Operatoren wurden eingef¨uhrt, da Compiler zum Zeitpunkt der Erfindung von C noch nicht optimierten und C aber zur Implementierung eines laufzeitkritischen Betriebssystems (UNIX) entwickelt wurde. Indem man statt
x = x + 7;
// x um 7 erh¨ohen
nun
x += 7;
// x um 7 erh¨ohen
schreiben konnte, konnte man wirklich Laufzeit sparen, da der Compiler auch ohne Codeanalyse wusste, dass der Wert, zu dem 7 addiert wird, sich an der gleichen Stelle befindet, an der das Ergebnis der Addition eingetragen werden muss. Heutzutage sollten Compiler derartig triviale Optimierungen in der Regel beherrschen. Die Schreibweise wird aber dennoch weiterhin verwendet, da sie pr¨agnanter ist, ohne unleserlich zu sein. Inkrement- und Dekrement-Operatoren In C wurden auch spezielle Operatoren zum Inkrementieren (erh¨ohen um 1) und Dekrementieren (vermindern um 1) eingef¨uhrt (siehe Tabelle 3.8). Auch hier lag der Grund f¨ur die Einf¨uhrung der Operatoren in der M¨oglichkeit, spezielle Befehle von Prozessoren auch ohne optimierende Compiler unterst¨utzen zu k¨onnen. Operator
++ -++ --
Bedeutung Postfix-Inkrement ( a++“) ” Postfix-Dekrement ( a--“) ” Pr¨afix-Inkrement ( ++a“) ” Pr¨afix-Dekrement ( --a“) ”
Tabelle 3.8: Inkrement- und Dekrement-Operatoren
Dabei gibt es aber jeweils zwei Varianten des Inkrement- und Dekrement-Operators, eine Pr¨afixund eine Postfix-Version. Beide Versionen erh¨ohen bzw. vermindern den Wert einer Variablen um 1. Der Unterschied besteht darin, was der Ausdruck als Ganzes liefert. Die Pr¨afix-Version (die Version, bei der der Operator vor der Variablen steht) liefert den Wert der Variablen nach der Erh¨ohung:
x = 42; std::cout << ++x; std::cout << x;
// gibt 43 aus (erst erh¨ohen, dann Wert von x) // gibt 43 aus
Die Postfix-Version (die Version, bei der der Operator hinter der Variablen steht) liefert den Wert der Variablen vor der Erh¨ohung:
x = 42; std::cout << x++;
// gibt 42 aus (erst Wert von x, dann erh¨ohen)
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
std::cout << x;
43
// gibt 43 aus
Von diesem Operator leitet sich auch der Name von C++ her: ein Schritt weiter als C“.5 ” Sofern Inkrement- und Dekrement-Operatoren nicht als Teil eines gr¨oßeren Ausdrucks verwendet werden, sollte man sich angew¨ohnen, die Pr¨afix-Version, also z.B. ++x, zu verwenden. Die andere Version kann dazu f¨uhren, dass ein tempor¨arer Wert f¨ur den alten Wert von x erzeugt wird, der nicht immer wegoptimiert werden kann. Bit-Operatoren Die von C u¨ bernommene Hardware-N¨ahe von C++ kann man auch an der Tatsache erkennen, dass es spezielle Operatoren gibt, die bitweise operieren. Sie werden in Tabelle 3.9 aufgelistet. Operator
<< >> & ^ | ~
Bedeutung Links-Shift (es werden Nullen nachgeschoben) Rechts-Shift (es werden Nullen nachgeschoben) bitweises UND bitweises XOR (exklusives oder) bitweises ODER Bit-Komplement Tabelle 3.9: Bitweise operierende Operatoren
Die Operatoren << und >> sind uns schon bei der Ein-/Ausgabe begegnet. Die dortige Verwendung ist, wenn man so will, eine Zweckentfremdung dieser Shift-Operatoren f¨ur spezielle Datentypen, den so genannten I/O-Streams. Diese Verwendung von << und >> f¨uhrt inzwischen sogar dazu, dass diese Operatoren in C++ auch Ausgabe- und Eingabeoperatoren genannt werden.6 Entscheidend ist somit, welchen Datentyp jeweils die Operanden besitzen:
Solange beide Operanden einen fundamentaler ganzzahligen Datentyp besitzen, handelt es sich um eine Shift-Operation:
int x; x << 2;
// shiftet die Bits in x um zwei Stellen
Der rechte Operand darf dabei nicht negativ sein. Ist der erste Operand ein I/O-Stream wie z.B. std::cout, handelt es sich um eine Ein- oder Ausgabe:
std::cout << 2; // gibt die Zahl 2 aus 5
Eine interessante Frage ist, warum die Sprache nicht ++C“ genannt wurde. Verwendet man C++ als Teil ” eines gr¨oßeren Ausdrucks, liefert dieser Ausdruck den alten Wert, also C :-). 6 Auf die genaue Motivation dieser Zweckentfremdung wird bei der Diskussion der Ein-/Ausgabetechnik von C++ in Abschnitt 4.5 noch ausf¨uhrlich eingegangen.
Sandini Bib
44
Kapitel 3: Grundkonzepte von C++-Programmen
Spezielle Operatoren Drei weitere Operatoren verdienen eine speziellere Beachtung. Sie werden in Tabelle 3.10 aufgelistet. Operator
?: , sizeof (...)
Bedeutung bedingte Bewertung Folge von Ausdr¨ucken Speicherplatz
Tabelle 3.10: Spezielle Operatoren
Bedingte Bewertung Der Operator ?: erm¨oglicht eine so genannte bedingte Bewertung. Abh¨angig von einer Bedingung wird einer von zwei m¨oglichen Werten geliefert. Es handelt sich dabei um den einzigen dreistelligen Operator von C++. Der Ausdruck bedingung ? ausdruck1 : ausdruck2 liefert ausdruck1, wenn die Bedingung erf¨ullt ist, und sonst ausdruck2. Dadurch k¨onnen If-Abfragen abgek¨urzt werden, bei denen ein Wert sowohl im Then- als auch im Else-Fall manipuliert wird.
if (x < y) { z = x; } else { z = y; }
// z das Minimum von x und y zuweisen
kann man kurz Folgendes schreiben:
z =
x < y
?
x
:
y;
Wenn x kleiner als y ist, wird x, sonst y zugewiesen. Ein anderes Beispiel w¨are:
std::cout <<
(x==0
?
"false" : "true")
<< std::endl;
Falls x den Wert null hat wird false, sonst true ausgegeben. Da dieser Operator eine sehr geringe Priorit¨at hat, muss er in diesem Fall geklammert werden. Komma-Operator Der Komma-Operator erlaubt es, zwei Anweisungen zu einer Anweisung zusammenzuziehen. Statt
x = 42; y = 33;
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
45
k¨onnte man auch
x = 42, y = 33;
schreiben. Dabei liefert dieser Gesamtausdruck das Ergebnis des hinter dem Komma liegenden Teilausdrucks. Sinn macht dieser Operator an Stellen, an denen man mehrere Anweisungen durchf¨uhren will, aber nur die Angabe einer Anweisung m¨oglich ist. Ein typisches Beispiel daf¨ur sind For-Schleifen (siehe Seite 47). Normalerweise sollte man diesen Operator nicht verwenden. sizeof-Operator Der sizeof-Operator liefert die Gr¨oße eines Objekts oder Datentyps in Bytes. Dieser Operator wurde in C vor allem gebraucht, um f¨ur Objekte im laufenden Programm Speicherplatz anzufordern. In C++ wird dieser Operator so gut wie nie ben¨otigt.
Priorit¨aten und Klammerung Diese und alle weiteren Operatoren, die im weiteren Verlauf dieses Buches noch eingef¨uhrt werden, haben unterschiedliche Priorit¨aten und Auswertungsreihenfolgen. Die Tabelle aller Operatoren auf Seite 571 listet diese auf. Dabei gelten die u¨ blichen Regeln, wie Punktrechnung geht ” vor Strichrechnung“. Die Priorit¨at und damit die Auswertungsreihenfolge kann durch Klammerung ver¨andert werden. Dazu folgen hier zwei (mehr oder weniger sinnvolle) Beispiele:
(x + y) * z // ohne Klammerung w¨urde erst y mit z multipliziert werden while ((x += 2) < 42 ) // ohne Klammerung w¨urde x um true bzw. 1 // (das Ergebnis von 2 < 42) erh¨oht werden
3.2.4
Kontrollstrukturen
Alle Arten von Kontrollstrukturen wurden in C++ von C u¨ bernommen. Es gibt einfache und mehrfache Fallunterscheidungen, verschiedene Schleifen und die M¨oglichkeit, mehrere Anweisungen zu einem Block zusammenzufassen. Fallunterscheidungen In C++ gibt es folgende Kontrollstrukturen f¨ur Fallunterscheidungen:
if dient zur einfachen Fallunterscheidung:
if (x < 7) { std::cout << "x ist kleiner als 7" << std::endl; } else { std::cout << "x ist groesser oder gleich 7" << std::endl; } Der Else-Zweig kann entfallen.
Sandini Bib
46
Kapitel 3: Grundkonzepte von C++-Programmen switch dient zur mehrfachen Fallunterscheidung, wobei nur mit konstanten Werten verglichen werden darf:
switch (x) { case 7: std::cout << "x ist 7" << std::endl; break; case 17: case 18: std::cout << "x ist 17 oder 18" << std::endl; break; default: std::cout << "x ist weder 7 noch 17 noch 18" << std::endl; break; } Mit dem Label case werden jeweils die Stellen definiert, bei denen ein Fall beginnt. Stimmt der bei switch u¨ bergebene Ausdruck mit diesem Wert u¨ berein, wird mit den Anweisungen an dieser Stelle fortgefahren. Dabei k¨onnen auch mehrere Case-Labels hintereinander stehen. Die Anweisung break; dient innerhalb einer Switch-Anweisung dazu, einen Fall zu beenden und mit den Anweisungen hinter der Switch-Anweisung fortzufahren. Wird ein Fall nicht durch eine Break-Anweisung abgeschlossen, wird mit den Anweisungen des n¨achsten Falls fortgefahren. Das optionale Label default: kennzeichnet alle anderen F¨alle“. Dieser Default-Fall ” kann an beliebiger Stelle stehen. Ist kein Default-Fall aufgef¨uhrt und passt keiner der angegebenen F¨alle, wird gleich hinter der Switch-Anweisung fortgefahren. Schleifen In C++ gibt es folgende Kontrollstrukturen f¨ur Schleifen:
while In der While-Schleife wird vor jedem Durchlauf eine Schleifenbedingung getestet, die jeweils erf¨ullt sein muss:
while (x < 7) { std::cout << "x ist (immer noch) kleiner als 7" << std::endl; // x wird um 1 erh¨oht ++x; } std::cout << "x ist groesser oder gleich 7" << std::endl;
do-while In der Do-While-Schleife wird dagegen nach jedem Durchlauf eine Schleifenbedingung getestet, die zum Fortgang der Schleife jeweils erf¨ullt sein muss. Der Schleifenk¨orper wird im Gegensatz zur While-Schleife also mindestens einmal durchlaufen.
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
47
Im Gegensatz zur Repeat-Until-Schleife von Pascal handelt es sich aber auch hier um eine Bedingung, die f¨ur das Weiterlaufen der Schleife erf¨ullt sein muss, und nicht um eine Abbruchbedingung: // x wird auf jeden Fall einmal inkrementiert und ausgegeben
do {
++x; std::cout << "x: " << x << std::endl; } while (x < 7);
for In der For-Schleife werden im Schleifenkopf s¨amtliche Bedingungen f¨ur den Verlauf der Schleife festgelegt: for (initialisierung; bedingung; reinitialisierung) Die Initialisierung wird am Anfang einmal durchgef¨uhrt. Solange dann die Bedingung erf¨ullt ist, werden die Anweisungen im Schleifenk¨orper durchlaufen. Nach diesen Anweisungen wird jedes Mal, bevor die Bedingung erneut bewertet wird, die Reinitialisierung durchgef¨uhrt. Dies kann typischerweise dazu verwendet werden, eine Schleife u¨ ber eine Wertfolge iterieren zu lassen: // Schleife, bei der i alle Werte von 0 bis 6 durchl¨auft
for (i=0; i<7; ++i) { std::cout << "i hat den Wert: " << i << std::endl; } i wird am Anfang 0 zugewiesen. Die Schleife l¨auft, solange i einen Wert kleiner als 7 besitzt. Mit dem Inkrement-Operator ++ wird i dabei jedes Mal nach dem Durchlaufen der Anweisungen im Schleifenk¨orper um eins erh¨oht. Da die Ausdr¨ucke im Schleifenkopf der For-Schleife beliebig sein k¨onnen, kann ein Schleifenverlauf auch komplexer definiert werden:
/* Schleife, die f¨ur jeden zweiten Wert von 100 bis 0 * durchlaufen wird (100, 98, 96, 94, ..., 0) */ for (i=100; i>=0; i-=2) { std::cout << "i hat den Wert: " << i << std::endl; } i startet mit dem Wert 100 und wird nach jedem Durchgang um 2 vermindert. Die Schleife l¨auft, solange i gr¨oßer oder gleich 0 ist. Die einzelnen Ausdr¨ucke im Kopf einer For-Schleife k¨onnen auch wegfallen. Ist keine Bedingung angegeben, bedeutet das, dass die Bedingung immer erf¨ullt ist. Das kann f¨ur Endlosschleifen verwendet werden: // Endlosschleife
for (;;) { std::cout << "das geht endlos so weiter" << std::endl; }
Sandini Bib
48
Kapitel 3: Grundkonzepte von C++-Programmen Mit Hilfe des Komma-Operators (siehe Seite 44) kann die Initialisierung oder die Reinitialisierung auch aus mehreren Anweisungen bestehen. Damit kann man z.B. bei einem Feld (Array) in einer Schleife zwei Indexe aufeinander zu laufen lassen (Felder werden zwar erst in Abschnitt 3.7.2 eingef¨uhrt, der vorliegende Code sollte aber dennoch verst¨andlich sein): // Werte in einem Feld vertauschen int feld[100]; // Feld von 100 ganzzahligen Werten // die Indexe i und j laufen in der Schleife von aussen in die // Mitte und vertauschen jeweils die Werte
for (int i=0, int tmp = feld[i] = feld[j] = }
int j=99; feld[i]; feld[j]; tmp;
i<j;
++i, --j) {
Am Anfang der Schleife wird i der Wert 0 (das ist der Index des ersten Elements) und j der Wert 99 (das ist der Index des letzten Elements) zugewiesen. Solange i kleiner als j ist, werden dann die Anweisungen im Schleifenk¨orper durchgef¨uhrt. Dabei wird i nach jedem Durchgang um eins erh¨oht und j um eins vermindert. In den Schleifen k¨onnen auch noch zwei besondere Anweisungen stehen:
break dient neben dem Abbruch eines Falls in einer Switch-Anweisung auch zum sofortigen Abbruch einer Schleife. Diese Anweisung bewirkt im Prinzip einen Sprung hinter die Schleife und sollte nur in Sonderf¨allen eingesetzt werden. continue dient zum sofortigen Neueintritt in eine Schleife. Auch dies ist im Prinzip ein Sprung, n¨amlich an die Stelle, an der die Schleife neu bewertet wird (bzw. bei For-Schleifen ein Sprung zur Reinitialisierung), und sollte ebenfalls nur in Sonderf¨allen verwendet werden.
Bl¨ocke Bei allen Kontrollstrukturen wurden in den Beispielen die K¨orper durch geschweifte Klammern eingeschlossen. Durch diese geschweiften Klammern werden jeweils mehrere Anweisungen zu einem Block von Anweisungen zusammengefasst. Dies ist bei Kontrollstrukturen meistens auch notwendig, da der K¨orper jeweils nur eine Anweisung oder eben ein Block von Anweisungen sein kann. Bei zwei einzelnen Anweisungen w¨urde nur die erste Anweisung zur Kontrollstruktur geh¨oren. Die zweite Anweisung w¨are bereits die erste Anweisung hinter der Kontrollstruktur:
if (x < 7) std::cout << "x ist kleiner als 7" << std::endl; std::cout << "diese Anweisung wird in jedem Fall ausgefuehrt" << std::endl; Obwohl es eigentlich unn¨otig ist, empfehle ich, auch bei nur einer Anweisung im K¨orper von Kontrollstrukturen geschweifte Klammern zu verwenden:
Sandini Bib
3.2 Datentypen, Operatoren, Kontrollstrukturen
49
if (x < 7) { std::cout << "x ist kleiner als 7" << std::endl; } std::cout << "diese Anweisung wird in jedem Fall ausgefuehrt" << std::endl; Es ist nicht nur besser lesbar, sondern sch¨utzt auch vor dem Problem, dass beim Einf¨ugen einer zweiten Anweisung im Schleifenk¨orper die Klammern leicht vergessen werden.
3.2.5
Zusammenfassung
C++ bietet verschiedene fundamentale Datentypen f¨ur Zeichen, ganze Zahlen, Gleitkommazahlen und Boolesche Werte. Der genaue Wertebereich der Zahlen ist systemspezifisch. C++ verf¨ugt u¨ ber zahlreiche Operatoren. Dazu geh¨oren auch Operatoren zum Inkrementieren und Dekrementieren sowie modifizierende Zuweisungen. Der Operator = ist die Zuweisung; der Operator == ist der Test auf Gleichheit. C++ verf¨ugt u¨ ber die u¨ blichen Kontrollstrukturen (Fallunterscheidungen, Schleifen). Die For-Schleife erlaubt es, sehr flexibel u¨ ber Werte zu iterieren. Mehrere Anweisungen k¨onnen durch geschweifte Klammern zu einem Anweisungsblock zusammengefasst werden.
Sandini Bib
50
Kapitel 3: Grundkonzepte von C++-Programmen
3.3 Funktionen und Module Ein C++-Programm setzt sich aus einer strukturierten Folge von Anweisungen zusammen. Auf unterster Ebene werden diese Anweisungen durch Kontrollstrukturen und Bl¨ocke gruppiert. Die n¨achsth¨ohere Gliederungsebene sind Funktionen. Der Begriff Funktion steht dabei f¨ur jede Art von selbst definierter aufrufbarer Operation. M¨ogliche andere Bezeichnungen in anderen Kontexten w¨aren Unterprogramm, Prozedur oder Subroutine. Um Funktionen zu strukturieren, gibt es zwei M¨oglichkeiten, eine physikalische und eine logische Gruppierung. F¨ur eine physikalische Gruppierung werden die Funktionen auf unter¨ schiedliche Ubersetzungseinheiten oder Module aufgeteilt. Dabei handelt es sich einfach um verschiedene Dateien, die unabh¨angig voneinander u¨ bersetzt (kompiliert) werden. Diese Grup¨ pierung hat unter anderem den Vorteil, dass man bei einer Anderung in einer Funktion nicht den ¨ gesamten Code neu ubersetzen muss. Es wird nur die Datei neu u¨ bersetzt, in der die Funktion implementiert ist. Diese u¨ bersetzte Datei wird dann mit den anderen u¨ bersetzten Dateien zusammengebunden (gelinkt). Diese Gruppierung hat bietet auch den Vorteil, dass die Funktionen damit eine besondere Einheit bilden und als Ganzes z.B. in mehreren Programmen verwendet werden k¨onnen. Insofern geht eine derartige physikalische Gruppierung in der Regel auch immer mit einer logischen Gruppierung einher. Neben dieser physikalisch basierten Strukturierung (die von C u¨ bernommen wurde) gibt es in C++ aber auch mehrere M¨oglichkeiten der logischen Strukturierung. So gibt es zum einen die M¨oglichkeit, zusammengeh¨origen Code unabh¨angig von physikalischen Grenzen durch einen Namensbereich als zusammengeh¨orig zu kennzeichnen. Eine weitere M¨oglichkeit bietet das objektorientierte Paradigma. Es ordnet Funktionen einem gemeinsamen Datentyp (einer so genannten Klasse) zu. Alle Operationen, die man f¨ur Objekte dieser Klasse aufrufen kann, geh¨oren logisch zu dieser Klasse. Derartige Funktionen nennt man auch Elementfunktionen oder, nach objektorientierter Sprachregelung, Methoden. Eine derartige logische Gruppierung ist im Prinzip unabh¨angig von der physikalischen Gruppierung. Es ist aber in der Regel so, dass man alle Funktionen einer Klasse auch physikalisch in der gleichen Datei verwaltet. In diesem Abschnitt wird zun¨achst die M¨oglichkeit vorgestellt, Funktionen (die hier noch keiner Klasse zugeordnet sind) physikalisch auf verschiedene Module zu verteilen. Anschließend wird auf die M¨oglichkeit einer logischen Strukturierung mit Hilfe von Namensbereichen eingegangen.
3.3.1
Headerdateien
¨ C++ ist eine Sprache mit Typpr¨ufung. Das bedeutet, dass man zur Ubersetzungszeit herausfinden will, ob Operationen wie Funktionsaufrufe zumindest vom Datentyp her sinnvoll sind. Verteilt man Funktionen auf unterschiedliche Module, die getrennt u¨ bersetzt werden, stellt sich die Frage, wie man eine derartige Typpr¨ufung gew¨ahrleisten kann. ¨ Damit die Ubergabe von Daten zwischen Funktionen verschiedener Module funktioniert, wird die Schnittstelle einer derartigen Funktion typischerweise in einer separaten Headerdatei deklariert. Diese Datei wird dann sowohl von dem Modul, das die Funktion implementiert, als auch von allen Modulen, die die Funktion aufrufen, eingebunden.
Sandini Bib
3.3 Funktionen und Module
51
Das folgende Programm zeigt daf¨ur ein Beispiel. Es besteht aus drei Dateien, die, wie in Abbildung 3.1 skizziert, folgende Rolle spielen:
In quer.hpp wird die Funktion quersumme() deklariert. Damit ist sie in allen Modulen, die diese Datei einbinden, bekannt und aufrufbar. In quer.cpp wird die in quer.hpp deklarierte Funktion quersumme() implementiert. Durch Einbindung von quer.hpp wird sichergestellt, dass sich Deklaration und Implementierung nicht widersprechen. In quertest.cpp wird die in quer.hpp deklarierte Funktion quersumme() aufgerufen. Durch Einbindung von quer.hpp wird sichergestellt, dass die Funktion beim Aufruf bekannt ist. q u e r . h p p : # i f n d e f # d e f i n e i n t
Q U E R _ H P P Q U E R _ H P P
q u e r s u m m e
( l o n g
z a h l ) ;
# e n d i f
q u e r . c p p : # i n c l u d e i n t { }
q u e r t e s t . c p p : # i n c l u d e
" q u e r . h p p "
q u e r s u m m e
( l o n g
i n t
z a h l )
. . .
{
m a i n ( ) x
}
" q u e r . h p p "
. . . =
. . .
q u e r s u m m e ( y ) ;
Abbildung 3.1: Deklaration, Implementierung und Aufruf von quersumme()
Der genaue Aufbau der Headerdatei sieht wie folgt aus:
// allg/quer.hpp #ifndef QUER_HPP #define QUER_HPP // Funktion, die die Quersumme aus einer ganzen Zahl berechnet
int quersumme (long zahl); #endif
Sandini Bib
52
Kapitel 3: Grundkonzepte von C++-Programmen
Die komplette Headerdatei wird durch Pr¨aprozessor-Anweisungen eingeschlossen, mit deren Hilfe vermieden wird, dass die darin befindlichen Deklarationen bei mehrfachem Einbinden der Datei mehrfach durchgef¨uhrt werden:
#ifndef QUER_HPP #define QUER_HPP
// ist nur beim ersten Durchlauf erf¨ullt, denn // QUER_HPP wird beim ersten Durchlauf definiert
...
#endif Dabei wird typischerweise der großgeschriebene Dateiname als Symbol verwendet. Jede Headerdatei sollte grunds¨atzlich von einer derartigen Klammer“ eingeschlossen werden. Auf die ” genauen Gr¨unde wird auf Seite 57 eingegangen.
3.3.2
Quelldatei mit der Implementierung
Die Quelldatei quer.cpp mit der Implementierung von quersumme() hat folgenden Aufbau:
// allg/quer.cpp #include "quer.hpp" // Funktion, die die Quersumme aus einer ganzen Zahl berechnet
int quersumme (long zahl) { int quer = 0; while (zahl > 0) { quer += zahl % 10; zahl = zahl / 10; } }
// Einer auf Quersumme addieren // weiter mit restlichen Ziffern
return quer;
In dieser Datei wird mit
#include "quer.hpp" zun¨achst einmal die Datei mit der Deklaration eingebunden. Dadurch kann der Compiler feststellen, ob es Widerspr¨uche zwischen der Deklaration und der Implementierung der Funktion gibt.
3.3.3
Quelldatei mit dem Aufruf
Jede Anwendung dieser Funktion bindet ebenfalls die Headerdatei mit der Deklaration ein, da jede Funktion vor einem Aufruf bekannt sein muss:
Sandini Bib
3.3 Funktionen und Module
53
// allg/quertest.cpp // Headerdatei mit der Deklaration von quersumme() einbinden
#include "quer.hpp"
// Headerdatei f¨ur I/O einbinden
#include // Vorw¨artsdeklaration von gibQuersummeAusVon()
void gibQuersummeAusVon(long);
// Implementierung von main() int main() { gibQuersummeAusVon(12345678); gibQuersummeAusVon(0); gibQuersummeAusVon(13*77); } // Implementierung von gibQuersummeAusVon() void gibQuersummeAusVon (long zahl) { std::cout << "Die Quersumme von " << zahl << " ist " << quersumme(zahl) << std::endl; }
In diesem Fall enth¨alt das Testprogramm zwei Funktionen, die Hauptfunktion main() und die Funktion gibQuersummeAusVon(). Innerhalb der Funktion main() wird dreimal die Funktion gibQuersummeAusVon() aufgerufen. Da diese erst nach ihrem Aufruf implementiert wird, muss sie vor main() deklariert werden. Ansonsten gibt es eine Fehlermeldung, dass eine Funktion aufgerufen wird, die nicht bekannt ist. Wie man sieht, kann man bei der Deklaration der Parameter den Namen der Parameter weglassen. Die Namen werden aus Dokumentationsgr¨unden h¨aufig aber dennoch angegeben (wie dies z.B. bei der Deklaration von quersumme() in der Headerdatei quer.hpp der Fall ist). Jeder Aufruf von gibQuersummeAusVon() u¨ bergibt also die Zahl, deren Quersumme ausgegeben werden soll. Innerhalb der Funktion werden die Zahl selbst und deren mit Hilfe von quersumme() berechnete Quersumme ausgegeben. Die Parameter¨ubergabe erfolgt im Normalfall by-value“. Das bedeutet, dass zahl in gibQuersummeAusVon() eine Kopie des u¨ berge” benen Arguments ist, die man in der Funktion lokal ohne Auswirkung f¨ur den Aufrufer noch ver¨andern k¨onnte.
Sandini Bib
54
Kapitel 3: Grundkonzepte von C++-Programmen
3.3.4
¨ Ubersetzen und binden
¨ F¨ur dieses Programm ergibt sich das in Abbildung 3.2 dargestellte Ubersetzungsschema: Die Headerdatei quer.hpp, die quersumme() deklariert, wird sowohl von der Quelldatei mit der Implementierung der Funktion als auch von der Quelldatei mit dem Anwendungsprogramm eingebunden. Die beiden Quelldateien werden vom C++-Compiler zun¨achst in Objektdateien u¨ bersetzt (diese haben typischerweise die Endung .o oder .obj). Von einem Linker (oder Binder“) ” werden die Objektdateien dann zum ausf¨uhrbaren Programm quertest (oder quertest.exe) zusammengebunden. C o m p ile r
q u e r . c p p
q u e r . o o d e r q u e r . o b j L in k e r
# i n c l u d e
q u e r t e s t o d e r q u e r t e s t . e x e
q u e r . h p p # i n c l u d e
q u e r t e s t . c p p
L in k e r
C o m p ile r
q u e r t e s t . o o d e r q u e r t e s t . o b j
¨ Abbildung 3.2: Ubersetzen und binden
Mit dem so erstellten ausf¨uhrbaren Programm ( Executable“) kann das Programm gestartet wer” den. Seine Ausgabe lautet:
Die Quersumme von 12345678 ist 36 Die Quersumme von 0 ist 0 Die Quersumme von 1001 ist 2
3.3.5
Dateiendungen
In C++ werden also typischerweise zwei Arten von Dateien verwendet:
Headerdateien (auch Include-Dateien, Header-Files oder H-Files genannt), die dazu dienen, Definitionen, die beim Kompilieren in verschiedenen Modulen gebraucht werden, zentral zu verwalten. Sie enthalten typischerweise die Deklarationen von globalen Konstanten, Variablen, Funktionen und Datentypen und werden von den Dateien, in denen diese Deklarationen gebraucht werden, jeweils eingebunden. Quelldateien (auch Source-Dateien, Source-Files oder C-Files genannt), die die eigentlichen ¨ Module (Ubersetzungseinheiten) darstellen, in denen der Programmablauf definiert wird. Darin befinden sich vor allem die Implementierungen der einzelnen Funktionen.
Sandini Bib
3.3 Funktionen und Module
55
Ungl¨ucklicherweise gibt es keine standardisierten Dateiendungen. Aus historischen Gr¨unden haben sich verschiedene Endungen etabliert, die auch nach wie vor mehr oder weniger verbreitet sind. Inzwischen kristallisieren sich aber u¨ bliche Endungen heraus.
F¨ur Quelldateien lautet die Endung inzwischen typischerweise .cpp. Manchmal wird aber auch .cc, .C oder .cxx verwendet. Entsprechend lautet die Endung f¨ur Headerdateien inzwischen typischerweise .hpp. Analog zu den Endungen der Quelldateien gibt es aber auch .hh, .H oder .hxx.
Das Durcheinander wird noch gr¨oßer, da die Systemdateien in C++ fr¨uher wie in C die Endung .h besaßen. Dies f¨uhrte dazu, dass man nicht zwischen C- und C++-Headerdateien unterscheiden konnte. Inzwischen haben Systemdateien von C++ gar keine Endung mehr.7 An diesem Beispiel sollte man sich aber bei eigenen Dateien nicht orientieren. Zur bequemen Handhabung und Verwaltung von C++-Dateien sind eindeutige Endungen wie .cpp und .hpp dringend angeraten.
3.3.6
Systemdateien
Beim Linken werden nicht nur die eigenen Module zusammengebunden. Hinzu kommen auch alle anderen Module, die Funktionen und Symbole f¨ur das Programm bereitstellen. Dazu geh¨oren insbesondere die Module der Standardbibliothek. In diesem Fall wird z.B. ein Modul mit der Implementierung der Standard-I/O-Schnittstelle von C++ dazugebunden. Derartige Module werden typischerweise aus Bibliotheken dazugebunden, die mehrere zusammengeh¨orige Module verwalten k¨onnen. Sie besitzen je nach System Endungen wie .a, .so, .lib oder .dll. Auch f¨ur die Funktionen und Symbole der Ein- und Ausgabe gilt, dass alles f¨ur deren Verwendung vor der Verwendung bekannt sein muss. Deshalb wird vom Testprogramm nach dem gleichen Muster eingebunden. Dabei fallen zwei Besonderheiten auf:
Als Systemdatei hat die Datei im Include-Befehl keine Endung. Durch die spitzen Klammern wird die Datei nicht im lokalen Verzeichnis sondern nur in den Systemverzeichnissen gesucht.
3.3.7
Pr¨aprozessor
Zu C++ geh¨ort ein Pr¨aprozessor, mit dem der Code vor dem eigentlichen Kompilieren noch modifiziert werden kann. Mit seiner Hilfe k¨onnen z.B. Dateien eingebunden oder bestimmte Teile des Codes ignoriert werden. Die Anweisungen f¨ur den Pr¨aprozessor beginnen alle mit dem Zeichen # und m¨ussen in einer Zeile jeweils das erste Zeichen sein, das kein Leer- oder Tabulatorzeichen ist. 7
Genau genommen muss es diese System-Headerdateien auch gar nicht als Dateien geben. Der Standard garantiert nur, dass mit #include alle notwendigen Deklarationen zur Ein-/Ausgabe zur Verf¨ugung stehen. Wie dies von einer C++-Umgebung umgesetzt wird, ist Sache der Umgebung. In der Praxis findet man allerdings in der Regel tats¨achlich entsprechende Dateien ohne Endung in einem Systemverzeichnis des Compilers.
Sandini Bib
56
Kapitel 3: Grundkonzepte von C++-Programmen
Einbinden von anderen Dateien Wie bereits erl¨autert, kann mit #include der Inhalt einer anderen Datei eingebunden werden. Dabei kann der dazugeh¨orige Dateiname in doppelte Anf¨uhrungsstrichen oder in spitze Klammern eingeschlossen werden:
#include #include "header2.hpp" In beiden F¨allen werden diese Dateien in den Systemverzeichnisse f¨ur Headerdateien gesucht. Wo diese genau liegen, ist systemspezifisch. Auf vielen Systemen kann man den Pfad f¨ur die Systemverzeichnisse bei Kompilieren mit Optionen wie -I beeinflussen. Verwendet man doppelte Anf¨uhrungsstriche, werden die Dateien zus¨atzlich zun¨achst im lokalen Verzeichnis gesucht. ¨ Bedingte Ubersetzung Neben der Einbindung von Dateien kann man mit dem Pr¨aprozessor noch beeinflussen, welche Teile einer Datei wirklich kompiliert werden sollen. Da Konstanten einem Compiler auch als Parameter u¨ bergeben werden k¨onnen, kann man damit zum Beispiel das Kompilieren bestimmter Zeilen ein- oder ausschalten:
void f () { ...
#ifdef DEBUG std::cout << "x hat den Wert: " << x << std::endl; #endif ...
} Die Ausgabeanweisung wird nur dann kompiliert, wenn die Konstante DEBUG definiert ist. Die Konstante kann nicht nur im Quellcode, sondern auch beim Aufruf des Compilers definiert werden (typischerweise als Option -Dkonstante). Da f¨ur jedes System bestimmte Konstanten definiert sind, kann man auf diese Weise im Notfall auch Systemunterschiede ber¨ucksichtigen. Zum Beispiel:
#if defined __GNUC__ // Sonderbehandlung f¨ur GNU-Compiler
#
if __GNUC__ == 2 && __GNUC_MINOR__ <= 95 // bis Version 2.95 ...
#
else // danach ...
# endif #elif defined _MSC_VER // Sonderbehandlung f¨ur Microsoft Visual-C++-Compiler ...
#endif
Sandini Bib
3.3 Funktionen und Module
57
¨ Bedingte Ubersetzung von Headerdateien ¨ Ein anderes Beispiel f¨ur die Verwendung der bedingten Ubersetzung ist die typische Klammer um Code in Headerdateien:
#ifndef QUER_HPP #define QUER_HPP
// ist nur beim ersten Durchlauf erf¨ullt, denn // QUER_HPP wird beim ersten Durchlauf definiert
...
#endif Bindet eine Quelldatei diese Headerdatei ein, werden die dortigen Anweisungen u¨ bernommen. Aus
#include "quer.hpp" wird also:
#ifndef QUER_HPP #define QUER_HPP
// ist nur beim ersten Durchlauf erf¨ullt, denn // QUER_HPP wird beim ersten Durchlauf definiert
...
#endif Das hat zur Folge, dass alle Zeilen aus quer.hpp nur dann ber¨ucksichtigt werden, wenn die Konstante QUER_HPP noch nicht definiert ist (#ifndef steht f¨ur if not defined“). Dies ist beim ” ersten Einbinden der Fall. Wird die Datei aber ein zweites Mal eingebunden,
#include "quer.hpp" #include "quer.hpp" wird daraus:
#ifndef QUER_HPP #define QUER_HPP
// ist nur beim ersten Durchlauf erf¨ullt, denn // QUER_HPP wird beim ersten Durchlauf definiert
...
#endif #ifndef QUER_HPP #define QUER_HPP
// ist nur beim ersten Durchlauf erf¨ullt, denn // QUER_HPP wird beim ersten Durchlauf definiert
...
#endif In diesem Fall wird der Inhalt von quer.hpp nur bei der ersten Expandierung ber¨ucksichtigt. Beim zweiten Mal werden alle Zeilen aus quer.hpp ignoriert. Auf diese Weise werden Fehlermeldungen u¨ ber doppelte Definitionen vermieden. Sicherlich wird man nicht direkt zweimal hintereinander die gleiche Headerdatei einbinden. Es kann aber immer passieren, dass zwei eingebundene Headerdateien jeweils die gleiche Headerdatei einbinden, was zum gleichen Szenario f¨uhrt. Da es also immer passieren kann, dass Headerdateien u¨ ber unterschiedliche Wege von einer Quelldatei mehrfach eingebunden werden, sollten Definitionen in Headerdateien immer durch derartige Anweisungen eingeschlossen werden.
Sandini Bib
58
Kapitel 3: Grundkonzepte von C++-Programmen
Definitionen Man kann mit einer Define-Anweisung auch selbst Konstanten im Code definieren:
#define DEBUG Dabei kann man auch einen Wert angeben:
#define VERSION 13 Diese Konstanten kann man dann im Code entsprechend auswerten:
void foo() { #ifdef DEBUG std::clog << "foo() aufgerufen" << std::endl; #endif ...
#if VERSION < 10 ...
#else ...
#endif } In a¨ lteren C-Programmen wurden Pr¨aprozessor auch zur Definition von Konstanten und Makros verwendet:
#define ANZ 100
// SCHLECHT
...
int array[ANZ]; // Feld mit ANZ Elementen anlegen for (int i=0; i
} Eine derartige Zweckentfremdung des Pr¨aprozessors f¨ur den eigentlichen Datenfluss kann und sollte man in C++ aber unbedingt vermeiden. Es handelt sich n¨amlich um dummen“ Textersatz, ” der weder den Gesetzen der Typpr¨ufung unterliegt noch G¨ultigkeitsbereiche ber¨ucksichtigt. In C++ gibt es daf¨ur Konstanten:
const int anz = 100;
// OK
...
int array[anz]; // Feld mit anz Elementen anlegen for (int i=0; i
} Man kann bei derartigen Definitionen sogar Parameter u¨ bergeben. Diese so genannten Pr¨aprozessor-Makros sollte man aber ebenfalls vermeiden. Dazu gibt es in C++ ebenfalls ausreichende Sprachmittel, wie Inline-Funktionen (siehe Abschnitt 4.3.3) und Funktionstemplates (siehe Abschnitt 7.2).
Sandini Bib
3.3 Funktionen und Module
3.3.8
59
Namensbereiche
Wie in Abschnitt 3.1.5 bereits erl¨autert gibt es in C++ das Konzept der Namensbereiche (englisch: namespaces) zur logischen Gruppierung von Datentypen und Funktionen. Alle Symbole k¨onnen einem Namensbereich zugeordnet werden. Wird dieses Symbol dann außerhalb des Namensbereichs verwendet, muss es mit dem Namensbereich qualifiziert werden. Auf diese Weise werden auf der einen Seite Namenskonflikte vermieden und auf der anderen Seite wird deutlich gemacht, welche Symbole logisch zusammengeh¨oren (ein logisches Paket oder eine Komponente bilden). Das folgende Beispiel zeigt die Verwendung von Namensbereichen:
namespace A { typedef ... String; void foo (String); }
// definiert Datentyp A::String // definiert A::foo(A::String)
namespace B { typedef ... String; void foo (String); }
// definiert Datentyp B::String // definiert B::foo(B::String)
Das Schl¨usselwort namespace ordnet die darin definierten Symbole dem angegebenen G¨ultigkeitsbereich zu. Damit k¨onnen diese Symbole nicht mehr mit gleichnamigen Symbolen aus anderen Namensbereichen oder globalen Symbolen kollidieren. Zur Verwendung der Symbole außerhalb des Namensbereiches muss der Namensbereich dann einfach qualifiziert werden:
A::String s1; B::String s2;
// String aus Namensbereich A anlegen // String aus Namensbereich B anlegen
A::foo(s1); B::foo(s2);
// foo() aus Namensbereich A aufrufen // foo() aus Namensbereich B aufrufen
Auf die Qualifizierung der Funktionsaufrufe kann dabei verzichtet werden. Wird einer Funktion ein Argument eines Namensbereiches u¨ bergeben, wird die Funktion automatisch auch in diesem Namensbereich gesucht:
A::String s1; B::String s2;
// String aus Namensbereich A anlegen // String aus Namensbereich B anlegen
foo(s1); foo(s2);
// OK, findet A::foo(), da s1 zu A geh¨ort // OK, findet B::foo(), da s1 zu B geh¨ort
Auf diese und weitere M¨oglichkeiten, auf die Qualifizierung von Namensbereiche zu verzichten, wird in Abschnitt 4.3.5 noch ausf¨uhrlich eingegangen.
Sandini Bib
60
Kapitel 3: Grundkonzepte von C++-Programmen
3.3.9
Das Schlusselwort ¨ static
C++ hat von C das Schl¨usselwort static u¨ bernommen. Dieses Schl¨usselwort kann dort zwei verschiedenen Zwecken dienen: 1. Es kann die Lebensdauer von lokalen Variablen beeinflussen, indem deren Lebendauer auf die gesamte Laufzeit eines Programms ausgedehnt wird. 2. Es kann den Sichtbarkeit von Symbolen auf ein Modul beschr¨anken. Statische lokale Variablen Normalerweise werden alle lokalen Variablen und Objekte einer Funktion oder eines Blocks angelegt, wenn die entsprechende Stelle im Programmablauf erreicht wird. Wird der Block bzw. die Funktion verlassen, werden die darin deklarierten lokalen Variablen und Objekte automatisch wieder zerst¨ort:
void foo () { int x;
// wird mit undefiniertem Inhalt angelegt, // wenn diese Stelle erreicht wird
...
std::string s; // wird mit einem Leerstring als Inhalt angelegt, }
// wenn diese Stelle erreicht wird ... // zerst¨ort x und s wieder
Mit static kann festgelegt werden, dass eine lokale Variable beim ersten Durchlauf des Kontrollflusses einmal einmal initialisiert und erst mit dem Programmende zerst¨ort wird. In dem Fall beh¨alt die Variable auch nach Verlassen einer Funktion ihren Wert, und dieser Wert kann beim n¨achsten Aufruf der Funktion wieder ausgewertet werden. Es handelt sich sozusagen um eine globale Variable, auf die nur lokal innerhalb einer Funktion zugegriffen werden kann. Das folgende Beispiel verdeutlicht den Sachverhalt:
void foo () { static int anzahlAufrufe = 0; // wird beim ersten Aufruf initialisiert und // zum Programmende zerst¨ort
++anzahlAufrufe; std::cout << "Die Funktion wurde zum " << anzahlAufrufe << ". Mal aufgerufen" << std::endl; ...
} Die Variable anzahlAufrufe wird beim ersten Aufruf von foo() mit dem zur Initialisierung u¨ bergebenen Wert initialisiert und bleibt f¨ur die gesamte Dauer des Programms erhalten. Nach dem Programmstart spielt der Initialwert keine Rolle mehr. Mit jedem Aufruf der Funktion wird
Sandini Bib
3.3 Funktionen und Module
61
zun¨achst der Wert der Variablen um eins erh¨oht. In der Variablen wird also die Anzahl der Aufrufe dieser Funktion gez¨ahlt. Diese Anzahl wird dann jeweils auch ausgegeben. Derartige statische Variablen oder Objekte sind immer dann sinnvoll, wenn man einen Zustand zwischen zwei Funktionsaufrufen erhalten will, ohne dass Daten explizit durchgereicht werden. Es ist allerdings besser, derartige Variablen zu vermeiden. So gibt es z.B. erhebliche Probleme, wenn in Multithreading-Programmen parallel auf diese Variablen zugegriffen werden kann. Modulspezifische Variablen und Funktionen Die andere Anwendung von static dient dazu, die Sichtbarkeit einer Variablen oder einer Funktion auf ein Modul zu beschr¨anken. Man kann damit Hilfsvariablen oder Hilfsfunktionen programmieren, deren Namen nicht mit Symbolen anderer Module kollidieren k¨onnen. Im folgenden Beispiel werden eine Variable zustand und eine Funktion hilfsfunktion() deklariert, auf die nur innerhalb des Moduls zugegriffen werden kann:
static std::string zustand;
// nur in diesem Modul bekannt
static void hilfsfunktion (); // nur in diesem Modul bekannt void foo () {
// global bekannt
...
++zustand; ...
} static void hilfsfunktion () { ...
if (zustand == 17) { hilfsfunktion(); } ...
} Der Versuch, hilfsfunktion() außerhalb des Moduls aufzurufen oder auf zustand außerhalb des Moduls zuzugreifen, f¨uhrt zu einem Fehler. Von außen kann lediglich foo() aufgerufen werden. In C++ kann und sollte f¨ur diesen Zweck auch ein so genannter anonymer Namensbereich verwendet werden. Dabei handelt es sich um einen Namensbereich, f¨ur den kein Name vergeben wird. Das obige Beispiel sieht damit wie folgt aus:
namespace { // alles hier ist nur in diesem Modul bekannt std::string zustand; void hilfsfunktion (); }
Sandini Bib
62
Kapitel 3: Grundkonzepte von C++-Programmen
void foo () {
// global bekannt
...
++zustand; ...
} // alles hier ist nur in diesem Modul bekannt namespace { void hilfsfunktion () { if (zustand == 17) { hilfsfunktion(); } ...
}
}
Die Verwendung von anonymen Namensbereichen hat den Vorteil, dass static nur noch dazu dient, die Lebensdauer einer Variablen zu beeinflussen. Die Semantik zur Einschr¨ankung der Sichtbarkeit bleibt anderen Sprachmitteln u¨ berlassen. Insofern ist die Verwendung anonymer Namensbereiche der Verwendung von static vorzuziehen. static sollte in C++ nur noch zur Beeinflussung der Lebensdauer von Variablen verwendet werden.
3.3.10
Zusammenfassung
In C++ verwendet man zwei Arten von Dateien: Headerdateien f¨ur Deklarationen und Quelldateien f¨ur die eigentlichen Implementierungen. Die Headerdateien werden dabei von den Quelldateien zum Kompilieren jeweils eingebunden. Headerdateien haben typischerweise die Endung .hpp. Quelldateien haben typischerweise die Endung .cpp. Mit dem Pr¨aprozessor k¨onnen vor dem eigentlichen Kompilieren Headerdateien eingebunden werden. Mit dem Pr¨aprozessor kann Code geschrieben werden, der nur unter bestimmten Bedingungen kompiliert wird. Headerdateien sollten grunds¨atzlich von Pr¨aprozessor-Anweisungen umschlossen sein, die Fehler durch mehrfache Einbindung vermeiden. F¨ur Makros und zur Definition von Konstanten gibt es in C++ bessere M¨oglichkeiten als den Pr¨aprozessor. Mit Namensbereichen k¨onnen Symbole logisch gruppiert werden. Mit static k¨onnen lokale Variablen definiert werden, deren Lebensdauer u¨ ber die gesamte Programmdauer reicht. Mit anonymen Namensbereichen kann die Sichtbarkeit von Variablen und Funktionen auf Module eingeschr¨ankt werden.
Sandini Bib
3.4 Strings
63
3.4 Strings In C++ gibt es keinen eingebauten Datentyp zur Verwendung von Strings. Im Gegensatz zu C gibt es aber einen Datentyp string, der in der Standardbibliothek angeboten wird. Genau genommen handelt es sich um eine so genannte Klasse, die die Eigenschaften und F¨ahigkeiten von Strings definiert. Diese Klasse ist so implementiert, dass man mit Strings wie mit fundamentalen Datentypen operieren kann. So kann man Strings mit = zuweisen, mit == vergleichen und mit + aneinander h¨angen. Hinzu kommen weitere Operationen, die man, wie bei Klassen u¨ blich, jeweils f¨ur Objekte (also Strings) aufrufen kann. Die Tatsache, dass eine String-Klasse vorhanden ist, die diesen Datentyp wie einen fundamentalen Datentyp zur Verf¨ugung stellt, bietet einen großen Vorteil. Die moderne Datenverarbeitung ist heutzutage zu einem Großteil eine String-Verarbeitung. Namen, Daten und Texte werden als Zeichenfolgen erfasst, durchgereicht und ausgewertet. In Sprachen, bei denen es keinen einfachen String-Datentyp gibt (z.B. in C oder Fortran) sind Strings oft die Ursache f¨ur allerlei Probleme, die in C++ nicht mehr auftreten.
3.4.1
Ein erstes einfaches Beispielprogramm mit Strings
Das folgende Beispiel zeigt, welche elementaren Operationen f¨ur Strings durchgef¨uhrt werden k¨onnen:
// allg/string1.cpp #include #include <string>
// C++-Headerdatei f¨ur I/O // C++-Headerdatei f¨ur Strings
int main () { // zwei Strings anlegen
std::string vorname = "bjarne"; std::string nachname = "stroustrup"; std::string name; // Strings manipulieren
vorname[0] = 'B'; nachname[0] = 'S'; // Strings verketten
name = vorname + " " + nachname; // Strings vergleichen
if (name != "") { // Strings ausgeben
std::cout << name
Sandini Bib
64
Kapitel 3: Grundkonzepte von C++-Programmen
}
<< " ist der Urvater von C++" << std::endl;
// Anzahl der Zeichen in einem String ermitteln
}
int anz = name.length(); std::cout << "\"" << name << "\" hat " << anz << " Zeichen" << std::endl;
Zun¨achst wird neben der Standard-Headerdatei f¨ur Ein- und Ausgaben auch die Standard-Headerdatei f¨ur den Datentyp string eingebunden:
#include #include <string>
// C++-Headerdatei f¨ur Ein-/Ausgaben // C++-Headerdatei f¨ur Strings
Anschließend werden drei Strings angelegt:
std::string vorname = "bjarne"; std::string nachname = "stroustrup"; std::string name; Die ersten beiden Strings werden mit "bjarne" bzw. "stroustrup" initialisiert. Dem dritten String wird kein Wert zur Initialisierung u¨ bergeben. Damit erh¨alt er als Startwert den Leerstring. Was hier passiert, kann man unterschiedlich bezeichnen:
Aus Sicht der klassischen Programmierung wird hier eine Variable vom Typ std::string angelegt. Aus Sicht der objektorientierten Programmierung wird hier ein Objekt bzw. eine Instanz der Klasse std::string angelegt.
Im objektorientierten Umfeld nennt man Datentypen also Klassen. Legt man konkrete Variablen einer Klasse an, bezeichnet man diese Variablen als Objekte oder auch Instanzen. Der Effekt ist aber so oder so der gleiche: Es gibt etwas, das unter einem bestimmten Namen eine Zeichenfolge repr¨asentiert. In der folgenden Anweisung werden die Strings manipuliert:
vorname[0] = 'B'; nachname[0] = 'S'; Das erste Zeichen in den beiden initialisierten Strings wird jeweils korrigiert. Wie man sieht, kann man auf das i-te Zeichen eines Strings jeweils mit dem Indexoperator zugreifen. Wie immer in C und C++ hat das erste Zeichen dabei den Index 0. Anschließend werden die beiden Namensteile mit einem Leerzeichen dazwischen hintereinander geh¨angt und dem String name zugewiesen:
name = vorname + " " + nachname; Man kann also den Operator + verwenden, um zwei Strings zu verketten.
Sandini Bib
3.4 Strings
65
In der folgenden If-Abfrage wird gepr¨uft, ob der String name leer ist:
if (name != "") { ...
} Wie man sieht, kann man auch f¨ur Strings einfach die f¨ur Vergleiche u¨ blichen Operatoren != und == verwenden. Ausgeben kann man Strings wie alle anderen Datentypen auch:
std::string name; ...
std::cout << name << " ist der Urvater von C++" << std::endl; Schließlich wird noch die Anzahl der Zeichen in dem String name abgefragt:
name.length(); Diese Zeile mag etwas ungew¨ohnlich aussehen. Ohne Kenntnisse der objektorientierten Programmierung w¨urde man vielleicht einen Aufruf wie getLength(name) erwarten. Hier sehen wir zum ersten Mal die objektorientierte Syntax f¨ur Operationen, die in Klassen f¨ur Objekte definiert werden. Man wendet sich an das Objekt name und ruft f¨ur dieses Objekt die Operation length() auf. Eine derartige Funktion nennt man in C++ Elementfunktion (englisch: member function). In der objektorientierten Terminologie handelt es sich um den Aufruf einer so genannten Methode. Man sendet die Nachricht length ohne Argumente an das Objekt name. Dieses Objekt hat dann eine Methode, auf diese Nachricht zu reagieren. Die Auswirkungen dieses Aufrufs h¨angen von der Implementierung dieser Operation in der Klasse string ab. In diesem Fall wird auf die Nachricht so reagiert, dass das Objekt die Anzahl seiner Zeichen zur¨uckliefert. Insgesamt liefert das Programm also folgende Ausgabe:
Bjarne Stroustrup ist der Urvater von C++ "Bjarne Stroustrup" hat 17 Zeichen Strings und String-Literale Wie man sieht, k¨onnen Strings wie fundamentale Datentypen verwendet werden:
Mit == und != k¨onnen Strings verglichen werden. Mit + k¨onnen Summenstrings gebildet werden. Mit = k¨onnen Strings einander zugewiesen werden.
Dies mag offensichtlich sein. F¨ur C-Programmierer ist es das aber nicht, denn in C kann man auf diese Weise leider nicht mit Strings programmieren (siehe auch Seite 116). Und auch in Java darf man einen String nicht einfach mit == mit einem Leerstring vergleichen. Man beachte, dass dabei sowohl Objekte vom Typ string als auch String-Literale wie "bjarne" oder "" verwendet wurden. Dabei muss allerdings beachtet werden, dass String-
Sandini Bib
66
Kapitel 3: Grundkonzepte von C++-Programmen
Literale aus historischen Gr¨unden nicht den Datentyp string besitzen.8 Aus diesem Grund kann man nicht einfach zwei String-Literale verketten:
"hallo" + " " + "welt"
// FEHLER
In diesem Fall muss man mindestens einen der ersten beiden Operanden explizit in einen String umwandeln:
string("hallo") + " " + "welt"
// OK
Wert-Semantik Noch ein Hinweis, der sich insbesondere an Programmierer richtet, die bisher mit Sprachen wie Java oder Smalltalk gearbeitet haben: C++ hat bei allen Datentypen eine Wert-Semantik. Das bedeutet, dass bei der Deklaration einer Variablen auch gleich Speicherplatz f¨ur die dazugeh¨origen Daten angelegt wird. Dies ist ein deutlicher Unterschied zur Referenz-Semantik von Sprachen wie Java und Smalltalk. Dort legt eine Deklaration einer Variablen nur einen Verweis an, der zun¨achst auf nichts (NIL oder null) verweist. Erst mit einem Aufruf von new wird eine echte Instanz mit Speicherplatz f¨ur die dazugeh¨origen Daten angelegt. In C++ gibt es zwar auch ein new. Dies ist aber nur notwendig, um Objekte anzulegen, auf die unabh¨angig von Blockgrenzen zugegriffen werden muss. Darauf wird in Abschnitt 3.8 eingegangen.
3.4.2
Ein weiteres Beispielprogramm mit Strings
Das folgende Programm demonstriert weitere F¨ahigkeiten und die Arbeitsweise mit den StringTypen. Es extrahiert aus den jeweils eingegebenen Zeilen HTML-Links und gibt diese wieder aus. Das Programm hat folgenden Aufbau:
// allg/html.cpp #include #include <string>
// C++-Headerdatei f¨ur Ein-/Ausgaben // C++-Headerdatei f¨ur Strings
int main () { const std::string anfang("http:"); const std::string trenner(" \"\t\n<>"); std::string zeile; std::string link; std::string::size_type anfIdx, endIdx;
// Beginn eines Links // Zeichen, die den Link beenden // aktuelle Zeile // aktueller HTML-Link // Indizes
// f¨ur jede erfolgreich gelesene Zeile
while (getline(std::cin,zeile)) { Der tats¨achliche Datentyp von String-Literalen ist const char*. Darauf, was dieser Datentyp bedeutet, und auf die genauen Konsequenzen dieser Tatsache wird in Abschnitt 3.7.3 auf Seite 115 noch eingegangen.
8
Sandini Bib
3.4 Strings
67 // suche erstes Vorkommen von "http:" anfIdx = zeile.find(anfang); // solange "http:" in der Zeile gefunden wurde,
while (anfIdx != std::string::npos) { // Ende des Links finden
endIdx = zeile.find_first_of(trenner,anfIdx); // Link extrahieren
if (endIdx != std::string::npos) { // Ausschnitt von gefundenem Anfang bis gefundenem Ende
link = zeile.substr(anfIdx,endIdx-anfIdx); } else { // Kein Ende gefunden: Rest der Zeile
}
link = zeile.substr(anfIdx);
// Link ausgeben // - "http:" ohne weitere Zeichen ignorieren
if (link != "http:") { link = string("Link: ") + link; std::cout << link << std::endl; } // weiteren Link in der Zeile suchen
if (endIdx != std::string::npos) { // suche weiteres Vorkommen von "http:" ab gefundenem Ende anfIdx = zeile.find(anfang,endIdx); } else { // Ende war Zeilenende: kein neuer Anfang in der Zeile auffindbar
}
}
}
}
anfIdx = std::string::npos;
Bei einer Eingabe der Form
In diesem Text sind verschiedene Links untergebracht. Zu diesem Buch gehort der Link "http://www.josuttis.de/cppbuch". Meine Home-Page findet man unter http://www.josuttis.de und http://www.josuttis.com
Sandini Bib
68
Kapitel 3: Grundkonzepte von C++-Programmen
liefert es folgende Ausgabe:
Link: http://www.josuttis.de/cppbuch Link: http://www.josuttis.de Link: http://www.josuttis.com Datentypen In main() werden wiederum einige Variablen deklariert bzw. Objekte instantiiert:
Ein nicht a¨ nderbarer String anfang als Suchkriterium f¨ur den Anfang eines HTML-Links:
const std::string anfang("http:");
// Beginn eines Links
Es werden also alle W¨orter gesucht, die mit http: beginnen. Ein String, in dem alle Zeichen angegeben werden, die einen HTML-Link beenden:
const std::string trenner(" \"\t\n<>"); // Zeichen, die den Link beenden
In diesem Fall wird ein Link durch ein Leerzeichen, das Zeichen ", einen Tabulator, ein Newline (eigentlich unn¨otig, da wir zeilenweise lesen) sowie die Zeichen < und > beendet (siehe die Tabelle der Sonderzeichen auf Seite 38). Zwei Variablen f¨ur die aktuelle Zeile bzw. den aktuellen Link:
std::string zeile; std::string link;
// aktuelle Zeile // aktueller HTML-Link
Zwei Variablen f¨ur den Index des Anfangs und den Index des Endes eines Links:
std::string::size_type anfIdx, endIdx;
// Indizes
Als Datentypen werden f¨ur Strings der Datentyp std::string (string in der Standardbibliothek) und std::string::size_type verwendet. size_type ist ein Hilfstyp von Strings f¨ur deren Index. Es ist ein spezieller Datentyp, da es einen speziellen Wert kein Index“ von diesem ” Typ gibt: std::string::npos ( no position“). ” Zeilenweise einlesen Das eigentliche Hauptger¨ust des Programms bildet eine Schleife, die zeilenweise aus der Standardeingabe einliest:
std::string zeile; // f¨ur jede erfolgreich gelesene Zeile
while (getline(std::cin,zeile)) { ...
} Die Zeilen werden von std::cin gelesen und jeweils in zeile eingetragen. Das Zeichen f¨ur das Zeilenende, '\n', wird dabei nicht in zeile eingetragen.
Sandini Bib
3.4 Strings
69
while testet jeweils den R¨uckgabewert von getline(). Dabei handelt es sich um den u¨ bergebenen Standard-Eingabekanal. Diese Kan¨ale haben die Eigenschaft, dass man sie zur Zustandsabfrage als Bedingungen verwenden kann. Dazu werden bereits einige spezielle Sprachmittel verwendet, die erst sp¨ater vorgestellt werden. Uns reicht hier erst einmal die Tatsache, dass man das erfolgreiche Einlesen einer Zeile auf diese Weise gleich testen kann. String-Operationen Innerhalb der Schleife werden verschiedene String-Operationen aufgerufen. Sie haben alle wieder die Syntax einer Elementfunktion, also zeile.operation(). F¨ur die jeweils aktuelle Zeile zeile wird also jeweils eine Operation aufgerufen, die diese Zeile in irgendeiner Form bearbeitet. Zun¨achst wird mit find() das erste Vorkommen des Beginns eines Links gesucht:
const std::string anfang("http:"); std::string zeile; std::string::size_type anfIdx;
// Beginn eines Links // aktuelle Zeile // Index
... // suche erstes Vorkommen von "http:" anfIdx = zeile.find(anfang);
Diese Operation liefert das erste Vorkommen des u¨ bergebenen Strings in dem String, f¨ur den diese Operation aufgerufen wird. In diesem Fall wird also das erste Vorkommen von http: in zeile gesucht. Zur¨uckgeliefert wird der Index des ersten Zeichens der gefundenen Stelle. Ist der String nicht vorhanden, wird die schon angesprochene Konstante std::string::npos zur¨uckgeliefert. Dies wird in der n¨achsten Zeile gepr¨uft: // solange "http:" in der Zeile gefunden wurde,
while (anfIdx != std::string::npos) { ...
} Solange die Suche nach einem Beginn eines HTML-Links in der aktuellen Zeile erfolgreich war, l¨auft diese Schleife. Innerhalb der Schleife wird dazu jeweils das n¨achste Vorkommen von http: gesucht. Haben wir den Anfang gefunden, wird innerhalb der inneren Schleife das Ende des HTMLLinks gesucht. Die Operation find_first_of() erm¨oglicht es, nach mehreren Zeichen gleichzeitig zu suchen. Sobald eines der als Parameter u¨ bergebenen Zeichen gefunden wird, wird dessen Index zur¨uckgeliefert. Auch hier wird std::string::npos zur¨uckgeliefert, wenn keines der Zeichen mehr vorkommt. In diesem Fall wird in zeile nach einem der oben definierten Trennzeichen gesucht:
const std::string trenner(" \"\t\n<>"); // Zeichen, die den Link beenden std::string zeile; // aktuelle Zeile std::string::size_type anfIdx, endIdx; // Indizes ...
endIdx = zeile.find_first_of(trenner,anfIdx);
Sandini Bib
70
Kapitel 3: Grundkonzepte von C++-Programmen
Der optionale zweite Parameter legt fest, ab welchem Zeichen die Trenner gesucht werden. Damit die Zeichen vor dem Beginn dieses HTML-Links keine Rolle spielen, wird erst ab dem Beginn des HTML-Links gesucht. In der n¨achsten Anweisung wird dann der eigentliche HMTL-Link aus der Zeile extrahiert. Je nachdem, ob ein Trenner gefunden wurde, werden entweder alle Zeichen vom ersten bis zum letzten Index oder bis zum Zeilenende extrahiert: // Link extrahieren
if (endIdx != std::string::npos) { // Ausschnitt von gefundenem Anfang bis gefundenem Ende
link = zeile.substr(anfIdx,endIdx-anfIdx); } else { // Kein Ende gefunden: Rest der Zeile
}
link = zeile.substr(anfIdx);
Mit einem Parameter aufgerufen, liefert substr() den Teilstring aller Zeichen vom u¨ bergebenen Index bis zum Ende des Strings. Ein optionaler zweiter Parameter dient zur Angabe der maximalen Anzahl zu extrahierender Zeichen. In diesem Fall ist das die Differenz zwischen Ende- und Anfangsindex. Die n¨achste Anweisung gibt den Link aus. Dabei wird einschr¨ankend gepr¨uft, ob hinter http: u¨ berhaupt noch Zeichen folgen. // Link ausgeben // - "http:" ohne weitere Zeichen ignorieren
if (link != "http:") { link = string("Link: ") + link; std::cout << link << std::endl; } Hier werden wieder die schon im ersten Beispiel vorgestellten fundamentalen Operatoren f¨ur Strings, !=, + und =, verwendet. Es bleibt schließlich noch die Suche nach einem eventuell vorhandenen weiteren HTMLLink in der gleichen Zeile, was nat¨urlich nur der Fall sein kann, wenn das Ende des vorherigen Links nicht das Zeilenende war: // weiteren Link in der Zeile suchen
if (endIdx != std::string::npos) { // suche weiteres Vorkommen von "http:" ab gefundenem Ende anfIdx = zeile.find(anfang,endIdx); } else { // Ende war Zeilenende: kein neuer Anfang in der Zeile auffindbar
}
anfIdx = std::string::npos;
Sandini Bib
3.4 Strings
71
Sofern anfIdx danach nicht den Wert std::string::npos hat, l¨auft die innere Schleife zur Bearbeitung der aktuellen Zeile weiter.
3.4.3
¨ String-Operationen im Uberblick
Nach diesen einf¨uhrenden Beispielen bekommt man sicherlich schon einen ersten Eindruck von den F¨ahigkeiten des Standard-String-Typs. Tabelle 3.11 listet die wichtigsten Operationen noch einmal auf. Operation =, assign()
swap() +=, append(), push_back() insert() erase() clear() resize() replace() + ==, !=, <, <=, >, >=, compare() length(), size() empty() [ ], at() >>, getline() << find-Funktionen
substr() c_str() copy()
Effekt Neuen Wert zuweisen Werte zwischen zwei Strings vertauschen Zeichen anh¨angen Zeichen einf¨ugen Zeichen l¨oschen Alle Zeichen l¨oschen (String leeren) Anzahl der Zeichen ver¨andern (Zeichen am Ende l¨oschen oder anf¨ugen) Zeichen ersetzen Summenstring bilden Strings vergleichen Anzahl der Zeichen in einem String liefern Testen, ob ein String leer ist Auf ein einzelnes Zeichen zugreifen String einlesen String ausgeben Teilstring oder Zeichen suchen Teilstring liefern String als C-String verwenden Zeichen in einen Zeichenpuffer kopieren
Tabelle 3.11: Die wichtigsten String-Operationen
F¨ur eine genaue Beschreibung der Operationen (Parameter, R¨uckgabewert, Semantik) sei auf entsprechende Referenzhandb¨ucher zur C++-Klassenbibliothek verwiesen. In Abschnitt 3.6 wird allerdings noch auf einen wichtigen allgemeinen Aspekt der StringKlasse eingegangen, das Verhalten im Fehlerfall. Das Verhalten im Fehlerfall spielt insbesondere beim Zugriff auf ein Zeichen im String eine Rolle. Beim Indexoperator f¨uhrt die Verwendung eines fehlerhaften Index zu einem undefinierten Verhalten. Insofern muss man im Programm sicherstellen, dass der Index, mit dem auf ein Zeichen im String zugegriffen wird, auch g¨ultig ist. Verwendet man stattdessen die Elementfunktion at(), wird bei einem ung¨ultigen Index eine so genannte Ausnahme ausgel¨ost, die im Programm automatisch zu einer geordneten Fehlerbehandlung f¨uhrt. Mehr zur Fehlerbehandlung beim Zugriff auf ein Zeichen findet man vor allem in Abschnitt 3.6.4.
Sandini Bib
72
Kapitel 3: Grundkonzepte von C++-Programmen
3.4.4
Strings und C-Strings
strings kapseln die Tatsache, dass die Zeichen intern mit Feldern (Arrays) und Zeigern verwaltet werden. In C muss stattdessen der Datentyp char* mit all seinen Nachteilen verwendet werden. Um diesen Datentyp kommt man auch in C++ nicht herum, da String-Literale einen entsprechenden Typ besitzen (genau genommen besitzen String-Literale den Datentyp const char* ). Zur Abgrenzung bezeichne ich im weiteren Verlauf des Buchs Strings vom Datentyp char* oder const char* auch als C-Strings. In Abschnitt 3.7.3 wird n¨aher auf C-Strings eingegangen. Auf zwei Dinge soll hier aber schon einmal hingewiesen werden: 1. Die Funktionen zum Umwandeln oder Kopieren in einen C-String sind f¨ur den Fall konzipiert, dass man noch die alte String-Schnittstelle von C ben¨otigt. Dies ist z.B. dann der Fall, wenn Strings an Funktionen aus einer C-Bibliothek u¨ bergeben werden m¨ussen. Ein entsprechender Aufruf sieht wie folgt aus:
void cfunktion(const char*);
// Vorw¨artsdeklaration
...
std::string s; ...
cfunktion(s.c_str());
// String als C-String u¨ bergeben
Falls f¨alschlicherweise der Parameter in der C-Funktion ohne const deklariert wurde, obwohl der String in der Funktion gar nicht ver¨andert wird, muss man Folgendes schreiben:
void cfunktion(char*);
// Vorw¨artsdeklaration
...
std::string s; ...
cfunktion(const_cast(s.c_str())); // String als C-String u¨ bergeben const_cast(wert) entfernt die Konstantheit. Dieser Operator ist mit Vorsicht zu genießen, da er Zugriff auf Objekte zul¨asst, die vom Typ her nicht ver¨andert werden sollen. Er sollte deshalb nur in Ausnahmef¨allen wie diesen Verwendet werden. 2. Man beachte, dass strings beliebige Zeichen, also auch Sonderzeichen, enthalten k¨onnen. Im Gegensatz zu C-Strings ist das Zeichen '\0' in strings kein Endekennzeichen. Die Umwandlung in einen C-String mit c_str() h¨angt allerdings ein Endekennzeichen an.
3.4.5
Zusammenfassung
F¨ur Strings bietet die C++-Standardbibliothek den Datentyp std::string. Damit k¨onnen Strings wie fundamentale Datentypen verwendet werden. Etliche Suchfunktionen erlauben es, Zeichen oder Teilstrings in Strings zu suchen. F¨ur String-Indizes gibt es den Datentyp std::string::size_type.
Sandini Bib
3.4 Strings
73
Der Wert std::string::npos steht f¨ur kein Index“. ” Zur Umwandlung in C-Strings dient c_str(). Strings k¨onnen beliebige Zeichen (auch bin¨are Zeichen) enthalten. In objektorientierter Terminologie bezeichnet man Datentypen als Klassen und Variablen als Objekte oder Instanzen. Alle Datentypen von C++ haben eine Wert-Semantik.
In den Abschnitten 6.1 und 6.2.1 werden am Beispiel einer eigenen Implementierung der Standard-String-Klasse weitere Aspekte und interne Details der Klasse string vorgestellt.
Sandini Bib
74
Kapitel 3: Grundkonzepte von C++-Programmen
3.5 Verarbeitung von Mengen In der Datenverarbeitung werden heutzutage nicht nur einzelne Daten, Variablen oder Objekte, sondern Mengen davon verarbeitet. In den meisten herk¨ommlichen Programmiersprachen muss man zur Verwaltung von Mengen die entsprechenden Datenstrukturen wie Felder, verkettete Listen, B¨aume oder Hash-Tabellen selbst programmieren. In C++ ist dies nicht notwendig. Die C++-Standardbibliothek bietet ein eigenes Framework zur Arbeit mit Mengen, die so genannte Standard Template Library oder kurz STL. Dieses Framework stellt Mechanismen zur Verf¨ugung, um durch einfache Typdeklarationen unterschiedliche Datenstrukturen zur Mengenverarbeitung (so genannte Container) verwenden zu k¨onnen. Dar¨uber hinaus werden Mechanismen angeboten, um auf derartige Mengen unabh¨angig von der darunter liegenden Datenstruktur Operationen (so genannte Algorithmen) anwenden zu k¨onnen. Der große Vorteil der STL besteht darin, dass sie ein Interface anbietet, bei dem man sich zur Verarbeitung von Mengen nicht mehr um die Details der Datenstrukturen und der dazugeh¨origen Speicherverwaltung k¨ummern muss. Diese Details werden abstrahiert. Dabei wurde darauf geachtet, dass dies nicht auf Kosten der Performance geht. Eine komplette Einf¨uhrung in die STL w¨are Stoff f¨ur ein eigenes Buch.9 Die Verwendung der STL zur einfachen Verwaltung von Mengen stellt jedoch keine großen Aufwand dar und wird in diesem Abschnitt erl¨autert.
3.5.1
Beispielprogramm mit Vektoren
Das erste Beispiel zeigt die Verwendung des einfachsten Datentyps zur Mengenverarbeitung, eines so genannten Vektors. Die Bezeichnung Vektor“ hat nichts mit dem mathematischen Begriff ” zu tun, sondern wird in der STL f¨ur einen so genannten Container verwendet, der seine Elemente wie ein dynamisches Feld (Array) verwaltet. Das bedeutet z.B., dass man mit dem Indexoperator auf die Elemente zugreifen kann und ein Einf¨ugen und L¨oschen von Elementen dann am schnellsten ist, wenn sich diese Elemente am Ende der Menge befinden (ansonsten m¨ussen die nachfolgenden Elemente entsprechend verschoben werden). Das folgende Beispiel definiert als Menge einen Vektor von ganzen Zahlen, f¨ugt sechs Elemente darin ein und gibt alle Elemente der Menge wieder aus:
// stl/vector1.cpp #include #include int main() { std::vector menge;
9
// Vektor-Container f¨ur ints
Nicht ganz objektiv empfehle ich dazu mein bei Addison-Wesley erschienenes Buch The C++ Standard ” ¨ Library – A Tutorial and Reference“ (ich habe es in englisch geschrieben; eine deutsche Ubersetzung ist mangels Zeit derzeit leider nicht erh¨altlich).
Sandini Bib
3.5 Verarbeitung von Mengen
75
// Elemente mit den Werten 1 bis 6 einf¨ugen
for (int i=1; i<=6; ++i) { menge.push_back(i); }
// alle Elemente jeweils gefolgt von einem Leerzeichen ausgeben
for (unsigned i=0; i<menge.size(); ++i) { std::cout << menge[i] << ' '; } // zum Abschluss ein Zeilenende ausgeben
}
std::cout << std::endl;
Mit
#include wird die Headerdatei f¨ur den Datentyp vector eingebunden. Bei diesem Datentyp handelt es sich genau genommen um ein Klassentemplate (eine Klassenschablone). Das bedeutet, es handelt sich um eine Klasse, bei der noch nicht alle Details feststehen. Konkret muss mit spitzen Klammern der Datentyp der in der Menge verwalteten Elemente angegeben werden. Die Deklaration
std::vector menge; legt auf diese Weise einen derartigen Container an. In diesem Fall handelt es sich also um einen Vektor, der Elemente vom Typ int enth¨alt. Der angelegte Container ist zun¨achst leer. Durch den Aufruf der Elementfunktion push_back() f¨ur den Container kann man Elemente am Ende einf¨ugen:
menge.push_back(i); In der Schleife zur Ausgabe der Elemente wird mit size() jeweils die aktuelle Anzahl der Elemente im Container abgefragt:
for (unsigned i=0; i<menge.size(); ++i) { ...
} Als Laufvariable i wird ein vorzeichenfreier Datentyp verwendet. Wird i einfach als int deklariert, kann der Vergleich mit menge.size() zu der Warnung f¨uhren, das ein vorzeichenfreier mit einem vorzeichenbehafteten Wert verglichen wird. Innerhalb der Schleife wird jeweils das Element mit dem Index i ausgegeben. Dazu wird die typische Feld-Syntax verwendet:
cout << menge[i] << ' '; Wie u¨ blich hat das erste Element den Index 0 und das letzte Element den Index size()-1.
Sandini Bib
76
Kapitel 3: Grundkonzepte von C++-Programmen
Das Programm liefert folgende Ausgabe:
1 2 3 4 5 6 Einfach, oder? Doch Vorsicht, STL-Container sind nicht idiotensicher“. Die Container sind so ” programmiert, dass man von der darunter liegenden Datenstruktur nichts sehen soll. Man hat aber immer noch darauf geachtet, dass die Datentypen m¨oglichst performant sind. So wird bei einem Zugriff auf ein Element mit [i] wie bei Strings nicht gepr¨uft, ob der Index korrekt ist. Dies muss der Programmierer sicherstellen. Auf die Pr¨ufung wurde verzichtet, da sie Zeit kostet. Beim Abw¨agen der Vor- und Nachteile war die wesentliche Argumentation, dass man diesen Datentyp jederzeit mit einem pr¨ufenden Mengentyp kapseln k¨onnte. Umgekehrt kann man aber keine Kapsel um einen pr¨ufenden Datentyp legen, die dann doch nicht pr¨uft.
3.5.2
Beispielprogramm mit Deques
Das vorherige Beispiel l¨asst sich leicht variieren. Dazu soll das folgende Programm ein Beispiel liefern. Es erzeugt wieder eine Menge, f¨ugt sechs Elemente ein und gibt alle Elemente wieder aus. Im Unterschied zum vorherigen Beispiel sind die Elemente diesmal allerdings Strings, als Container wird eine Deque (gesprochen Deck“ ) verwendet, und die Elemente werden jeweils ” vorn eingef¨ugt:
// stl/deque1.cpp #include #include <deque> #include <string> int main() { std::deque<std::string> menge;
// Deque-Container f¨ur strings
// Elemente jeweils vorn einf¨ugen
menge.push_front("oefter"); menge.push_front("immer"); menge.push_front("aber"); menge.push_front("immer"); menge.push_front("nicht"); // alle Elemente jeweils gefolgt von einem Leerzeichen ausgeben
for (unsigned i=0; i<menge.size(); ++i) { std::cout << menge[i] << ' '; } // zum Abschluss ein Zeilenende ausgeben
}
std::cout << std::endl;
Sandini Bib
3.5 Verarbeitung von Mengen
77
In diesem Fall wird mit
#include <deque> die Headerdatei f¨ur die Deque eingebunden. Die Deklaration
std::deque<std::string> menge; erzeugt eine leere Menge von String-Elementen. Die Elemente werden jeweils mit push_front() eingef¨ugt:
menge.push_front("oefter"); ...
menge.push_front("nicht"); Da die Elemente jeweils vorn eingef¨ugt werden, dreht sich die Reihenfolge der Elemente um. Das zuletzt eingef¨ugte Wort befindet sich ganz vorn. Das Programm hat somit folgende Ausgabe:
nicht immer aber immer oefter
3.5.3
Vektor versus Deque
In den beiden vorherigen Beispielen wurden zwei verschiedene STL-Container verwendet, ein Vektor und eine Deque. Worin unterscheiden sich diese beiden Datentypen? Die Bezeichnung deque“ ist eine Abk¨urzung f¨ur double-ended queue“. Es handelt sich ” ” dabei um eine etwas trickreichere Datenstruktur, die im Prinzip die Eigenschaften eines dynamischen Feldes hat, dabei aber in beide Richtungen wachsen kann. Wenn man bei einem Vektor vorn ein neues erstes Element einf¨ugt, m¨ussen alle bisherigen Elemente einen Speicherplatz nach hinten verschoben werden. Bei einer Deque ist das nicht der Fall. Die interne Implementierung verwaltet typischerweise einfach ein Feld von Feldern, bei dem beide Enden wachsen und schrumpfen k¨onnen (siehe Abbildung 3.3).
Abbildung 3.3: Typische interne Struktur einer Deque
Sandini Bib
78
Kapitel 3: Grundkonzepte von C++-Programmen
Ein Vektor ist also schnell, wenn man Elemente am Ende einf¨ugt oder l¨oscht. Ein Einf¨ugen von Elementen am Anfang ist dagegen langsam. Eine Deque ist dagegen an beiden Enden schnell (wenn auch durch die interne Struktur im Vergleich zu Vektoren etwas langsamer). In beiden Containern ist das Einf¨ugen und L¨oschen von Elementen in der Mitte aber relativ langsam, da immer Elemente verschoben werden m¨ussen. Dieser Unterschied in den F¨ahigkeiten spiegelt sich auch in der jeweiligen Schnittstelle der beiden Klassen wider. Die Deque bietet Funktionen zum Einf¨ugen und L¨oschen an beiden Enden. Insofern kann man bei einer Deque die Elemente auch mit push_back() einf¨ugen. Die Funktion push_front() wird dagegen f¨ur Vektoren nicht angeboten. Generell bieten STL-Container spezielle Funktionen, die die speziellen F¨ahigkeiten der Container widerspiegeln und ein gutes Zeitverhalten besitzen. Dies soll Programmierer etwas davor bewahren, Funktionen oder Container mit schlechtem Zeitverhalten zu verwenden. Man kann allerdings trotzdem in beide Container Elemente mit einer allgemeinen Einf¨ugefunktion an beliebiger Stelle einf¨ugen (dies wird sp¨ater auf Seite 89 erl¨autert).
3.5.4
Iteratoren
Im Allgemeinen wird auf STL-Container mit Hilfe von Iteratoren zugegriffen. Iteratoren sind Objekte, die u¨ ber Container iterieren“ (wandern) k¨onnen. Jedes Iterator-Objekt repr¨asentiert ” eine Position in einem Container. Der entscheidende Vorteil von Iteratoren ist, dass sie es erlauben, f¨ur alle Container die gleiche Schnittstelle zum Zugriff auf die Elemente zu verwenden. So setzt der Inkrement-Operator einen Iterator immer eine Position weiter. Dies ist unabh¨angig davon, ob er u¨ ber ein Feld, eine verkettete Liste oder eine andere Datenstruktur wandert. Dies ist m¨oglich, da die eigentlichen Iterator-Typen zu jedem Container passend zur Verf¨ugung gestellt werden und somit die Datenorganisation im Container ber¨ucksichtigen. Die folgenden fundamentalen Operationen definieren das Verhalten eines Iterators:
Operator * liefert das Element, an dessen Position sich der Iterator befindet. Operator ++ setzt den Iterator ein Element weiter. Operator == und Operator != liefert, ob zwei Iteratoren dasselbe Objekt repr¨asentieren (gleicher Container, gleiche Position). Operator = weist einem Iterator die Position eines anderen Iterators zu.
Um mit Iteratoren arbeiten zu k¨onnen, stellen Container entsprechende Elementfunktionen bereit. Die beiden wichtigsten sind:
begin() liefert einen Iterator, der die Position des ersten Elements im Container repr¨asentiert. end() liefert einen Iterator, der die Position hinter dem letzten Element (engl.: past-the-end“) im ” Container repr¨asentiert.
Sandini Bib
3.5 Verarbeitung von Mengen
79
e n d ( )
b e g i n ( )
Abbildung 3.4: begin() und end() bei Containern
Der Bereich von begin() bis end() ist also eine halboffene Menge (siehe Abbildung 3.4). Dies hat den Vorteil, dass sich eine einfache Ende-Bedingung f¨ur Iteratoren, die u¨ ber Container wandern, definieren l¨asst: Die Iteratoren wandern, bis end() erreicht ist. Ist begin() gleich end(), ist die Menge leer. Das folgende Beispielprogramm gibt mit Iteratoren alle Elemente in einem Vektor-Container aus. Es ist das Beispiel von Seite 74, umgestellt auf die Verwendung von Iteratoren:
// stl/vector2.cpp #include #include int main() { std::vector menge;
// Vektor-Container f¨ur ints
// Elemente mit den Werten 1 bis 6 einf¨ugen
for (int i=1; i<=6; ++i) { menge.push_back(i); }
// alle Elemente jeweils gefolgt von einem Leerzeichen ausgeben // - Iterator wandert dazu u¨ ber alle Elemente
std::vector::iterator pos; for (pos = menge.begin(); pos != menge.end(); ++pos) { std::cout << *pos << ' '; } // zum Abschluss ein Zeilenende ausgeben
}
std::cout << std::endl;
Sandini Bib
80
Kapitel 3: Grundkonzepte von C++-Programmen
Nachdem zun¨achst wieder die Menge mit den Werten 1 bis 6 gef¨ullt wurde, werden alle Elemente in der For-Schleife mit Hilfe eines Iterators ausgegeben. Der Iterator pos wird dabei zun¨achst als Iterator der dazugeh¨origen Containerklasse definiert:
std::vector::iterator pos; Der Typ des Iterators ist also iterator zum Container vector in der Standardbibliothek ” std“. Dies zeigt, dass Iteratoren jeweils von der dazugeh¨origen Containerklasse zur Verf¨ugung gestellt werden. In der For-Schleife selbst wird der Iterator mit der Position des ersten Elements initialisiert und wandert dann u¨ ber alle Elemente, solange er nicht das Ende (also die Position hinter dem letzten Element) erreicht hat (vgl. Abbildung 3.5):
for (pos = menge.begin(); pos != menge.end(); ++pos) { cout << *pos << ' '; } Dabei wird mit
*pos jeweils auf das aktuelle Element zugegriffen.
b e g i n ( )
p o s
+ +
e n d ( )
¨ Abbildung 3.5: Uber einen Vektor wandernder Iterator pos
Als Datentyp des Iterators kann auch const_iterator verwendet werden:
std::vector::const_iterator pos; Dieser Datentyp stellt sicher, dass Elemente durch einen Iterator-Zugriff nicht ver¨andert werden und kann insbesondere auch bei nicht¨anderbaren Containern verwendet werden. Auf Seite 456 wird ein const_iterator in einer Funktion verwendet, die in der Lage ist, die Elemente beliebiger STL-Container auszugeben.
3.5.5
Beispielprogramm mit einer Liste
Mit Hilfe von Iteratoren kann man auch auf Container zugreifen, f¨ur die der Indexoperator nicht angeboten wird. Dabei handelt es sich um alle Container, deren Elemente als Knoten mit Verweisen auf Nachfolger und Vorg¨anger verwaltet werden. Ein Beispiel daf¨ur ist ein List-Container. Ein List-Container – oder kurz eine Liste“ – ist als doppelt verkettete Liste von Elementen ” implementiert. Das bedeutet, dass jedes Element in der Menge auf genau einen Vorg¨anger und
Sandini Bib
3.5 Verarbeitung von Mengen
81
genau einen Nachfolger zeigt. Dadurch ist kein wahlfreier Zugriff mehr m¨oglich. Um auf das zehnte Element zuzugreifen, m¨ussen erst die ersten neun aufgesucht werden. Ein Wechsel auf benachbarte Elemente ist in beiden Richtungen in konstanter (immer der gleichen) Zeit m¨oglich. Der Zugriff auf ein bestimmtes Element dauert aber in linearer Zeit (ist proportional zur Entfernung zum Anfang). Daf¨ur ist das Einf¨ugen und L¨oschen von Elementen an allen Stellen gleich schnell. Es m¨ussen lediglich die entsprechenden Zeiger umgeh¨angt werden. Das folgende Beispiel definiert eine Liste f¨ur Zeichen, f¨ugt die Zeichen von 'a' bis 'z' ein und diese mit Hilfe eines Iterators wieder aus:
// stl/list1.cpp #include #include <list> int main() { std::list menge;
// List-Container f¨ur chars
// Elemente mit den Werten 'a' bis 'z' einf¨ugen for (char c='a'; c<='z'; ++c) { menge.push_back(c); } // alle Elemente jeweils gefolgt von einem Leerzeichen ausgeben // - Iterator wandert dazu u¨ ber alle Elemente
std::list::iterator pos; for (pos = menge.begin(); pos != menge.end(); ++pos) { std::cout << *pos << ' '; } // zum Abschluss ein Zeilenende ausgeben
}
std::cout << std::endl;
Die Schleife zum Ausgeben aller Elemente sieht genauso wie vorher aus. Lediglich der Datentyp des Iterators hat sich ge¨andert. Da dieser Datentyp zu einer Liste geh¨ort, weiß er, dass er, um bei ++ zum n¨achsten Element zu kommen, nicht einfach einen Index erh¨ohen, sondern einen Verweis traversieren muss (siehe Abbildung 3.6).
3.5.6
Beispielprogramme mit assoziativen Containern
Die vorgestellte Iterator-Schleife kann (bei entsprechender Anpassung des Iterator-Typs) f¨ur alle Container verwendet werden. Dies gilt f¨ur alle bisher vorgestellten Container vom Typ Vektor,
Sandini Bib
82
Kapitel 3: Grundkonzepte von C++-Programmen
b e g i n ( )
p o s
+ +
e n d ( )
¨ Abbildung 3.6: Uber eine Liste wandernder Iterator pos
Deque und Liste. Diese Container werden auch als sequenzielle Container bezeichnet, da der Anwendungsprogrammierer die Position der Elemente im Container bestimmt. Daneben gibt es auch noch die assoziativen Container. In diesen Containern werden die Elemente automatisch sortiert. Der Wert der Elemente bestimmt also deren Position. Es gibt folgende Arten von assoziativen Containern:
Set Ein Set-Container – oder kurz ein Set“ – ist eine Menge, in der die Elemente nur jeweils ” einmal vorkommen d¨urfen und automatisch nach ihrem Wert sortiert werden. Multiset Ein Multiset-Container – oder kurz ein Multiset“ – entspricht einem Set mit dem Unter” schied, dass Elemente mit gleichem Wert mehrfach vorkommen d¨urfen. Auch hier werden sie automatisch nach ihrem Wert sortiert. Map Ein Map-Container – oder kurz eine Map“ – verwaltet Schl¨ussel/Wert-Paare. Zu jedem Ele” ment geh¨ort ein identifizierender Schl¨ussel, nach dem sortiert wird, und ein dazugeh¨origer Wert. Jeder Schl¨ussel darf hier nur einmal vorkommen. Multimap Ein Multimap-Container – oder kurz eine Multimap“ – entspricht einer Map mit dem Un” terschied, dass die Schl¨ussel mehrfach vorkommen d¨urfen.
Alle diese Container verwenden intern typischerweise einen Bin¨arbaum als Datenstruktur. Auch zu diesen Containern folgen zwei Beispiele. Beispiel mit Sets Das erste Beispiel zeigt die entsprechende Verwendung eines Sets:
// stl/set1.cpp #include #include <set> int main() { std::set menge;
// Set-Container f¨ur ints
Sandini Bib
3.5 Verarbeitung von Mengen
83
// sieben Elemente mit den Werten 1 bis 6 ungeordnet einf¨ugen
menge.insert(3); menge.insert(1); menge.insert(5); menge.insert(4); menge.insert(6); menge.insert(2); menge.insert(1);
// alle Elemente jeweils gefolgt von einem Leerzeichen ausgeben
std::set::iterator pos; for (pos = menge.begin(); pos != menge.end(); ++pos) { std::cout << *pos << ' '; } // zum Abschluss ein Zeilenende ausgeben
}
std::cout << std::endl;
Mit
#include <set> wird die Headerdatei f¨ur Sets eingebunden. Mit
std::set menge; wird ein leeres Set f¨ur Elemente vom Typ int angelegt. An dieser Stelle k¨onnte man auch ein Sortierkriterium angeben. Da dies nicht der Fall ist, werden die Elemente als Default mit dem Operator < aufsteigend sortiert. Mit der Elementfunktion insert() wird jeweils ein Element eingef¨ugt und an der richtigen Stelle einsortiert:
menge.insert(3); menge.insert(1); ...
menge.insert(1); Nach dem Einf¨ugen aller Werte ergibt sich im Prinzip der in Abbildung 3.7 dargestellte Zustand: Die Elemente wurden so in die interne Baumstruktur des Containers einsortiert, dass sich jeweils links kleinere und rechts gr¨oßere Elemente befinden. Da ein Set und kein Multiset verwendet wurde, ist das mehrfach eingef¨ugte Element mit dem Wert 1 trotzdem nur einmal in der Menge vorhanden. Bei einem Multiset w¨are es zweimal vorhanden. Die Ausgabe aller Elemente funktioniert dann nach demselben Muster wie beim vorherigen Beispiel. Ein Iterator wandert u¨ ber alle Elemente und gibt sie jeweils aus:
Sandini Bib
84
Kapitel 3: Grundkonzepte von C++-Programmen
4
2
1
6
3
5
Abbildung 3.7: Ein Set mit sechs Elementen
std::set::iterator pos; for (pos = menge.begin(); pos != menge.end(); ++pos) { std::cout << *pos << ' '; } Dabei ist der Inkrement-Operator des Iterators so definiert, dass dieser unter Ber¨ucksichtigung der Baumstruktur des Containers jeweils das richtige Nachfolgeelement findet. Vom dritten Element wird hoch zum vierten und wieder hinunter zum f¨unften Element traversiert (siehe Abbildung 3.8). pos
++
4
2
1
6
3
5
¨ Abbildung 3.8: Uber ein Set wandernder Iterator pos
Die Ausgabe des Programms lautet entsprechend:
1 2 3 4 5 6 Beispiel mit Maps Bei Maps ist zu beachten, dass ein Element der Menge ein Schl¨ussel/Wert-Paar ist. Dies beeinflusst die Deklaration, das Einf¨ugen und den Zugriff auf Elemente:
Sandini Bib
3.5 Verarbeitung von Mengen
85
// stl/mmap1.cpp #include #include <map> #include <string> int main() { // Datentyp der Menge
typedef std::multimap IntStringMMap; IntStringMMap menge;
// Multimap-Container f¨ur int/string-Wertepaare
// einige Elemente ungeordnet einf¨ugen // - zwei Werte haben den Schl¨ussel 1
menge.insert(std::make_pair(5,"feucht")); menge.insert(std::make_pair(2,"besten")); menge.insert(std::make_pair(1,"Die")); menge.insert(std::make_pair(4,"sind:")); menge.insert(std::make_pair(5,"lang")); menge.insert(std::make_pair(3,"Parties")); / * die Werte aller Elemente ausgeben * - ein Iterator wandert u¨ ber alle Elemente * - mit second wird auf den Wert der Elemente zugegriffen */
}
IntStringMMap::iterator pos; for (pos = menge.begin(); pos != menge.end(); ++pos) { std::cout << pos->second << ' '; } std::cout << std::endl;
Da der Typ des Containers an mehreren Stellen ben¨otigt wird, wird er einmal als Datentyp definiert:
typedef std::multimap IntStringMMap; Statt mit
std::multimap menge; kann die Menge nun mit
IntStringMMap menge; angelegt werden.
Sandini Bib
86
Kapitel 3: Grundkonzepte von C++-Programmen
Der Datentyp definiert eine Multimap, deren Elemente den Datentyp int als Schl¨ussel und den Datentyp string als Wert besitzen. Da die Elemente Wertepaare sind, werden sie mit Hilfe von make_pair() eingef¨ugt:
menge.insert(std::make_pair(5,"feucht")); menge.insert(std::make_pair(2,"besten")); ...
make_pair() erzeugt Objekte vom Typ std::pair, die einfach nur Wertepaare darstellen. Entsprechend kann man die Elemente auch nicht einfach ausgeben. Greift man u¨ ber einen Iterator auf die Elemente mit *pos zu, erh¨alt man wiederum den Datentyp std::pair. Bei einem solchen Wertepaar kann man mit .first auf den ersten Teil des Wertepaares (in diesem Fall also auf den Schl¨ussel) und mit .second auf den zweiten Teil des Wertepaares (in diesem Fall also auf den zum Schl¨ussel geh¨orenden Wert) zugreifen. Zum Ausgeben von Schl¨ussel und Wert k¨onnte man also Folgendes schreiben:
std::cout << (*pos).first << ' '; std::cout << (*pos).second << ' ';
// Schl¨ussel des Elements ausgeben // Wert des Elements ausgeben
Man beachte, dass man den Ausdruck mit dem Stern-Operator klammern muss, da er eine niedrigerer Priorit¨at als der Punkt-Operator besitzt. Es gibt f¨ur diese Kombination von Operatoren allerdings den Operator -> als Abk¨urzung, weshalb der Wert der Elemente wie hier im Beispiel auch einfach mit
std::cout << pos->second << ' ';
// Wert des Elements ausgeben
ausgegeben werden kann. Die Ausgabe des Programms k¨onnte wie folgt lauten:
Die besten Parties sind: feucht lang Man beachte, dass es zwei Elemente mit dem Schl¨ussel 5 gibt. Je nach Implementierung der Standardbibliothek ist also auch folgende Ausgabe m¨oglich:
Die besten Parties sind: lang feucht
3.5.7
Algorithmen
Wie die bisherigen Beispiele zeigen, wird die tats¨achliche Datenstruktur von STL-Containern weitgehend gekapselt. Es mag zwar einige spezielle Operationen geben (wie den Indexoperator f¨ur Vektoren und Deques), doch kann man mit Iteratoren bei allen Containern auf die gleiche Weise auf die Elemente zugreifen. Diesen Umstand kann man ausnutzen, um Funktionen zu schreiben, die universell mit allen Datenstrukturen umgehen k¨onnen. Die STL bietet auch gleich eine Reihe von derartigen Funktionen. Diese werden dort Algorithmen genannt. Es gibt unter anderem Algorithmen zum Finden, Vertauschen, Sortieren, Kopieren, Aufaddieren und Modifizieren von Elementen.
Sandini Bib
3.5 Verarbeitung von Mengen
87
Ein kleines Beispiel soll einige m¨ogliche Algorithmen und deren Anwendung zeigen:
// stl/algo1.cpp #include #include #include int main() { std::vector menge; std::vector::iterator pos;
// Vektor-Container f¨ur ints // Iterator
// Elemente 1 bis 6 unsortiert in die Menge einf¨ugen
menge.push_back(2); menge.push_back(5); menge.push_back(4); menge.push_back(1); menge.push_back(6); menge.push_back(3);
// kleinstes und gr¨oßtes Element ausgeben
pos = std::min_element (menge.begin(), menge.end()); std::cout << "min: " << *pos << std::endl; pos = std::max_element (menge.begin(), menge.end()); std::cout << "max: " << *pos << std::endl; // alle Elemente aufsteigend sortieren
std::sort (menge.begin(), menge.end()); // Reihenfolge der Elemente umkehren
std::reverse (menge.begin(), menge.end()); // alle Elemente ausgeben
}
for (pos=menge.begin(); pos!=menge.end(); ++pos) { std::cout << *pos << ' '; } std::cout << std::endl;
Sandini Bib
88
Kapitel 3: Grundkonzepte von C++-Programmen
Im Beispiel wird zun¨achst eine Menge mit sechs unsortierten Integern angelegt:
std::vector menge; menge.push_back(2); ...
Als erstes werden die beiden Algorithmen std::min_element() und std::max_element() aufgerufen. Sie erhalten als Parameter jeweils einen durch zwei Iteratoren definierten Bereich, in dem das minimale bzw. maximale Element gesucht werden soll. Zur¨uckgeliefert wird ein Iterator f¨ur die Position dieses Elements. Bei der Zuweisung
pos = min_element (menge.begin(), menge.end()); liefert min_element() also einen Iterator f¨ur das kleinste Element in der gesamten Menge (gibt es mehrere, wird das erste kleinste genommen) und weist es dem Iterator pos zu. Dieses Element wird dann ausgegeben:
std::cout << "min: " << *pos << std::endl; Nat¨urlich w¨are das auch gleich in einem Schritt m¨oglich:
std::cout << *std::max_element(menge.begin(),menge.end()) << std::endl; Der n¨achste angewendete Algorithmus ist std::sort(). Er sortiert die Elemente in dem Bereich, der durch die u¨ bergebenen Iteratoren definiert wird. Da der Bereich in diesem Fall alle Elemente umfasst, werden auch alle Elemente sortiert:
std::sort (menge.begin(), menge.end()); Der letzte im Beispiel verwendete Algorithmus ist std::reverse(). Er dreht die Reihenfolge aller Elemente im angegebenen Bereich um:
std::reverse (menge.begin(), menge.end()); Die Ausgabe des Programms lautet entsprechend:
min: 1 max: 6 6 5 4 3 2 1 Dieses Beispiel zeigt, wie bequem es nun geworden ist, verschiedene Datenstrukturen zu verwenden. Man kann insbesondere durch Verwendung anderer Datentypen die Datenstrukturen bequem wechseln. Das gleiche Beispiel l¨auft auch, wenn man vector jeweils durch deque oder list ersetzt. Nur bei der Verwendung von einem Set gibt es Schwierigkeiten. Da ein Set selbst die Reihenfolge der Elemente bestimmt, kann man sort() und reverse() nicht aufrufen. Der Versuch wird durch eine leider oft sehr kryptische Fehlermeldung des Compilers quittiert. Außerdem muss push_back() bei Sets durch insert() ersetzt werden.
Sandini Bib
3.5 Verarbeitung von Mengen
89
Finden und einfugen ¨ Ein weiteres Beispiel soll demonstrieren, wie mit Hilfe von Algorithmen und Iteratoren Elemente vor bestimmten anderen Elementen eingef¨ugt werden k¨onnen. Das Programm hat folgenden Aufbau:
// stl/algo2.cpp #include #include #include #include
<string>
int main() { std::vector<std::string> menge; std::vector<std::string>::iterator pos;
// Container f¨ur Strings // Iterator
// Verschiedene St¨adtenamen einf¨ugen
menge.push_back("Hamburg"); menge.push_back("Munchen"); menge.push_back("Berlin"); menge.push_back("Braunschweig"); menge.push_back("Duisburg"); menge.push_back("Leipzig"); // alle Elemente aufsteigend sortieren
std::sort (menge.begin(), menge.end()); /* ”Hannover” vor ”Hamburg” einf¨ugen * - Position von ”Hamburg” suchen * - ”Hannover” davor einf¨ugen */
// Bereich pos = find (menge.begin(), menge.end(), "Hamburg"); // Suchkriterium if (pos != menge.end()) { menge.insert(pos,"Hannover"); } else { std::cerr << "Huch, Hamburg ist gar nicht vorhanden" << std::endl; menge.push_back("Hannover"); }
Sandini Bib
90
Kapitel 3: Grundkonzepte von C++-Programmen // alle Elemente ausgeben
}
for (pos=menge.begin(); pos!=menge.end(); ++pos) { std::cout << *pos << ' '; } std::cout << std::endl;
Zun¨achst werden in einen Vektor verschiedene St¨adtenamen eingef¨ugt und sortiert. Die interessante Stelle ist die Anweisung, mit der versucht wird, Hannover vor Hamburg einzuf¨ugen. Dazu wird zun¨achst der Find-Algorithmus aufgerufen, um die Position von Hamburg herauszufinden:
pos = find (menge.begin(), menge.end(), "Hamburg");
// Bereich // Suchkriterium
Wie bei Algorithmen u¨ blich, werden zun¨achst der Anfang und das Ende des Bereichs, der durchsucht werden soll, u¨ bergeben. Der dritte Parameter ist der Wert, der gesucht wird. Er wird mit dem Operator == jeweils mit allen Elementen verglichen. Wird ein entsprechendes Element gefunden, wird dessen Position in Form eines Iterators zur¨uckgeliefert. Wird kein passendes Element gefunden, wird das Ende der Menge zur¨uckgeliefert, was entsprechend gepr¨uft wird:
if (pos != menge.end()) { ...
} Sofern Hamburg gefunden wird, wird die Position dazu verwendet, Hannover davor einzuf¨ugen. Dazu wird die Elementfunktion insert() verwendet, bei der als erster Parameter ein Iterator f¨ur die Position und als zweiter Parameter der einzuf¨ugende Wert u¨ bergeben wird:
menge.insert(pos,"Hannover"); Die Ausgabe des Programms lautet entsprechend:
Berlin Braunschweig Duisburg Hannover Hamburg Leipzig M unchen
3.5.8
Algorithmen mit mehreren Bereichen
Bei den meisten Algorithmen, die mehrere Bereiche bearbeiten, muss nur beim ersten Bereich sowohl der Anfang als auch das Ende angegeben werden. Bei allen anderen Bereichen ist die Angabe des Anfangs ausreichend. Das Ende folgt dann aus dem Zusammenhang bzw. der Operation. Dies gilt insbesondere bei Algorithmen, die Elemente gegebenenfalls modifiziert in eine andere Menge kopieren. Hierbei ist eine Warnung angebracht: Es ist unbedingt darauf zu achten, dass die Zielmengen groß genug sind! Das folgende Programm verdeutlicht das Problem:
// stl/copy1.cpp #include #include #include <list>
Sandini Bib
3.5 Verarbeitung von Mengen
91
#include int main() { std::list menge1; std::vector menge2; // Elemente 1 bis 6 in die erste Menge einf¨ugen
for (int i=1; i<=6; i++) { menge1.push_back(i); } /* LAUFZEITFEHLER:
* - Elemente in die zweite Menge kopieren */
}
std::copy (menge1.begin(), menge1.end(), menge2.begin());
// Quellbereich // Zielbereich
Der Algorithmus std::copy() erh¨alt als Parameter den Anfang und das Ende des Quellbereichs und den Anfang eines Zielbereichs, in den die Elemente des Quellbereichs kopiert werden. Er setzt voraus, dass der Zielbereich bereits groß genug ist, um alle Elemente aufzunehmen. Wenn der Zielbereich aber wie in diesem Beispiel leer ist, wird auf Speicher zugegriffen, der nicht verf¨ugbar ist, was im besten Fall zu einem Absturz f¨uhrt (dann merkt man wenigstens, dass etwas schief gegangen ist). Um solche Fehler zu vermeiden, gibt es zwei M¨oglichkeiten: 1. Man muss daf¨ur sorgen, dass der Zielbereich groß genug ist. 2. Man muss einf¨ugende Iteratoren verwenden. Damit der Zielbereich groß genug ist, muss er entweder gleich mit der richtigen Gr¨oße angelegt oder explizit auf die richtige Gr¨oße gesetzt werden. Beides ist aber nur bei sequenziellen Containern (Vektoren, Deques, Listen) m¨oglich. Das folgende Programm zeigt dies an einem Beispiel:
// stl/copy2.cpp #include #include #include #include #include
<list> <deque>
Sandini Bib
92
Kapitel 3: Grundkonzepte von C++-Programmen
int main() { std::list menge1; std::vector menge2; // Elemente 1 bis 6 in die erste Menge einf¨ugen
for (int i=1; i<=6; i++) { menge1.push_back(i); }
// Platz f¨ur die zu kopierenden Elemente schaffen
menge2.resize(menge1.size()); // Elemente in die zweite Menge kopieren
std::copy (menge1.begin(), menge1.end(), menge2.begin());
// Quellbereich // Zielbereich
/* dritte Menge ausreichend groß definieren * - Die Startgr¨oße wird als Parameter u¨ bergeben */
std::deque menge3(menge1.size()); // Elemente in die dritte Menge kopieren
}
std::copy (menge1.begin(), menge1.end(), menge3.begin());
// Quellbereich // Zielbereich
Man beachte, dass durch das Definieren der Gr¨oße auch Elemente angelegt werden. Diese Elemente werden jeweils mit ihrem Default-Wert erzeugt. Einfugende ¨ Iteratoren Die andere M¨oglichkeit, einf¨ugende Iteratoren zu verwenden, zeigt folgendes Beispiel:
// stl/copy3.cpp #include #include #include #include #include
<list> <deque>
int main() {
Sandini Bib
3.5 Verarbeitung von Mengen
93
std::list menge1; std::vector menge2; std::deque menge3; // Elemente 1 bis 6 in die erste Menge einf¨ugen
for (int i=1; i<=6; i++) { menge1.push_back(i); }
// Elemente hinten einf¨ugend in die zweite Menge kopieren
std::copy (menge1.begin(), menge1.end(), std::back_inserter(menge2));
// Quellbereich // Zielbereich
// Elemente vorn einf¨ugend in die dritte Menge kopieren
}
std::copy (menge1.begin(), menge1.end(), std::front_inserter(menge3));
// Quellbereich // Zielbereich
Hier werden zwei speziell vordefinierte Iteratoren (so genannte Iterator-Adapter) verwendet:
Back-Inserter Ein Back-Inserter f¨ugt die Elemente jeweils am Ende ein (h¨angt sie also an). Mit jeder Zuweisung eines Elements wird dieses mit push_back() in den u¨ bergebenen Container eingef¨ugt. Durch den Aufruf
std::copy (menge1.begin(), menge1.end(), std::back_inserter(menge2));
// Quellbereich // Zielbereich
werden also alle Elemente aus menge1 jeweils am Ende von menge2 eingef¨ugt. Die Operation kann nur bei Containern aufgerufen werden, bei denen die Elementfunktion push_back() vorhanden ist. Dies sind Vektoren, Deques und Listen. Front-Inserter Ein Front-Inserter f¨ugt die Elemente jeweils am Anfang eines Containers durch Aufruf von push_front() ein. Dies hat zur Folge, dass die Reihenfolge der einzuf¨ugenden Elemente umgedreht wird. Durch den Aufruf
std::copy (menge1.begin(), menge1.end(), std::front_inserter(menge3));
// Quellbereich // Zielbereich
werden also alle Elemente aus menge1 jeweils am Anfang von menge3 eingef¨ugt. Die Operation kann nur bei Containern aufgerufen werden, bei denen die Elementfunktion push_front() vorhanden ist. Dies sind Deques und Listen.
Sandini Bib
94
Kapitel 3: Grundkonzepte von C++-Programmen
3.5.9
Stream-Iteratoren
Eine weitere Form von Iterator-Adaptern sind Stream-Iteratoren. Dies sind Iteratoren, die aus einem Stream lesen bzw. in einen Stream schreiben. Die Eingaben von der Tastatur oder die Ausgaben auf den Bildschirm bilden bei diesen Iteratoren die Menge“ oder den Container“, ” ” deren bzw. dessen Elemente durch die Algorithmen der STL bearbeitet werden. Das folgende Beispiel zeigt, wie dies konkret aussehen kann:
// stl/ioiter1.cpp #include #include #include #include
<string>
int main() { using namespace std; vector<string> menge;
// Alle Symbole in std sind global // Vektor-Container f¨ur Strings
/* Strings von der Standard-Eingabe bis zum Ende der Daten einlesen * - von der ‘‘Eingabe-Menge’’ cin einf¨ugend in menge kopieren */ // Beginn Quellbereich copy (istream_iterator<string>(cin), istream_iterator<string>(), // Ende Quellbereich back_inserter(menge)); // Zielbereich // Elemente in menge sortieren
sort (menge.begin(), menge.end());
}
/* alle Elemente ausgeben * - von menge in die ‘‘Ausgabe-Menge’’ cout kopieren * - jeder String auf einer Zeile (durch "\n" getrennt) */ copy (menge.begin(), menge.end(), ostream_iterator<string>(cout,"\n"));
// Quellbereich // Zielbereich
Zun¨achst wird in diesem Programm eine spezielle Using-Direktive verwendet:
using namespace std; Diese Direktive besagt, dass im aktuellen G¨ultigkeitsbereich auf alle Symbole aus std global zugegriffen werden kann. Damit entf¨allt die Notwendigkeit der Qualifizierung aller Symbole aus
Sandini Bib
3.5 Verarbeitung von Mengen
95
der Standardbibliothek. Vorsicht, eine derartige Direktive sollte nur in einem Kontext verwendet werden, in dem man weiß, dass dadurch keine Konflikte oder unerw¨unschten Seiteneffekte hervorgerufen werden. Insofern sollten Using-Direktiven nie in Headerdateien verwendet werden. In der Anweisung
copy (istream_iterator<string>(cin), istream_iterator<string>(), back_inserter(menge));
// Beginn Quellbereich // Ende Quellbereich // Zielbereich
werden gleich zwei spezielle Iteratoren erzeugt:
Der Ausdruck
istream_iterator<string>(cin)
erzeugt f¨ur den Input-Stream cin einen Iterator, der string-Elemente einliest. Diese Elemente werden vom Iterator jeweils mit dem Eingabeoperator >> eingelesen. Der Ausdruck
istream_iterator<string>() erzeugt, da kein Parameter u¨ bergeben wurde, einen speziellen End-Of-Stream-Iterator. Er steht f¨ur einen Stream, von dem nicht mehr gelesen werden kann. Der copy()-Algorithmus l¨asst den u¨ bergebenen ersten Iterator operieren, solange er ungleich dem zweiten Iterator ist. Das bedeutet, dass von cin so lange gelesen wird, bis keine Daten mehr vorliegen bzw. gelesen werden k¨onnen. Insgesamt definiert der u¨ bergebene Bereich also alle ” von cin gelesenen strings“. Diese werden mittels copy() mit Hilfe eines Back-Inserters in menge eingef¨ugt. Nachdem die eingelesenen Elemente in der Menge sortiert worden sind, werden in der Anweisung
copy (menge.begin(), menge.end(), ostream_iterator<string>(cout,"\n"));
// Quellbereich // Zielbereich
alle Elemente der Menge in den Zielbereich“ cout kopiert. Der Ausdruck ”
ostream_iterator<string>(cout,"\n")
erzeugt dabei f¨ur den Output-Stream cout einen Iterator, der string-Elemente ausgibt. Auch hier kann ein beliebiger Typ angegeben werden, f¨ur den dann jeweils der Ausgabe-Operator << aufgerufen wird. Der optionale zweite Parameter definiert, welche Zeichenfolge jeweils zwischen zwei Elementen eingef¨ugt wird. In diesem Fall ist es ein Zeilentrenner, der daf¨ur sorgt, dass jeder String auf einer eigenen Zeile ausgegeben wird. Insgesamt liest das Programm also alle Strings von der Standard-Eingabe ein und gibt sie sortiert, jeden String auf einer Zeile, wieder aus. Dieses Beispiel zeigt, wie pr¨agnant in C++ mit Hilfe der STL programmiert werden kann. Durch ein Auswechseln der Datentypen kann außerdem schnell und einfach gepr¨uft werden, ob andere Datenstrukturen als Vektoren die Aufgabe schneller erledigen. So k¨onnte man zum Beispiel versuchsweise einen Set-Container verwenden. Da dieser automatisch sortiert, kann und muss der Aufruf von sort() in dem Fall entfallen. Probieren Sie es ruhig aus.
Sandini Bib
96
Kapitel 3: Grundkonzepte von C++-Programmen
3.5.10
Ausblick
Wie schon am Anfang des Abschnitts erw¨ahnt, war dies nur ein allgemeiner Einstieg in die STL. Viele Details, Hintergrundinformationen und erg¨anzende Techniken wurden nicht erl¨autert. Dennoch reichen diese Beispiele aus, um erst einmal Mengen programmieren zu ko¨ nnen. Im weiteren Verlauf werden STL-Container noch an verschiedener Stelle verwendet. In Kapitel 9 werden noch weitere Details und hilfreiche Techniken zum Umgang mit der STL vorgestellt:
In Abschnitt 9.1.1 werden alle Operationen von Vektoren im Einzelnen vorgestellt. In Abschnitt 9.1.3 werden alle Standard-Algorithmen kurz aufgelistet. In Abschnitt 9.2.1 wird vorgestellt, wie man mit Hilfe von Smart-Pointern in STL-Containern Verweise auf Objekte verwalten kann. In Abschnitt 9.2.2 wird erl¨autert, wie man mit Hilfsfunktionen und Funktionsobjekten Verarbeitungskriterien f¨ur STL-Algorithmen definieren kann.
F¨ur weitere Details sei auf spezielle B¨ucher zur C++-Standardbibliothek und zur STL verwiesen.
3.5.11
Zusammenfassung
Die STL-Komponente der C++-Standardbibliothek bietet verschiedene Container, Iteratoren und Algorithmen, mit deren Hilfe man bequem Mengen in unterschiedlichen Datenstrukturen verwalten kann. Im Einzelnen existieren folgende Datentypen mit ihren dazugeh¨origen typischen Datenstrukturen: Container Typische Datenstruktur vector dynamisches Feld (Array) deque nach beiden Enden offenes dynamisches Feld list doppelt verkettete Liste set Bin¨arbaum ohne Duplikate multiset Bin¨arbaum mit Duplikaten map Bin¨arbaum f¨ur Schl¨ussel/Wert-Paare ohne Duplikate multimap Bin¨arbaum f¨ur Schl¨ussel/Wert-Paare mit Duplikaten Iteratoren erlauben es, bei allen Containern unabh¨angig von der dazugeh¨origen Datenstruktur auf die Elemente zuzugreifen. Verschiedene Algorithmen sind f¨ur Standardzugriffe auf Mengen vordefiniert. Inserter sind spezielle Iteratoren, die einf¨ugend operieren. Ohne Inserter muss darauf geachtet werden, dass Zielmengen von Algorithmen ausreichen groß sind. Stream-Iteratoren erlauben es, die Standard-Ein- und -Ausgabe als Menge bzw. Container zu verwenden. Mit typedef kann man einen eigenen Namen f¨ur Datentypen definieren.
Sandini Bib
3.5 Verarbeitung von Mengen
97
Mit
using namespace std; kann man die Symbole der Standardbibliothek global verf¨ugbar machen. Diese M¨oglichkeit sollte nie in Headerdateien verwendet werden.
Sandini Bib
98
Kapitel 3: Grundkonzepte von C++-Programmen
3.6 Ausnahmebehandlung Dieser Abschnitt f¨uhrt in die so genannte Ausnahmebehandlung (als Anglizismus auch ExceptionHandling genannt) ein. Mit der Ausnahmebehandlung wird eine neue Art der Behandlung von Fehlern und Ausnahmesituationen erm¨oglicht. Im Gegensatz zu herk¨ommlichen Sprachen, in denen die Fehlerbehandlung bei Funktionsaufrufen u¨ ber die normalen Schnittstellen, Parameter und R¨uckgabewerte, stattfindet, handelt es sich um ein Sprachmittel, bei dem Fehler oder Ausnahmen parallel zum Datenfluss mit einem separaten Mechanismus behandelt werden. Durch diese Trennung wird das Programmverhalten u¨ berschaubarer und sicherer. In diesem Kapitel wird nach einer konzeptionellen Einf¨uhrung vor allem auf den Umgang mit Ausnahmen in Programmen eingegangen. In Abschnitt 4.7 werden dann weitere Details vor allem aus der Sicht von Klassen, in denen Ausnahmen erkannt und ausgel¨ost werden, erl¨autert.
3.6.1
Motivation fur ¨ das Konzept der Ausnahmebehandlung
In jedem Programm kann es vorkommen, dass Situationen eintreten, die normalerweise nicht vorgesehen sind. Anwender k¨onnen unsinnige Werte eingeben, Verbindungen zu anderen Prozessen k¨onnen unterbrochen werden, Speicherplatz kann verbraucht sein, und last but not least k¨onnen einfach Fehler auftreten, da bei der Implementierung nicht alle F¨alle bedacht oder getestet wurden. Damit Programme dann nicht einfach abst¨urzen, m¨ussen Fehlerbehandlungen eingebaut werden. Dies gilt insbesondere f¨ur den Aufruf von Funktionen, die mit der Außenwelt“ Kontakt ” aufnehmen: Ein Anwender gibt nicht das ein, was er eingeben darf, eine Systemfunktion kann nicht das leisten, was sie soll. Aber auch bei internen Funktionsaufrufen und Zugriffen auf im Programm befindliche Daten k¨onnen Fehler auftreten. Um mit Fehlern oder unvorhergesehenen Situationen umzugehen, werden in herk¨ommlichen Programmiersprachen Status-Flags, Error-Handler und spezielle R¨uckgabewerte verwendet. Im Programm-Code m¨ussen dann st¨andig diese Fehlerf¨alle getestet und behandelt werden. Damit gibt es keine klare Trennung von normalem Datenfluss und Fehlerbehandlung. Insbesondere bei Funktionsaufrufen werden der normale Datenfluss und die Fehlerbehandlung u¨ ber ein und dieselbe Schnittstelle (Parameter und R¨uckgabewert) abgewickelt (siehe Abbildung 3.9).
Datenfluss
Funktionsaufruf
Funktionsaufruf
Fehlerbehandlung
Abbildung 3.9: Datenfluss und Fehlerbehandlung herk¨ommlich
Sandini Bib
3.6 Ausnahmebehandlung
99
Es ist z.B. typisch, dass spezielle R¨uckgabewerte Fehlerf¨alle anzeigen. Dies f¨uhrt dazu, dass f¨ur den R¨uckgabewert immer zus¨atzlich auch eine Sonderbehandlung implementiert werden muss. Wenn diese Sonderbehandlung entf¨allt, kann das Programm in einen undefinierten Zustand geraten, der fatale Folgen haben kann. F¨ur die Fehlerbehandlung wird dabei sogar manchmal der Wertebereich f¨ur die R¨uckgabewerte erweitert. Ein Beispiel daf¨ur ist die C-Funktion getchar(), die ein Zeichen liest und zur¨uckliefert. Damit neben jedem beliebigen Zeichen auch ein Fehlerwert zur¨uckgeliefert werden kann, ist der Typ des R¨uckgabewerts nicht char, sondern int. In objektorientierten Sprachen wie C++ kommt nun noch der Sonderfall hinzu, dass manche Operationen u¨ berhaupt keinen R¨uckgabewert haben k¨onnen, mit dem ein Fehler angezeigt werden kann. Ein Beispiel daf¨ur ist das Anlegen von Objekten durch eine einfache Deklaration. Sofern f¨ur Objekte bei einer Initialisierung z.B. Speicherplatz angefordert wird, Argumente verarbeitet oder Dateien ge¨offnet werden, kann dies schief gehen:
std::string s("hallo");
// kann fehlschlagen
Auch ein Zugriff mit dem Indexoperator kann zu einem Fehlerfall f¨uhren:
s[20] = 'q';
// kann fehlschlagen
Derartige Fehlerf¨alle k¨onnen mit den herk¨ommlichen Sprachmitteln nur schlecht oder gar nicht getestet und behandelt werden; denn wie soll getestet werden, ob beim Erzeugen eines tempor¨aren Objekts mitten in einem Ausdruck alles geklappt hat? Das Dilemma besteht darin, dass der Fehler zwar in den Operationen erkannt, nicht aber geeignet behandelt werden kann. Damit bleibt mit herk¨ommlichen Sprachmitteln eigentlich nur die M¨oglichkeit, eine Warnung auszugeben und irgendwie weiterzumachen oder das Programm mit einer Fehlermeldung zu beenden. Das Grundproblem ist also das folgende: Es treten Fehlersituationen auf, die zwar erkannt, nicht aber sinnvoll behandelt werden k¨onnen, da der Kontext, aus dem heraus der Fehler verursacht wurde, nicht bekannt ist. Andererseits kann der Fehler auch nicht an die Aufrufumgebung zur¨uckgemeldet werden, da R¨uckgabewerte entweder gar nicht definiert sind oder anderen Zwecken dienen. Es wird also ein Mechanismus ben¨otigt, der eine Trennung von Fehlererkennung und Feh¨ lerbehandlung erm¨oglicht und dabei nicht die Ubergabe von Parametern oder R¨uckgabewerten verwendet. Die Fehlerbehandlung sollte vom normalen Datenfluss komplett entkoppelt werden (siehe Abbildung 3.10).
Datenfluss
Funktionsaufruf
Funktionsaufruf
Fehlerbehandlung
Abbildung 3.10: Datenfluss und Fehlerbehandlung nach dem Konzept der Ausnahmebehandlung
Sandini Bib
100
Kapitel 3: Grundkonzepte von C++-Programmen
Das Konzept der Ausnahmebehandlung bietet einen solchen Mechanismus: An beliebiger Stelle im Code k¨onnen Fehler erkannt und u¨ ber einen eigenen Mechanismus an die jeweilige Aufrufumgebung gemeldet werden. In dieser Umgebung kann der Fehler dann abgefangen und entsprechend der aktuellen Situation sinnvoll behandelt werden. Geschieht dies nicht, wird der Fehler nicht einfach ignoriert, sondern f¨uhrt zu einem (definierbaren) geregelten Programmende (und keinem Programmabbruch). Um einer m¨oglichen Fehlinterpretation vorzubeugen, sei darauf hingewiesen, dass der Begriff Ausnahmebehandlung (Exception-Handling) in C++ bedeutet, dass Fehler oder Situationen behandelt werden, die ausnahmsweise im normalen Programmablauf auftreten. Es ist kein Mechanismus f¨ur Interrupts oder Signale, also Nachrichten, die von außen das laufende Programm unterbrechen und zur Folge haben, dass mitten im Programm an eine ganz andere Stelle zur Unterbrechungsbehandlung gesprungen wird. Die Ausnahmen, die f¨ur die Ausnahmebehandlung eine Rolle spielen, werden im Programm durch spezielle C++-Anweisungen ausgel¨ost und innerhalb der aktuellen G¨ultigkeitsbereiche verarbeitet. Man beachte auch, dass der Mechanismus Ausnahmebehandlung“ und nicht Fehlerbehand” ” lung“ genannt wird. Die Ausnahmen m¨ussen nicht unbedingt Fehler sein, und nicht jeder Fehler ist eine Ausnahmesituation (bei Benutzereingaben sind Fehler sogar eher die Regel). Der Mechanismus kann und sollte dazu verwendet werden, in außergew¨ohnlichen Situationen, die nicht der Bandbreite eines normalen“ Programmablaufs entsprechen, geordnet und flexibel zu reagieren. ” Fehlerhafte Benutzereingaben geh¨oren eher nicht dazu.
3.6.2
Das Konzept der Ausnahmebehandlung
Ausnahmen werden in C++ nach folgendem Konzept verarbeitet:
Wenn bei der Implementierung einer Funktion eine außergew¨ohnliche Situation auftritt, wird dies der Aufrufumgebung mit einer speziellen Anweisung mitgeteilt. Es wird sozusagen vom normalen Datenfluss auf die Ausnahmebehandlung umgeschaltet. Diese Ausnahmebehandlung sieht so aus, dass nach und nach alle aufgerufenen Blo¨ cke bzw. Funktionen verlassen werden, bis ein Block gefunden wird, in dem es f¨ur die Ausnahme eine Behandlungsm¨oglichkeit gibt. Bei der Durchf¨uhrung von Anweisungen kann definiert werden, was passieren soll, wenn durch die Anweisungen oder innerhalb darin aufgerufener Funktionen eine Ausnahmesituation auftritt. Ein eigener Block von Anweisungen kann dann festlegen, wie mit der Situation umgegangen wird.
Ausnahmeklassen Bei der Ausnahmebehandlung wird konsequenterweise ein objektorientierter Ansatz verwendet:
Ausnahmen werden als Objekte betrachtet. Tritt eine Ausnahme auf, wird ein entsprechendes Objekt erzeugt, das die Ausnahmesituation beschreibt. Ausnahmeklassen definieren die Eigenschaften dieser Objekte. Als Attribute k¨onnen erl¨auternde Eigenschaften einer Ausnahme (etwa ein fehlerhafter Index oder eine erl¨auternde Meldung) definiert werden. Operationen k¨onnen dazu dienen, weitere Informationen zu einer Ausnahme abzufragen. F¨ur verschiedene Arten von Ausnahmen gibt es entsprechend auch
Sandini Bib
3.6 Ausnahmebehandlung
101
verschiedene Klassen. Jede Ausnahmeklasse beschreibt also eine bestimmte Art von Ausnahmesituationen. Tritt eine Ausnahme auf, wird einfach ein Objekt der entsprechenden Ausnahmeklasse erzeugt. Diese Objekte k¨onnen, wie alle Objekte, auch Komponenten besitzen, die die Ausnahmesituation oder die Umst¨ande, unter denen sie auftrat, beschreiben. Es handelt sich sozusagen um die Parameter der Ausnahme. Durch Vererbung k¨onnen sogar Hierarchien von Ausnahmeklassen gebildet werden. Damit ist es z.B. m¨oglich, eine Ausnahmeklasse f¨ur mathematische Fehler“ noch in speziellere Klas” sen f¨ur Division durch 0“ oder Wurzel aus negativer Zahl“ zu unterteilen. Ein Anwendungs” ” programm besitzt dann die Freiheit, mathematische Fehler im Allgemeinen oder speziell eine Division durch 0 als Fehler behandeln zu k¨onnen. Dies kann auch wieder situationsabh¨angig erfolgen. Schlusselw¨ ¨ orter F¨ur die Ausnahmebehandlung wurden drei Schl¨usselw¨orter eingef¨uhrt.
throw dient dazu, ein entsprechendes Ausnahmeobjekt in die Programmumgebung zu werfen, wenn eine Ausnahmesituation auftritt. Damit wird vom normalen Datenfluss auf die Ausnahmebehandlung umgeschaltet. catch dient dazu, ein solches Ausnahmeobjekt abzufangen und auf die Ausnahmesituation zu reagieren. Damit wird also implementiert, was passieren soll, wenn der normale Datenfluss nicht funktioniert hat. try dient dazu, einen G¨ultigkeitsbereich anzugeben, innerhalb dessen etwaige Ausnahmen mit catch abgefangen und behandelt werden. Es wird versucht, die darin aufgef¨uhrten Anweisungen fehlerfrei durchzuf¨uhren. Mit try wird also der G¨ultigkeitsbereich f¨ur eine Ausnahmebehandlung definiert.
3.6.3
Standard-Ausnahmeklassen
Wie schon erl¨autert, werden Ausnahmen durch Objekte repr¨asentiert. Die dazugeh¨origen Datentypen m¨ussen von den Klassen oder Bibliotheken definiert werden, die diese Ausnahmen ausl¨osen. In C++ wird dabei eine Reihe von Standard-Ausnahmeklassen definiert, die in Abbildung 3.11 dargestellt werden. Alle Ausnahmeklassen besitzen die gemeinsame Basisklasse std::exception. Dies ist ein Datentyp, der stellvertretend f¨ur alle Standard-Ausnahmen verwendet werden kann. F¨ur Objekte dieser Klassen wird nur eine Operation definiert: Man kann mit what() eine implementierungsspezifische Meldung abfragen.
3.6.4
Ausnahmebehandlung am Beispiel
Zur Behandlung einer Ausnahme muss der Bereich, in dem auf Ausnahmen reagiert werden soll mit einem Try-Block umschlossen werden. Die Reaktion auf die Ausnahmen wird dann direkt hinter dem Try-Block durch einen oder mehrere Catch-Bl¨ocke definiert. Das folgende Beispiel zeigt, wie dies konkret aussehen kann:
Sandini Bib
102
Kapitel 3: Grundkonzepte von C++-Programmen
b a d _ a l l o c b a d _ c a s t b a d _ t y p e i d
d o m a i n _ e r r o r i n v a l i d _ a r g u m e n t l e n g t h _ e r r o r
l o g i c _ e r r o r
e x c e p t i o n
o u t _ o f _ r a n g e
i o s _ b a s e : : f a i l u r e
r u n t i m e _ e r r o r
b a d _ e x c e p t i o n
r a n g e _ e r r o r o v e r f l o w _ e r r o r u n d e r f l o w _ e r r o r
Abbildung 3.11: Hierarchie der Standard-Ausnahmeklassen
// allg/ehbsp1.cpp #include #include #include #include
<string> <exception>
// Headerdatei f¨ur I/O // Headerdatei f¨ur Strings // Headerdatei f¨ur EXIT_FAILURE // Headerdatei f¨ur Ausnahmen
int main() { try { // zwei Strings anlegen
std::string vorname("bjarne"); // kann std::bad_alloc ausl¨osen std::string nachname("stroustrup"); // kann std::bad_alloc ausl¨osen std::string name; // Strings manipulieren
vorname.at(20) = 'B'; nachname[30] = 'S';
// l¨ost std::out_of_range aus // FEHLER: undefiniertes Verhalten
// Strings verketten
}
name = vorname + " " + nachname; // k¨onnte std::bad_alloc ausl¨osen
Sandini Bib
3.6 Ausnahmebehandlung
103
catch (const std::bad_alloc& e) { // Spezielle Ausnahme: Speicherplatz alle
std::cerr << "Speicherplatz alle" << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE;
} catch (const std::exception& e) { // sonstige Standard-Ausnahmen
std::cerr << "Standard-Exception: " << e.what() << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE;
} catch (...) {
// alle (bisher nicht behandelten) Ausnahmen
} }
std::cerr << "unerwartete sonstige Exception" << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE;
std::cout << "OK, bisher ging alles gut" << std::endl;
Durch try wird ein Bereich definiert, f¨ur den eine gemeinsame Ausnahmebehandlung definiert wird. Es wird sozusagen versucht“, die im Try-Block befindlichen Anweisungen durchzuf¨uhren: ”
try { // zwei Strings anlegen
std::string vorname("bjarne"); // k¨onnte std::bad_alloc ausl¨osen std::string nachname("stroustrup"); // k¨onnte std::bad_alloc ausl¨osen std::string name; // Strings manipulieren
vorname.at(20) = 'B'; nachname[30] = 'S';
// l¨ost std::out_of_range aus // FEHLER: undefiniertes Verhalten
// Strings verketten
}
name = vorname + " " + nachname; // k¨onnte std::bad_alloc ausl¨osen
Tritt eine Ausnahme auf, wird der gesamte Try-Block sofort verlassen. Wird hier im Beispiel in einer der Deklarationen eine Ausnahme ausgel¨ost, werden die nachfolgenden Anweisungen also nicht mehr durchgef¨uhrt. In diesem Beispiel wird der Try-Block deshalb sp¨atestens beim Aufruf von
vorname.at(20) = 'B';
Sandini Bib
104
Kapitel 3: Grundkonzepte von C++-Programmen
verlassen, da hier versucht wird, bei vorname auf das Zeichen mit dem Index 20 zuzugreifen, und es dieses Zeichen nicht gibt. Im Gegensatz zum Indexoperator pr¨uft at() bei Strings, ob der Index korrekt ist, und l¨ost gegebenenfalls eine entsprechende Ausnahme aus. Beim Indexoperator wird auf die Pr¨ufung aus Performance-Gr¨unden verzichtet (die nachfolgende Zeile w¨urde also keine Ausnahme ausl¨osen, sondern zu einem Absturz oder einem anderen undefinierten Verhalten f¨uhren). Wie die Reaktion auf eine Ausnahme aussieht, wird durch die Catch-Bl¨ocke definiert, die dem Try-Block folgen. In diesem Fall gibt es drei Catch-Bl¨ocke:
catch (const std::bad_alloc& e) { // Spezielle Ausnahme: Speicherplatz alle
std::cerr << "Speicherplatz alle" << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE;
} catch (const std::exception& e) { // sonstige Standard-Ausnahmen
std::cerr << "Standard-Exception: " << e.what() << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE;
} catch (...) {
// alle (bisher nicht behandelten) Ausnahmen
}
std::cerr << "unerwartete sonstige Exception" << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE;
Damit werden nacheinander Reaktionen auf Ausnahmen vom Typ std::bad_alloc (sie kennzeichnen Speicherplatzmangel), auf Ausnahmen vom Typ std::exception (sie kennzeichnen alle Standard-Ausnahmen) und auf alle sonstigen Ausnahmen definiert. Der letzte Catch-Block zeigt die spezielle M¨oglichkeit, auf beliebige Ausnahmen zu reagieren:
catch (...) { // alle (bisher nicht behandelten) Ausnahmen ... ...
} Eine Folge von drei Punkten in einer Catch-Anweisung steht also f¨ur jede beliebige Ausnahme“. ” Da man u¨ ber die Art der Ausnahme in dem Fall keinerlei Aussage machen kann, kann man hier nur ganz generell reagieren. Diese Reihenfolge der Catch-Bl¨ocke ist kein Zufall. Bei einer Ausnahme in einem Try-Block werden die nachfolgenden Catch-Bl¨ocke in der Reihenfolge, in der sie angegeben werden, nach einer passenden Behandlung durchsucht. Die Anweisungen des ersten passenden Catch-Blocks werden durchgef¨uhrt. Die Reihenfolge der Catch-Bl¨ocke muss also so definiert sein, dass spezielle vor allgemeinen Ausnahmeklassen stehen. Ein Catch-Block fu¨ r beliebige Ausnahmen, der durch
Sandini Bib
3.6 Ausnahmebehandlung
105
catch (...) { ...
} definiert wird, sollte also immer der letzte Catch-Block sein. Innerhalb eines Catch-Blocks k¨onnen beliebige Anweisungen stehen. In diesem Fall wird z.B. jeweils eine Fehlermeldung auf dem Standard-Fehlerausgabekanal cerr ausgegeben und die Funktion main() durch return mit einem Fehlerstatus verlassen:
std::cerr << ... << std::endl; // main() mit Fehlerstatus beenden return EXIT_FAILURE; Die in
catch (const std::exception& e) { ... e.what() ... } Der R¨uckgabewert von what() ist ein implementierungsspezifischer String. Da in diesem Beispiel eine Ausnahme durch den Aufruf von at() ausgel¨ost wird und es sich dabei um eine Standard-Ausnahme vom Typ std::out_of_range handelt, wird genau dieser zweite Block als passende Ausnahmebehandlung gefunden. Die dazu implementierungsspezifische Ausgabe k¨onnte z.B. wie folgt aussehen:
Standard-Exception: pos >= length () Sofern die Funktion nicht im Catch-Block verlassen wird, wird das Programm nach der Behandlung der Ausnahme hinter dem letzten Catch-Block fortgesetzt. Ohne die Return-Anweisungen w¨urde in diesem Beispiel also bei einer Ausnahme noch die folgende Ausgabeanweisung aufgerufen werden. Man beachte, dass die Ausnahmeobjekte im Catch-Block in der Form const typ&“ u¨ ber” geben werden sollten. Dabei handelt es sich um eine so genannte konstante Referenz, die sicherstellt, dass von dem Ausnahmeobjekt keine unn¨otige Kopie angelegt wird. In Abschnitt 4.4 werden die dazugeh¨origen Sprachmittel im Detail erl¨autert. Wie schon angesprochen, werden alle zwischen Erkennung und Behandlung liegenden Bl¨ocke verlassen, und deren angelegten lokalen Objekte zerst¨ort. Handelt es sich um Objekte von Klassen, f¨ur die Operationen zum Aufr¨aumen definiert sind (so genannte Destruktoren), werden diese auch aufgerufen. Das folgende Beispiel verdeutlicht das Szenario noch einmal:
// allg/ehbsp2.cpp char f1 (const std::string s, int idx) { std::string tmp = s; // lokales Objekt, das bei einer Ausnahme
Sandini Bib
106
Kapitel 3: Grundkonzepte von C++-Programmen ...
char c = s.at(idx);
// automatisch zerst¨ort wird // k¨onnte Ausnahme ausl¨osen
...
}
return c;
void foo() { try { std::string s("hallo"); // wird bei einer Ausnahme zerst¨ort f1(s,11); // l¨ost eine Ausnahme aus f2(); // wird bei einer Ausnahme in f1() nicht aufgerufen } catch (...) { std::cerr << "Exception, wir machen aber weiter" << std::endl; } // hier geht es nach der Ausnahme in f1() weiter ...
} Aus foo() heraus wird f1() mit dem String "hallo" und dem Index 11 aufgerufen. In f1() l¨ost dies beim Aufruf von at() eine Ausnahme aus. In dem Fall wird f1() sofort verlassen, wobei das lokale Objekt tmp aufger¨aumt wird. Auch der Try-Block von foo() wird sofort verlassen. f2() wird auf jeden Fall nicht mehr aufgerufen. Existiert ein passender Catch-Block, werden dessen Anweisungen durchgef¨uhrt, und dann wird hinter allen Catch-Bl¨ocken fortgefahren. Existiert kein passender Catch-Block, wird auch foo() sofort verlassen. Dies geschieht so lange, bis ein passender Catch-Block gefunden wird.
3.6.5
Behandlung nicht abgefangener Ausnahmen
In einem kommerziellen Programm sollte es nicht passieren, dass eine Ausnahme nicht abgefangen wird. Falls es doch vorkommt, f¨uhrt eine nicht abgefangene Ausnahme im Allgemeinen zu einem außergew¨ohnlichen Programmabbruch. Konkret wird in diesem Fall die Funktion std::terminate() aufgerufen, die wiederum std::abort() aufruft. Die Funktion std::abort() veranlasst einen außergew¨ohnlichen Programmabbruch in der Art eines Notfalls. Typischerweise werden in Abh¨angigkeit vom Betriebssystem Laufzeitinformationen wie etwa ein Speicherabzug (Core-Dump) ausgegeben (siehe auch Abschnitt 3.9.3). Um zu verhindern, dass std::terminate() die Funktion std::abort() aufruft, kann mit der Funktion std::set_terminate() eine alternative Terminate-Funktion definiert werden. Die bisherige Terminate-Funktion wird jeweils zur¨uckgeliefert. Eine mit set_terminate() u¨ bergebene Terminate-Funktion darf weder Parameter noch einen R¨uckgabewert besitzen.
Sandini Bib
3.6 Ausnahmebehandlung
3.6.6
107
Hilfsfunktionen zur Behandlung von Ausnahmen
In der Praxis ist es h¨aufig sinnvoll, Ausnahmen an verschiedenen Stellen auf die gleiche Art und Weise zu behandeln. Nun ist es aber recht m¨uhsam, jedes Mal die gleichen Catch-Bl¨ocke zu implementieren. Stattdessen sollte man die Behandlung der Ausnahmen in eine Hilfsfunktion auslagern. Es gibt nur ein Problem: Wie u¨ bergibt man die Ausnahmen, die behandelt werden sollen, an diese Funktion? Das Problem ist, dass es keinen gemeinsamen Datentyp fu¨ r alle Ausnahmen gibt. An dieser Stelle hilft ein Trick, der wie folgt aussieht:
// allg/ehbsp3.cpp #include #include #include #include
<string> <exception>
// Headerdatei f¨ur I/O // Headerdatei f¨ur Strings // Headerdatei f¨ur EXIT_FAILURE // Headerdatei f¨ur Ausnahmen
void processException() { try { // zu behandelnde Ausnahme nochmal ausl¨osen, damit sie throw; // hier ausgewertet werden kann
} catch (const std::bad_alloc& e) { // Spezielle Ausnahme: Speicherplatz alle
std::cerr << "Speicherplatz alle" << std::endl;
} catch (const std::exception& e) { // sonstige Standard-Ausnahmen
std::cerr << "Standard-Exception: " << e.what() << std::endl;
} catch (...) {
// alle (bisher nicht behandelten) Ausnahmen
}
}
std::cerr << "unerwartete sonstige Ausnahme" << std::endl;
int main() { try { // zwei Strings anlegen
std::string vorname("bjarne"); // kann std::bad_alloc ausl¨osen std::string nachname("stroustrup"); // kann std::bad_alloc ausl¨osen std::string name;
Sandini Bib
108
Kapitel 3: Grundkonzepte von C++-Programmen // Strings manipulieren
vorname.at(20) = 'B'; nachname[30] = 'S';
// l¨ost std::out_of_range aus // FEHLER: undefiniertes Verhalten
// Strings verketten
name = vorname + " " + nachname; // k¨onnte std::bad_alloc ausl¨osen
} catch (...) {
// alle Ausnahmen in Hilfsfunktion behandeln
} }
processException(); return EXIT_FAILURE;
// main() mit Fehlerstatus beenden
std::cout << "OK, bisher ging alles gut" << std::endl;
F¨ur alle Ausnahmen wird ein gemeinsamer Catch-Block definiert, der zur eigentlichen Behandlung dieser Ausnahme die Hilfsfunktion processException() aufruft:
try { ...
} catch (...) { ...
processException(); ...
} Innerhalb von processException() wird in einem Try-Block throw ohne Parameter aufgerufen. Dies hat zur Folge, dass die Ausnahme, die gerade behandelt wird, erneut ausgel¨ost wird. Damit werden wiederum alle Bl¨ocke verlassen, bis eine passende Catch-Anweisung gefunden wird. Durch die nachfolgenden Catch-Bl¨ocke ist dies noch innerhalb von processException() der Fall.
3.6.7
Zusammenfassung
Ausnahmebehandlung (Exception-Handling) ist ein Sprachmittel zur Behandlung von Fehlern und Ausnahmen, die im Programmverlauf auftreten. Sie bietet die M¨oglichkeit, den normalen Datenfluss von der Fehlerbehandlung zu trennen. Da bei diesem Mechanismus keine Parameter oder R¨uckgabewerte von Funktionen verwendet werden, kann das Konzept insbesondere zur Fehlerbehandlung beim Erzeugen von Objekten eingesetzt werden. Ausnahmen sind Objekte, deren Datentyp in entsprechenden Klassen definiert wird.
Sandini Bib
3.6 Ausnahmebehandlung
109
Wird eine Ausnahme ausgel¨ost, wird ein entsprechendes Objekt erzeugt, und es werden alle u¨ bergeordneten Bl¨ocke geordnet verlassen, bis das Objekt zur Ausnahmebehandlung abgefangen und damit die Ausnahme behandelt wird. Wird eine Ausnahme nicht behandelt, ist das ein Programmfehler, der mit einem außergew¨ohnlichen Abbruch durch std::terminate() und std::abort() behandelt wird. In C++ werden diverse Standard-Ausnahmeklassen definiert. F¨ur diese Ausnahmeobjekte liefert what() einen implementierungsspezifischen String. Mit throw kann man Ausnahmen, die gerade behandelt werden, erneut ausl¨osen. Damit kann man allgemeine Funktionen zur Ausnahmebehandlung implementieren. Ausnahmen sollten im Catch-Block immer in der Form const typ&“ (als konstante Refe” renz) deklariert werden. Wird mit dem Indexoperator auf Strings oder Vektoren zugegriffen, muss man sicherstellen, dass der Index g¨ultig ist. Verwendet man at() zum Zugriff, wird bei einem ung¨ultigen Index eine Ausnahme ausgel¨ost.
Sandini Bib
110
Kapitel 3: Grundkonzepte von C++-Programmen
3.7 Zeiger, Felder und C-Strings Dieses Kapitel stellt die Elemente von C und C++ vor, die maßgeblich dazu beigetragen haben, dass C von einigen als komplizierte Sprache eingestuft wird. Nur wer Zeiger und Felder (Arrays) verstanden hat, wird erfolgreich in C programmieren k¨onnen. In C++ werden diese Sprachmittel nach wie vor angeboten. Vor allem in der Standardbibliothek gibt es aber etliche Komponenten, die die Verwendung von Zeigern und Feldern u¨ berfl¨ussig machen. Durch eine geschickte Programmierung werden die Low-level“-Aspekte von Zeigern ” und Feldern so gekapselt, dass neue h¨ohere Datentypen entstehen, f¨ur deren Verwendung man nichts u¨ ber Zeiger und Felder wissen muss. Das Thema Strings ist ein klassisches Beispiel daf¨ur: In C sind Strings Felder von Zeichen, die mit Zeigern verwaltet werden. Das schafft entsprechende Probleme. In C++ gibt es den Datentyp string, der diese Probleme kapselt und eine intuitive Programmierung mit Strings erm¨oglicht (siehe Abschnitt 3.4). Insofern k¨onnen alle, die die in diesem Kapitel angesprochenen Themen auf die Schnelle nicht verstehen, beruhigt sein. Falls wir im weiteren Verlauf auf Themen dieses Kapitels zur¨uckkommen, wird noch einmal darauf eingegangen.
3.7.1
Zeiger
Wesentlich f¨ur das Verst¨andnis von C ist das Verst¨andnis von Zeigern (auch Pointer genannt). Zeiger sind Verweise auf Daten, die an einer anderen Stelle stehen und auf die sozusagen ge” zeigt“ wird. Physikalisch handelt es sich einfach nur um Adressen von anderen Variablen. Zur Verwaltung von Zeigern dienen die Operatoren & und *:
Der einstellige Operator & liefert die Adresse von einem Datum bzw. einen Zeiger auf ein Datum. Der einstellige Operator * dereferenziert einen Zeiger, was bedeutet, dass er das liefert, worauf der Zeiger zeigt, bzw. das, was an seiner Adresse steht.
Beispiel:
Nach den folgenden Deklarationen
int x = 42; int* xp;
// int // Zeiger auf int
existieren eine Variable x mit dem initialen Wert 42 und ein Zeiger xp mit undefiniertem Wert:
x :
4 2
x p : ?
Sandini Bib
3.7 Zeiger, Felder und C-Strings
111
Mit der Anweisung
xp = &x;
// xp zeigt nun auf x
wird xp die Adresse von x zugewiesen. Damit verweist xp auf x:
x :
4 2
x p :
¨ Damit steht der Ausdruck *xp f¨ur x, da er das liefert, worauf xp zeigt. Uber diesen Zeiger kann x zum Beispiel einen neuen Wert erhalten:
*xp = 7;
// das, worauf xp zeigt, also x, erh¨alt den Wert 7
Damit ergibt sich folgender Zustand:
x :
7
x p :
Entsprechend k¨onnte man den Wert von x mit Hilfe von xp ausgeben:
std::cout << *xp << std::endl;
// das, worauf xp zeigt, ausgeben
Deklaration von Zeigern Zeiger werden deklariert, indem ihrem Namen jeweils ein Stern vorangestellt wird. Da C++ eine formatfreie Sprache ist, gibt es in der Praxis verschiedene M¨oglichkeiten, Zeiger zu definieren. Die folgenden M¨oglichkeiten sind alle a¨ quivalent:
int *xp; int * xp; int*xp; int* xp;
// K&R-Notation
// meine typische Notation
In C wird meistens die erste Form verwendet, die schon durch die Autoren von C, Kernighan und Ritchie, eingef¨uhrt wurde. Ich bevorzuge allerdings wie viele im C++-Umfeld die letztere Notation, was den einen oder anderen Leser vielleicht am Anfang etwas verwirren kann. Sie hat vor allem den Vorteil, dass der Typ (Zeiger auf int) klar vom Variablennamen (xp) getrennt wird. Die Notation hat aber auch einen schwerwiegenden Nachteil: Sie darf nie zur Deklaration mehrerer Zeiger verwendet werden, da bei der Deklaration
int* p1, p2;
// NEIN, keine zwei Zeiger, deshalb vermeiden!
der Stern nur zur ersten Variablen geh¨ort. p1 wird also als Zeiger auf einen int, p2 aber als int deklariert, was durch die K&R-Schreibweise besser deutlich wird:
int *p1, p2;
// Zeiger und einfache Variable
Sandini Bib
112
Kapitel 3: Grundkonzepte von C++-Programmen
Die Konstante NULL Die spezielle Konstante NULL steht f¨ur einen Zeiger, der nirgendwohin zeigt. Das ist im Gegensatz zu einem nicht initialisierten Zeiger, der irgendwohin zeigt, ein definierter Zustand. NULL kann man zuweisen und abfragen:
int* xp = NULL;
// xp ist ein Zeiger, der nirgendwohin zeigt
...
if (xp != NULL) {
// zeigt xp irgendwohin?
...
} Hinter NULL steckt eigentlich der Wert 0, mit dem die Konstante in etlichen Standard-Headerdateien definiert wird. 0 ist der einzige ganzzahlige Wert, der Zeigern zugewiesen werden darf. Man kann statt NULL also einfach den Wert 0 verwenden:
int* xp = 0;
// xp ist Zeiger der nirgendwohin zeigt
...
if (xp != 0) {
// zeigt xp irgendwohin?
...
} Da 0 dem Wert false entspricht und jeder andere ganzzahlige Wert true entspricht, kann man Zeiger auch logisch testen:
if (xp) {
// zeigt xp irgendwohin?
...
} ...
if (!xp) {
// zeigt xp nirgendwohin?
...
}
3.7.2
Felder
Ein Feld (Array) ist eine Menge mehrerer Elemente gleichen Typs, die hintereinander angeordnet werden. Beim Anlegen eines Feldes muss die Anzahl der Elemente angegeben werden. Der Zugriff erfolgt mit dem Operator [ ]:
int werte[10];
// Feld von zehn ints
werte[0] = 77; werte[9] = werte[0];
// erstes Element initialisieren // letztes Element erh¨alt Wert vom ersten Element
for (int i=0; i<10; ++i) { werte[i] = 42; }
// alle Werte initialisierten
Sandini Bib
3.7 Zeiger, Felder und C-Strings
113
Der Index-Bereich geht immer von 0 bis gr¨oße-1. Ein Programmierer muss dabei selbst darauf achten, dass auf Felder nicht mit einem unerlaubten Index zugegriffen wird. Im Programm wird es aus Performance-Gr¨unden nicht gepr¨uft. Das Ergebnis eines unerlaubten Zugriffs ist nicht definiert und h¨angt vom Zufall ab. Felder und Zeiger Man kann auf Felder u¨ ber Zeiger zugreifen. In dieser M¨oglichkeit spiegelt sich der Sachverhalt wider, dass eine Feld-Variable intern im Programm eigentlich ein Zeiger auf das erste Element ist (also die Adresse des ersten Elements in dem Feld enth¨alt). Durch die Deklaration
int werte[10];
// Feld von zehn ints
wird also der in Abbildung 3.12 dargestellte Zustand aufgebaut. w e r t e :
Abbildung 3.12: Zustand nach Anlegen des Feldes werte[ ]
Insofern kann man auf Felder auch mit Zeigerschreibweisen zugreifen:
int* wp = werte; *werte = 88;
// entspricht: wp = &werte[0] // ver¨andert werte[0]
Durch die Zuweisung von werte an wp wird wp die Adresse des ersten Elements im Feld zugewiesen. wp zeigt damit auf werte[0]. Durch die Zuweisung von 88 an das, worauf werte zeigt, wird werte[0] also 88 zugewiesen. Zeigerarithmetik Die Analogie zwischen Feldern und Zeigern geht sogar so weit, dass man Zeiger u¨ ber alle Elemente eines Feldes wandern lassen kann. Dazu ist es m¨oglich, f¨ur Zeiger arithmetische Operationen aufzurufen:
Addiert man zu einem Zeiger einen ganzzahligen Wert n, wird der Zeiger n Elemente weitergesetzt. Entsprechend setzt der Operator ++ einen Zeiger ein Element weiter. Bildet man die Summe aus einem Zeiger und einem ganzzahligen Wert n, erh¨alt man einen Zeiger auf den n-ten Wert hinter dem Zeiger. Subtrahiert man zwei Zeiger, erh¨alt man den Abstand der Elemente, auf die sie zeigen.
Damit kann man folgende Schleife schreiben, die alle Elemente eines Feldes ausgibt:
int werte[10];
// Feld von zehn ints
Sandini Bib
114
Kapitel 3: Grundkonzepte von C++-Programmen
for (int* p=werte; p<werte+10; ++p) { std::cout << *p << std::endl; } Am Anfang wird p als Zeiger auf das erste Element von werte initialisiert. Solange dieser Zeiger kleiner als ein um zehn Elemente weiter gesetzter Zeiger ist, wird auf das aktuelle Element mit *p zugegriffen und der Zeiger mit ++p jeweils ein Element weiter gesetzt (siehe Abbildung 3.13). w e r t e :
w e r t e + 1 0 :
. . . p :
¨ Abbildung 3.13: Uber das Feld werte[ ] wandernder Zeiger p
Bildet man innerhalb der Schleife die Differenz von p und werte , bekommt man den Abstand der Elemente, was dem aktuellen Index entspricht:
std::cout << "Index: " << p-werte << " Wert: " << *p << std::endl; Durch diese Regeln ist es absolut gleichwertig, ob man auf Elemente eines Feldes mit dem Indexoperator oder mit der Zeiger-Schreibweise zugreift Statt
werte[5] kann man z.B. auch mit
*(werte+5) auf das sechste Element des Feldes werte zugreifen. Zeiger und Iteratoren Kommt Ihnen die Art und Weise, wie Zeiger u¨ ber Felder wandern k¨onnen, bekannt vor? Richtig, das Interface entspricht dem Iterator-Interface (siehe Seite 78). Dies ist kein Zufall. Beim Design der STL hat man das Verhalten von Feldern und Zeigern abstrahiert: Mit dem gleichen Interface, mit dem Zeiger auf Felder zugreifen k¨onnen, k¨onnen Iteratoren auf Container zugreifen. Man kann Iteratoren deshalb auch als schlaue Zeiger“ (englisch: smart pointer“, siehe ” ” Abschnitt 9.2.1) bezeichnen. Sie verhalten sich nach außen wie Zeiger, sind aber so intelligent, die Operationen passend f¨ur die Datenstruktur, auf der sie operieren, umzusetzen.
Sandini Bib
3.7 Zeiger, Felder und C-Strings
3.7.3
115
C-Strings
Die Standardtypen von C++ f¨ur Strings kapseln die von C u¨ bernommene Art und Weise, mit Strings zu operieren. In C werden Strings als Felder von Zeichen verwaltet. Als Endekennung besitzen C-Strings das Zeichen '\0' (dahinter steckt auch wieder der Wert 0). Die L¨ange eines Strings ist also die Anzahl der Zeichen bis zum Auftreten von '\0'. Entsprechend sind auch in C++ String-Literale Felder von Zeichen, die mit '\0' abgeschlossen werden. Die Stringkonstante
"hallo" wird intern wie in Abbildung 3.14 dargestellt abgelegt. " h a l l o " :
' h '
' a '
' l '
' l '
' o '
' \ 0 '
Abbildung 3.14: Interne Repr¨asentation des String-Literals "hallo"
Entsprechend ist der Datentyp eines String-Literals const char*, also ein Zeiger auf nicht a¨ nderbare Zeichen. Als Konzession an die Tatsache, dass viele C-Funktionen const nicht ver¨ der Zeichen wenden, darf man ein String-Literal auch einfach als char* verwenden. Ein Andern ist aber auch in diesem Fall nicht erlaubt. Wenn man in C++ Strings verwendet, sieht man von dieser Tatsache nichts mehr, da der Datentyp string die Tatsache kapselt, dass es sich eigentlich um Felder von Zeichen handelt. Durch die Deklaration:
std::string s = "hallo"; wird genau genommen ein Objekt des Standarddatentyps string mit einem Wert vom Datentyp const char* initialisiert. Statt dessen kann man deshalb auch folgendes schreiben:
std::string s("hallo"); Bleibt man allerdings beim Datentyp const char*, kann man auf alle Zeichen eines C-Strings zugreifen, indem man einen Zeiger u¨ ber diese Zeichen wandern l¨asst: // Schleife, die alle Zeichen eines C-Strings s auf einer eigenen Zeile ausgibt
const char* s = "hallo";
for (const char* p=s; *p != '\0'; ++p) { std::cout << *p << std::endl; }
Sandini Bib
116
Kapitel 3: Grundkonzepte von C++-Programmen
Am Anfang wird p der C-String s zugewiesen, was bedeutet, dass p auf das erste Zeichen vom String zeigt. Solange p nicht auf das Stringendezeichen '\0' zeigt, werden jeweils die Anweisungen im Schleifenk¨orper durchgef¨uhrt und der Zeiger wird mit ++p um ein Zeichen weitergesetzt. Problematik von C-Strings Ohne die Verwendung eines Datentyps, der C-Strings kapselt, wird man mit der gesamten Problematik von Feldern und Zeigern konfrontiert. Durch die Verwaltung als Feld k¨onnen Strings nicht wie fundamentale Datentypen behandelt werden. Wird ein String einem anderen String mit Hilfe des Zuweisungsoperators zugewiesen, werden n¨amlich nicht die Elemente, sondern die Adressen kopiert (Felder sind schließlich eigentlich Zeiger auf das jeweils erste Element, und diese Zeiger werden einander zugewiesen). Werden z.B. zwei Strings wie folgt deklariert:
const char* s = "hallo"; const char* t = "Welt"; ergibt sich folgender Zustand: s :
t :
' h '
' a '
' l '
' l '
' o '
' W '
' e '
' l '
' t '
' \ 0 '
' \ 0 '
Mit der Zuweisung
s = t;
// VORSICHT: Zeiger werden zugewiesen
wird daraus: s :
t :
' h '
' a '
' l '
' l '
' o '
' W '
' e '
' l '
' t '
' \ 0 '
' \ 0 '
Es werden also nicht die Zeichen, sondern nur die Zeiger (Adressen der C-Strings) zugewiesen. s und t werden damit ein und derselbe C-String. Um wirklich die C-Strings zu kopieren, m¨ussen die einzelnen Zeichen kopiert werden. Hinzu kommt, dass der C-Programmierer immer selbst darauf achten muss, dass gen¨ugend Speicherplatz f¨ur die Strings vorhanden ist. Aus diesem Grund sehen C-Programme, die mit Strings operieren, auch immer recht grausam aus. Zum Beispiel:
Sandini Bib
3.7 Zeiger, Felder und C-Strings
117
// allg/cstring.cpp // C-Headerdatei f¨ur I/O
#include <stdio.h> // C-Headerdatei f¨ur die String-Behandlung
#include <string.h> void f() { const char* k = "Eingabe: "; // String-Konstante char text[81]; // String-Variable f¨ur 80 Zeichen char s[81]; // String-Variable f¨ur die Eingabe (bis 80 Zeichen) /* String s einlesen * - aus Platzgr¨unden nicht mehr als 80 Zeichen */
if (scanf ("%80s", s) != 1) { // Einlesefehler ...
} // String mit Leerstring vergleichen
if (strcmp(s,"") == 0) { /* String-Literal an String text zuweisen * - VORSICHT: text muss ausreichend groß sein */ strcpy (text, "keine Eingabe"); } else { /* String-Konstante k, gefolgt von eingelesenem String, * an text zuweisen * - VORSICHT: text muss ausreichend groß sein */ if (strlen(k)+strlen(s) <= 80) { strcpy (text, k); strcat (text, s); } } ...
}
Sandini Bib
118
Kapitel 3: Grundkonzepte von C++-Programmen
Die Probleme, die schon dieses kleine Programm aufzeigt, sind allen C-Programmierern bekannt:
F¨ur eine Zuweisung muss die Funktion strcpy() verwendet werden. Dabei muss der Programmierer sicherstellen, dass der Speicherplatz des Strings, dem ein neuer Wert zugewiesen wird, groß genug ist. Zum Anh¨angen eines Strings an einen anderen dient die Funktion strcat(), wobei auch hier f¨ur ausreichend Speicherplatz zu sorgen ist. Zum Vergleichen zweier Strings muss die Funktion strcmp() verwendet werden. Beim Einlesen eines Strings muss angegeben werden, dass nur f¨ur eine bestimmte Anzahl von Zeichen Platz vorhanden ist.
Die String-Verarbeitung ist in C also nicht nur umst¨andlich, sondern birgt auch erhebliche Gefahren. Man muss f¨ur alle Operationen spezielle Funktionen aufrufen (die wichtigsten werden in Tabelle 3.12 aufgelistet) und sich selbst um die Speicherverwaltung k¨ummern. Funktion
strlen() strcpy() strncpy() strcat() strncat() strcmp() strncmp() strchr()
Bedeutung liefert die Anzahl der Zeichen in einem C-String weist einen C-String einem anderen zu weist bis zu n Zeichen eines C-Strings einem anderen zu h¨angt einen C-String an einen anderen an h¨angt bis zu n Zeichen eines C-Strings an einen anderen an vergleicht zwei C-Strings vergleicht bis zu n Zeichen von zwei C-Strings sucht in einem C-String ein bestimmtes Zeichen Tabelle 3.12: Die wichtigsten Funktionen f¨ur C-Strings
All diese Probleme sind mit C++ behoben. Durch Verwendung des in Abschnitt 3.4 eingef¨uhrten Datentyps string kann man Standardoperatoren verwenden und braucht sich u¨ ber die Speicherverwaltung keine Gedanken zu machen. Das obige C-Programm w¨urde in C++ wie folgt aussehen:
// allg/string2.cpp #include #include <string>
// C++-Headerdatei f¨ur I/O // C++-Headerdatei f¨ur Strings
int main () { const std::string k = "Eingabe: "; // String-Konstante std::string text; // String-Variable std::string s; // String-Variable f¨ur Eingabe // String s einlesen if (! (std::cin >> s)) {
Sandini Bib
3.7 Zeiger, Felder und C-Strings
119
// Einlesefehler ...
} // String mit Leerstring vergleichen
if (s == "") {
// String-Literal an String text zuweisen
text = "keine Eingabe";
} else { /* String-Konstante k, gefolgt von eingelesenem String, * an text zuweisen */ text = k + s; } ...
} Entsprechend kompliziert ist die Verwaltung von dynamischen Feldern in C. Auch hier bietet C++ den Vorteil, dass die Standardbibliothek mit den STL-Containern Klassen zur Verf¨ugung stellt, die diese Problematik wegkapseln.
3.7.4
Zusammenfassung
Zeiger sind Variablen, die auf andere Variablen verweisen. Sie enthalten somit die Adressen dieser Variablen. Zur Operation mit Zeigern dienen die einstelligen Operatoren * und &. Hat ein Zeiger den Wert 0 oder NULL, verweist er nirgendwohin. Das ist etwas anderes als ein nicht initialisierter Zeiger, der irgendwohin zeigt. Felder werden mit eckigen Klammern angelegt. Ihr Index-Bereich erstreckt sich von 0 bis gr¨oße-1. Der Datentyp einer Feld-Variablen entspricht einem Zeiger auf das erste Element. Zeiger k¨onnen durch arithmetische Operatoren u¨ ber Felder wandern. C-Strings sind Felder von Zeichen. Die Problematik von C-Strings wird mit Hilfe des C++-Typs f¨ur Strings, string, beseitigt.
Sandini Bib
120
Kapitel 3: Grundkonzepte von C++-Programmen
3.8 Freispeicherverwaltung mit new und delete Dieser Abschnitt f¨uhrt in das Konzept der Freispeicherverwaltung in C++ ein. Dabei werden die in C++ daf¨ur vorhandenen Operatoren new und delete vorgestellt, und ihre Anwendung wird erl¨autert. Dazu wird auch auf die M¨oglichkeit eingegangen, ihr Verhalten zu u¨ berwachen und neu zu definieren. Normalerweise besitzen Variablen einen klar abgegrenzten G¨ultigkeitsbereich. Sobald der durch geschweifte Klammern definierte Block, in dem sie deklariert werden, verlassen wird, werden die Variablen ung¨ultig. Damit wird der dazugeh¨orige Speicherplatz automatisch freigegeben, und die darin befindlichen Daten werden ung¨ultig. Handelt es sich bei den Variablen um komplexe Objekte, werden die dazugeh¨origen Aufr¨aumarbeiten durchgef¨uhrt (steht die Variable z.B. f¨ur eine ge¨offnete Datei, wird sie automatisch geschlossen). Mitunter ist es aber notwendig, dass Variablen oder Objekte u¨ ber Blockgrenzen hinaus g¨ultig bleiben. Eine globale Variable hilft da nicht unbedingt weiter, da sie zum Programmstart initialisiert wird. Es kann aber sein, dass die Daten zur Initialisierung zu diesem Zeitpunkt noch gar nicht vorliegen. Es wird also eine M¨oglichkeit gebraucht, zur Laufzeit explizit Objekte anzulegen und sp¨ater explizit wieder zu zerst¨oren. Dazu geh¨ort insbesondere auch die M¨oglichkeit, Speicherplatz f¨ur diese Objekte anzufordern und freizugeben. Die Operatoren zur Freispeicherverwaltung F¨ur die dynamische Speicherverwaltung und das explizite Erzeugen und Zerst¨oren von Objekten existieren in C++ die Operatoren new und delete. Mit new wird Speicherplatz angefordert und ein neues Objekt explizit angelegt; mit delete wird ein solches Objekt zerst¨ort und Speicherplatz freigegeben. Die mit new und delete verwalteten Objekte k¨onnen sowohl zu einer Klasse geh¨oren als auch einen fundamentalen Datentyp besitzen. Genauso ist es m¨oglich, Felder (Arrays) von Objekten beliebiger Art anzulegen. Abgrenzung zu C-Funktionen Die neuen Operatoren ersetzen die in C bekannten Speicherplatzfunktionen malloc() und
free(). Dies hat mehrere Vorteile:
Als Operatoren sind new und delete Teil der Sprache C++ und geh¨oren nicht zu einer Standardumgebung. Aus diesem Grund kann der Typ der anzulegenden Objekte als Operand direkt angegeben werden und muss nicht als Parameter geklammert und mit sizeof versehen werden. Außerdem liefert new im Gegensatz zu malloc() immer einen Zeiger auf den richtigen Typ zur¨uck. Sein R¨uckgabewert muss also nicht erst explizit in den richtigen Typ umgewandelt werden. Sofern mit new Objekte erzeugt werden, werden diese vollst¨andig initialisiert. Bei der Anforderung von Speicherplatz f¨ur fundamentale Datentypen gilt allerdings weiterhin wie bei lokalen Variablen, dass der Speicherplatz nicht initialisiert ist.
Sandini Bib
3.8 Freispeicherverwaltung mit new und delete
121
Der R¨uckgabewert muss nicht unbedingt auf NULL getestet werden, da f¨ur den Fall, dass es nicht m¨oglich ist, Speicherplatz oder Objekte anzulegen, eine separate Fehlerbehandlung mit Hilfe von Ausnahmen angestoßen wird. Schließlich kann der Operator new f¨ur eigene Datentypen selbst implementiert werden. Dadurch ist eine beliebige Optimierung bei der Speicherverwaltung m¨oglich.
Durch diese Vorteile kann viel Code gespart werden. Aus C-Code wie
personPointer = (Person*) malloc (sizeof(Person)); if (personPointer == NULL) { // FEHLER ...
} initPerson(personPointer,"Nicolai","Josuttis"); wird in C++ einfach nur noch:
personPointer = new Person("Nicolai","Josuttis"); Man gibt in C++ also nur noch an, was f¨ur ein Objekt man auf dem Freispeicher anlegen will, und u¨ bergibt optional gleich die Parameter zur Initialisierung.
3.8.1
Der Operator new
Mit dem Operator new kann explizit Speicherplatz f¨ur ein Objekt eines bestimmten Typs angelegt werden. Als Operand hinter new wird einfach der Typ des Objekts angegeben, das explizit angelegt werden soll. Zur¨uckgeliefert wird ein Zeiger auf dieses Objekt:
float* fp = new float; // legt einen Gleitkommawert an std::string* sp = new std::string; // legt einen String an Sofern es sich bei dem Typ um eine Klasse handelt, wird das angelegte Objekt auch gleich initialisiert (dazu wird ein so genannter Konstruktor der Klasse aufgerufen). Zu diesem Zweck k¨onnen in Klammern Argumente u¨ bergeben werden:
std::string* sp1; // initialisiert den String mit dem Default-Wert sp1 = new std::string; std::string* sp2; sp2 = new std::string("hallo"); // initialisiert den String mit "hallo"
3.8.2
Der Operator delete
Der Operator delete dient dazu, mit new angeforderten Speicherplatz wieder freizugeben. Als Operand muss der von new zur¨uckgelieferte Zeiger auf das Objekt (die Adresse des Objekts) u¨ bergeben werden:
float* fp = new float; std::string* sp = new std::string; ...
// Gleitkommawert anlegen // String anlegen
Sandini Bib
122
Kapitel 3: Grundkonzepte von C++-Programmen
delete fp; delete sp;
// Gleitkommawert freigeben // String freigeben
std::string* p1 = new std::string("hallo"); // String anlegen (p1 zeigt darauf) ...
std::string* p2 = p1;
// p2 zeigt nun auch darauf
...
delete p2;
// String freigeben
Der Aufruf von delete f¨ur ein nicht mit new erzeugtes Objekt ist nicht definiert und kann beliebig fatale Folgen haben. Insbesondere ist es falsch, einen mit malloc() angelegten Speicherplatz mit delete bzw. einen mit new angelegten Speicherplatz mit free() freizugeben. Dies funktioniert zwar manchmal, da new und delete oft u¨ ber malloc() und free() implementiert werden – ein solches Programm ist allerdings nicht mehr portabel, da new und delete auch beliebig anders definiert sein k¨onnen. Ein delete f¨ur 0 (bzw. NULL) ist erlaubt und hat keinen Effekt. Auf diese Weise kann beim Aufr¨aumen ein delete f¨ur einen mit NULL initialisierten Zeiger aufgerufen werden, unabh¨angig davon, ob er in der Zwischenzeit auf mit new angelegten Speicherplatz zeigt:
Person* feld = NULL; ...
if (bedingung-die-vielleicht-erf¨ullt-ist) { feld = new Person; } ...
delete feld;
// OK
Man beachte, dass lokale Zeiger ohne explizite Initialisierung keinen definierten Startwert besitzen. Ohne die explizite Initialisierung von feld mit NULL w¨are das Beispiel also fehlerhaft, da mit delete bei nicht erf¨ullter Bedingung f¨ur irgendeine in feld stehende Adresse aufgerufen wird.
3.8.3
Dynamische Speicherverwaltung fur ¨ Felder
Durch Verwendung von Feld-Klammern k¨onnen auch Felder von Objekten angelegt und wieder freigegeben werden. Beim Anlegen mit new[] wird ein ebenfalls Zeiger auf das erste Objekt zur¨uckgeliefert:
char* s = new char[len+1]; // len+1 chars std::string* strings = new std::string[42]; // 42 Strings float** werte = new float*[10]; // 10 Zeiger auf floats Auch in diesem Fall werden damit angelegte Objekte automatisch initialisiert. Die 42 Strings werden also alle mit ihrem Default-Wert, dem Leerstring, initialisiert. Fundamentale Datenty-
Sandini Bib
3.8 Freispeicherverwaltung mit new und delete
123
pen (chars, floats usw.) erhalten aber wie u¨ blich einen undefinierten Startwert. Bei mit new angelegten Feldern ist es nicht m¨oglich, Argumente zur Initialisierung zu u¨ bergeben. F¨ur die Freigabe von Feldern gibt es eine eigene Syntax des Operators delete, die verlangt, dass ebenfalls Feld-Klammern angegeben werden m¨ussen:
char* s = new char[len+1]; // len+1 chars std::string* strings = new std::string[42]; // 42 Strings float** werte = new float*[10]; // 10 Zeiger auf floats ...
delete [] s; delete [] strings; delete [] werte;
// chars freigeben // Strings freigeben // float-Zeiger freigeben
Ein Programmierer muss selbst darauf achten, ob ein Zeiger auf ein einzelnes Objekt oder ein Feld von Objekten zeigt, um die richtige Syntax zur Freigabe verwenden zu k¨onnen:
void freigeben (std::string* sp) { // Was ist gemeint?:
}
delete sp; delete [] sp;
// bei: sp = new std::string // bei: sp = new std::string[10]
Wird die falsche Syntax verwendet, so ist der Effekt nicht definiert. Diese etwas unsch¨one Eigenschaft der Sprache liegt darin begr¨undet, dass in C++ anhand des Datentyps nicht zwischen einem Zeiger auf ein Objekt und einem Feld von Objekten unterschieden werden kann (eine Folge der Kompatibilit¨at zu C). Die Unterscheidung ist aber wesentlich, da unter Umst¨anden verschiedene Operationen aufgerufen werden. Hinter dem Aufruf von delete bzw. delete[] stecken n¨amlich unterschiedliche Funktionen, die sogar durch eigene Funktionen ersetzt werden k¨onnen (darauf wird in Abschnitt 9.3.4 eingegangen). Da der Compiler also weder anhand des Datentyps noch am Kontext entscheiden kann, ob ein einzelnes Objekt oder ein Feld von Objekten zerst¨ort werden soll (new und delete k¨onnen in v¨ollig verschiedenen Modulen u¨ bersetzt werden), muss dies vom Programmierer angegeben werden. Mehrdimensionale Felder Mit new k¨onnen auch mehrdimensionale Felder angelegt werden: Auch hier wird beim Anlegen ein Zeiger auf das erste Objekt zur¨uckgeliefert:
std::string(*zweidim)[7] = new std::string[10][7];
// 10*7 Strings
...
delete [] zweidim;
// Strings freigeben
Durch den Aufruf von new werden 70 Strings angelegt und jeweils mit dem Default-Wert der Klasse initialisiert. Die Delete-Anweisung gibt diese Strings wieder frei. Man beachte, dass durch
std::string(*zweidim)[7] = new std::string[anz][7]; // anz mal (7 Strings)
Sandini Bib
124
Kapitel 3: Grundkonzepte von C++-Programmen
vom Datentyp her ein Zeiger auf anz Elemente vom Typ Feld von sieben Strings“ geliefert wird. ” Dies ist nicht das gleiche wie ein Feld von anz mal sieben Strings:
std::string* p = new std::string[anz*7];
// (anz * 7) Strings
Aufgrund der Tatsache, dass jede weitere Dimension Teil des zur¨uckgeliefertyen Datentyps ist, m¨ussen alle bis auf die erste Dimension als konstante Werte u¨ bergeben werden:
new std::string[anz][7]; new std::string[10][anz]
// OK // FEHLER
Wegen der komplizierten Datentypen bei mehrdimensionalen Feldern verwendet man in der Regel eindimensionale Felder und verwaltet die Dimensionen selbst. Felder ohne Elemente Es ist erlaubt, mit new Felder mit null Elementen anzulegen. Damit muss f¨ur den Fall, dass dynamische Felder auch leer sein k¨onnen, keine Sonderbehandlung durchgef¨uhrt werden:
int size = 0; ... // size kann immer noch 0 sein
int* zahlen = new int[size]; ...
delete [] zahlen;
3.8.4
// OK
Fehlerbehandlung fur ¨ new
Der Aufruf von new kann prinzipiell immer fehlschlagen, da es zumindest bei herk¨ommlichen Multiuser-Betriebssystemen immer passieren kann, dass kein Speicherplatz mehr zur Verf¨ugung steht. Kann new keinen Speicherplatz bekommen, wird ein so genannter New-Handler aufgerufen. Der Default-New-Handler l¨ost im Rahmen der in C++ u¨ blichen Fehlerbehandlung eine so genannte Ausnahme vom Typ std::bad_alloc aus. In dieses Default-Verhalten kann man sich auf vielf¨altige Art und Weise einmischen: 1. Man kann einen derartigen Fehler im Programm im Rahmen des Konzepts der Ausnahmebehandlung abfangen und behandeln. Darauf wird in Abschnitt 3.6.4 eingegangen. 2. Man kann eigene New-Handler installieren, die z.B. zun¨achst versuchen, weiteren Speicher anzufordern oder mit einer entsprechenden Warnung den Reserve-Speicher“ anzubrechen. ” Darauf wird in Abschnitt 9.3.3 eingegangen. 3. Man kann new so aufrufen, dass im Fehlerfall kein New-Handler aufgerufen wird und stattdessen NULL zur¨uckgeliefert wird. Darauf wird in Abschnitt 9.3.1 eingegangen. 4. Man kann den Operator new anders implementieren. Darauf wird in Abschnitt 9.3.4 eingegangen. Man beachte, dass ein Mangel an Speicherplatz in der Regel ein derart fundamentales Problem darstellt, dass es ohnehin nicht mehr sinnvoll ist, das Programm weiterlaufen zu lassen. Inso-
Sandini Bib
3.8 Freispeicherverwaltung mit new und delete
125
fern wird in der Praxis meistens im Rahmen der Ausnahmebehandlung eine globale Reaktion definiert, die das Programm mit einer geeigneten Fehlermeldung beendet (siehe z.B. Seite 101).
3.8.5
Zusammenfassung
Zur Speicherverwaltung wurden in C++ die Operatoren new und delete eingef¨uhrt.
Kann mit new kein Speicherplatz angelegt werden, wird im Normalfall eine Ausnahme vom Typ std::bad_alloc ausgel¨ost. Programme sollten Ausnahmen diesen Typs entsprechend auswerten.
F¨ur Felder m¨ussen new und delete jeweils mit eckigen Klammern aufgerufen werden. Die Speicherverwaltung von C++ mit new und delete sollte nie mit den C-Funktionen malloc(), free() usw. durcheinander gew¨urfelt werden.
Sandini Bib
126
Kapitel 3: Grundkonzepte von C++-Programmen
3.9 Kommunikation mit der Außenwelt Zum Abschluss der Sicht der C++-Anwendungsprogrammierer werden in diesem Kapitel M¨oglichkeiten vorgestellt, mit der Aufrufumgebung zu kommunizieren. Dazu geh¨oren Argumente aus dem Programmaufruf, Umgebungsvariablen, das Starten anderer Programme und R¨uckmeldungen mit Exit-Codes. Auch hierbei handelt es sich um Konzepte und Schnittstellen, die im Wesentlichen von C u¨ bernommen wurden. Deshalb werden dort leider nicht die h¨oheren Datentypen von C++, wie string, verwendet.
3.9.1
Argumente aus dem Programmaufruf
Die Funktion main() kann auf zweierlei Arten deklariert werden:
einmal ohne Parameter:
int main() { ...
}
zum anderen mit zwei Parametern:
int main (int argc, char* argv[]) { ...
} Im zweiten Fall werden in argv die Argumente aus dem Aufruf des Programms als Feld (Array) von C-Strings u¨ bergeben. In argc steht die Anzahl der Elemente in diesem Feld. Das Feld enth¨alt als erstes Element immer den Programmnamen; die weiteren Elemente sind ProgrammParameter. Ein einfaches Beispiel f¨ur die Auswertung zeigt folgendes Programm:
// allg/argv.cpp #include #include <string>
// C++-Headerdatei f¨ur Ein-/Ausgaben // C++-Headerdatei f¨ur Strings
int main (int argc, char* argv[]) { // Programmname und Anzahl der Parameter ausgeben
std::string progname = argv[0]; if (argc > 1) { std::cout << progname << " hat " << argc-1 << " Parameter: " << std::endl; } else {
Sandini Bib
3.9 Kommunikation mit der Außenwelt
}
127
std::cout << progname << " wurde ohne Parameter aufgerufen" << std::endl;
// Programmparameter ausgeben
}
for (int i=1; i<argc; ++i) { std::cout << "argv[" << i << "]: " << argv[i] << std::endl; }
Wird das Programm nur unter dem Namen argv und ohne Parameter aufgerufen, lautet die Ausgabe:
argv wurde ohne Parameter aufgerufen Wird das Programm wie folgt aufgerufen:
argv hallo "zwei Worte" 42 lautet die Ausgabe:
argv hat argv[1]: argv[2]: argv[3]:
3.9.2
3 Parameter: hallo zwei Worte 42
Zugriff auf Umgebungsvariablen
Mit der in bzw. <stdlib.h> definierten Funktion getenv() kann der Wert von Umgebungsvariablen abgefragt werden:
#include string path; const char* path_cstr = std::getenv("PATH"); if (path_cstr == NULL) { ...
} else { path = path_cstr; } Zur¨uckgeliefert wird entweder der Wert der Umgebungsvariablen als C-String oder NULL. Man beachte, dass man NULL nicht einfach C++-Strings zuweisen kann. Aus diesem Grund muss der R¨uckgabewert gepr¨uft werden, bevor er als C++-String verwendet wird.
Sandini Bib
128
3.9.3
Kapitel 3: Grundkonzepte von C++-Programmen
Abbruch von Programmen
Ein C++-Programm wird entweder durch Verlassen von main() oder durch Aufruf einer Funktion zum Abbruch des Programms beendet. Drei von C u¨ bernommene Funktionen, die in bzw. <stdlib.h> deklariert werden, sind in diesem Zusammenhang wichtig (siehe Tabelle 3.13): Man kann ein C++-Programm jederzeit durch Aufruf von exit() oder abort() abbrechen. Außerdem kann man mit atexit() Funktionen registrieren, die bei einem Programmende automatisch aufgerufen werden. Funktion
exit() atexit() abort()
Bedeutung bricht ein Programm geordnet“ ab ” installiert eine Funktion, die beim Programmende aufgerufen wird bricht ein Programm ungeordnet“ ab ” Tabelle 3.13: Funktionen zum Programmende
Mit exit() kann man ein C++-Programm zur Laufzeit abbrechen. Es handelt es sich dabei um kein geordnetes Programmende, sondern um einen Abbruch mitten im Programm. Gewisse Aufr¨aumarbeiten werden zwar noch durchgef¨uhrt (z.B. Ausgabepuffer geleert), jedoch k¨onnen z.B. angelegte tempor¨are Dateien erhalten bleiben. Genau genommen werden alle globalen Objekte aufger¨aumt, lokale Objekte jedoch nicht. Dies kann zu unerw¨unschten Effekten f¨uhren, weshalb man auch den Aufruf von exit() nach M¨oglichkeit vermeiden sollte. exit() erh¨alt als Parameter einen ganzzahligen Wert, der an die Aufrufumgebung durchgereicht wird und mit dem angezeigt werden kann, ob das Programm erfolgreich lief (der so genannte Exit-Code). Diese Werte entsprechen den R¨uckgabewerten von main(). Wird 0 oder die Konstante EXIT_SUCCESS u¨ bergeben, handelt es sich um ein erfolgreiches“ Programmende. ” Die Konstante EXIT_FAILURE kennzeichnet ein nicht erfolgreiches“ Programmende: ”
#include
if (massives-problem) { std::exit(EXIT_FAILURE); }
// Abbruch mit teilweisem Aufr¨aumen
EXIT_SUCCESS und EXIT_FAILURE k¨onnen auch als R¨uckgabewerte von main() verwendet werden. Vor allem die Verwendung von EXIT_FAILURE ist sinnvoll, da damit deutlich gekennzeichnet wird, dass etwas nicht geklappt hat:
int main() { ...
if (fehler) { return EXIT_FAILURE; } ...
}
// Programm mit Fehlerstatus beenden
Sandini Bib
3.9 Kommunikation mit der Außenwelt
129
Mit atexit() kann eine Funktion definiert werden, die automatisch unmittelbar vor einem Programmende durch exit() aufgerufen wird. Dies wird typischerweise dazu genutzt, um ein Programm im Fehlerfall geordnet zu beenden. In der installierten Funktion k¨onnen z.B. ge¨offnete Dateien geschlossen, Puffer geleert und Verbindungen zu anderen Prozessen geordnet beendet werden. Diese Funktionen werden nicht nur bei einer Beendigung mit exit() sondern auch bei einem regul¨aren Ende von main() aufgerufen. abort() dient dazu, ein Programm bei einem fatalen Fehler sofort ohne jedes weitere Aufr¨aumen abzubrechen. Die genaue Reaktion ist systemspezifisch. Typisch ist das Erzeugen eines Speicherabzugs (Core-Dumps). abort() wird ohne Parameter aufgerufen:
#include if (jede-weitere-operation-ist-nicht-mehr-sinnvoll) { // Abbruch mit Speicherabzug std::abort(); }
3.9.4
Aufruf von weiteren Programmen
Mit der in bzw. <stdlib.h> definierten Funktion system() kann ein anderes Programm gestartet werden. Der Name wird als C-String u¨ bergeben. Zur¨uckgeliefert wird der ExitStatus des Programms:
#include int status; if (ist-unix-system) { status = std::system("ls -l"); } else { status = std::system("dir"); }
// Dateien unter UNIX auflisten
// Dateien unter Windows auflisten
if (status == EXIT_SUCCESS) { ...
} Zur¨uckgeliefert wird der Exit-Code des aufgerufenen Programms bzw. ein Fehler-Code, wenn das Programm gar nicht aufgerufen werden konnte.
3.9.5
Zusammenfassung
Einem C++-Programm k¨onnen Argumente u¨ bergeben werden, die als Parameter von main() ausgewertet werden k¨onnen. Mit getenv() k¨onnen Werte von Umgebungsvariablen ausgewertet werden.
Sandini Bib
130
Kapitel 3: Grundkonzepte von C++-Programmen
Mit exit() und abort() k¨onnen Programme abgebrochen werden. Diese Funktionen sollten im Normalfall vermieden werden. Mit system() k¨onnen weitere Programme aufgerufen werden. Hat ein C-String den Wert NULL, kann man ihn nicht einfach einem string zuweisen. Der Wert muss gesondert behandelt werden.
Sandini Bib
Kapitel 4
Programmieren von Klassen Dieses Kapitel stellt zwei Grundkonzepte der objektorientierten Programmierung vor: die Programmierung mit Klassen in Verbindung mit dem Konzept der Datenkapselung. Anhand von Beispielen werden die verschiedenen Sprachmittel und Programmiertechniken, die in C++ f¨ur die Verwendung von Klassen ben¨otigt werden, nach und nach eingef¨uhrt und erl¨autert. Als einf¨uhrendes Beispiel dient dabei die Klasse Bruch. Diese Klasse beschreibt, wie der Name schon sagt, Objekte, die Br¨uche repr¨asentieren. Es handelt sich bewusst um ein sehr einfaches Objekt, damit bei der Einf¨uhrung der verschiedenen Sprachmittel nicht das Beispiel selbst zum Problem wird.
131
Sandini Bib
132
Kapitel 4: Programmieren von Klassen
4.1 Die erste Klasse: Bruch In diesem Abschnitt wird als erste C++-Klasse die Klasse Bruch implementiert und angewendet. Die Klasse Bruch bietet dabei ein kleines, u¨ berschaubares Beispiel. Der Aufbau eines Bruchs und der Umgang mit Br¨uchen kann im Wesentlichen als bekannt vorausgesetzt werden. Dennoch gibt es einige Fallen, die in den nachfolgenden Versionen eine Verwendung von nichttrivialen Sprachmitteln erfordern. Es kann gut sein, dass sich ein Leser, der bereits einiges u¨ ber C++ weiß, u¨ ber diese erste Version wundern wird. Mit fortgeschrittenen Kenntnissen der Sprache w¨urde man die Klasse sicherlich anders implementieren. Aber alle Eigenschaften der Sprache k¨onnen unm¨oglich auf einmal vorgestellt werden. Im weiteren Verlauf des Buchs wird die Klasse Bruch von Version zu Version immer weiter verbessert. Einige Programmiertechniken, die hier als Grundlage aufgezeigt werden, werden aufgrund der gewonnenen Erkenntnisse dann in Frage gestellt oder durch andere Mechanismen ersetzt.
4.1.1
Voruberlegungen ¨ zur Implementierung
Wie bei jeder Klasse m¨ussen zur Implementierung der Klasse Bruch zwei Dinge gekl¨art werden:
Was soll man mit Objekten der Klasse machen k¨onnen? Welche Daten repr¨asentieren Objekte dieser Klasse?
Die erste Frage betrifft die Anwendung der Klasse und definiert somit ihre Schnittstelle nach außen. Sie wird anhand der Anforderungen, die an die Klasse gestellt werden, in einer entsprechenden Spezifikation beschrieben. Die zweite Frage f¨uhrt zum internen Aufbau der Klasse und ihrer Instanzen. Die Daten m¨ussen in irgendeiner Form als Attribute verwaltet werden. Diese Frage wird vor allem bei der Implementierung der Klasse relevant und muss so beantwortet werden, dass sie die Anforderungen, die sich aus der ersten Frage ergeben, m¨oglichst geschickt l¨osen kann. Anforderungen an die Klasse Bruch F¨ur unser erstes Beispiel sollen die Anforderungen an die Klasse Bruch m¨oglichst gering gehalten werden. Es soll ja nur das Prinzip verdeutlicht werden. Aus diesem Grund wird nur eine einzige Operation realisiert:
das Ausgeben eines Bruchs
In den folgenden Abschnitten kommen dann weitere Operationen hinzu, mit denen Br¨uche z.B. multipliziert oder eingelesen werden k¨onnen. Eines darf allerdings nicht vergessen werden: Um mit Br¨uchen eine Operation ausf¨uhren zu k¨onnen, muss es diese Br¨uche erst einmal geben. Außerdem sollte ein Bruch, wenn er nicht mehr gebraucht wird, zerst¨ort werden k¨onnen. Es werden also noch zwei weitere Operationen gebraucht:
Erzeugen (und Initialisieren) eines Bruchs Zerst¨oren eines Bruchs
Insgesamt ergibt sich so die in Abbildung 4.1 dargestellte Schnittstelle.
Sandini Bib
4.1 Die erste Klasse: Bruch
133
erzeugen
Bruch
ausgeben
zerstören Abbildung 4.1: Erste Schnittstelle der Klasse Bruch
Aufbau von Bruchen ¨ Bei der Frage nach den Attributen und dem internen Aufbau eines Objekts geht es letztlich darum, ein solches beliebig abstraktes Gebilde direkt oder indirekt auf bereits vorhandene Datentypen zur¨uckzuf¨uhren. Der Informationsgehalt und der Zustand des Objekts m¨ussen durch mehrere einzelne Komponenten, die bereits vorhandene Datentypen besitzen, beschrieben werden. Dabei ist zun¨achst einmal die Frage wichtig, wie der Informationsgehalt eines Objekts grunds¨atzlich beschrieben wird. Was repr¨asentiert das Objekt? Daraus ergeben sich meist schon die ersten Komponenten, aus denen das Objekt besteht. Im Laufe der Implementierung k¨onnen dann weitere Hilfskomponenten hinzukommen, die einen internen Objektzustand widerspiegeln und dadurch z.B. die Implementierung erleichtern oder Laufzeit einsparen. Was ein Bruch repr¨asentiert bzw. woraus er besteht, kann relativ leicht beantwortet werden: Ein Bruch besteht aus einem Z¨ahler und einem Nenner. Daf¨ur k¨onnen vorhandene fundamentale Datentypen wie int verwendet werden. Damit sind die beiden wesentlichen Komponenten eines Bruchs festgelegt:
Ein int f¨ur den Z¨ahler Ein int f¨ur den Nenner
Es kann durchaus sein, dass im Rahmen der Implementierung noch weitere Hilfskomponenten gebraucht werden. So k¨onnte eine Komponente intern z.B. festhalten, ob Z¨ahler und Nenner im aktuellen Zustand gek¨urzt werden k¨onnen. Die f¨ur den Informationsgehalt wesentlichen Komponenten f¨ur den Z¨ahler und den Nenner m¨ussen also nicht unbedingt die einzigen Komponenten bleiben. Insgesamt ergibt sich der in Abbildung 4.2 dargestellte Aufbau: Ein Bruch besteht aus einem Z¨ahler und einem Nenner und kann erzeugt, ausgegeben und zerst¨ort werden.
Sandini Bib
134
Kapitel 4: Programmieren von Klassen
erzeugen
Bruch: zaehler
ausgeben
nenner
zerstören Abbildung 4.2: Schnittstelle und Aufbau der Klasse Bruch
Terminologie In objektorientierter Lesart sind Z¨ahler und Nenner die Daten oder Attribute, aus denen sich eine Instanz der Klasse Bruch zusammensetzt. Das Erzeugen, Ausgeben und Zerst¨oren sind die Methoden, die f¨ur die Klassen definiert sind. In der Lesart von C++ bedeutet das, dass eine Klasse Bruch gebraucht wird, die sich aus den Komponenten Z¨ahler und Nenner sowie den Funktionen zum Erzeugen, Ausgeben und Zerst¨oren zusammensetzt. Die Komponenten einer Klasse werden auch Elemente oder Klassenelemente ¨ genannt. Dies alles sind verschiedene Ubersetzungen der englischen Bezeichnung member, die mitunter auch im Deutschen verwendet wird (siehe dazu auch die Anmerkungen zur Namensgebung auf Seite 22). Die Methoden, also die Operationen, die in einer Klasse definiert sind, nennt man in C++ Elementfunktionen. Eine andere weit verbreitete Bezeichnung daf¨ur ist Memberfunktion. F¨ur die Komponenten, die keine Elementfunktionen sind, gibt es in C++ eigentlich gar keine richtige Bezeichnung. Man spricht im Englischen im Allgemeinen nur von member und dem Spezialfall member function. Ich werde sie nachfolgend als Attribute oder Datenelemente bezeichnen. Die Komponenten der Klasse Bruch unterteilen sich also in die Datenelemente Z¨ahler und Nenner und die Elementfunktionen zum Erzeugen, Ausgeben und Zerst¨oren. Datenkapselung Br¨uche besitzen eine grunds¨atzliche Eigenschaft: Ihr Nenner darf nicht 0 sein, da durch 0 nicht geteilt werden darf. Diese Randbedingung soll nat¨urlich auch f¨ur die Klasse Bruch gelten. Wenn aber f¨ur alle Anwender unbeschr¨ankter Zugriff auf alle Komponenten der Klasse Bruch besteht, ist es m¨oglich, den Nenner eines Bruchs auf 0 zu setzen. Aus diesem Grund ist es sinnvoll, bei der Verwendung eines Bruchs keinen direkten Zugriff auf den Nenner zu erm¨oglichen.
Sandini Bib
4.1 Die erste Klasse: Bruch
135
Solange der Zugriff nur u¨ ber Funktionen m¨oglich ist, kann der Versuch, dem Nenner 0 zuzuweisen, mit einer entsprechenden Fehlermeldung quittiert werden. Generell ist es eine gute Idee, den Zugriff auf Objekte nur u¨ ber daf¨ur definierte Operationen zuzulassen. Durch deren Implementierung k¨onnen nicht nur Fehler gefunden, sondern auch Inkonsistenzen vermieden werden. Wird f¨ur einen Bruch z.B. intern in einer Hilfskomponente ¨ festgehalten, ob er k¨urzbar ist, kann bei einer Anderung des Sachverhalts durch eine Zuweisung des Z¨ahlers von außen intern eine entsprechende Anpassung stattfinden. F¨ur Br¨uche ist es deshalb sinnvoll, den Zugriff auf die Komponenten Z¨ahler und Nenner nur u¨ ber die zur Klasse Bruch geh¨orenden Funktionen zu erlauben. Damit wird auch ein anderer Vorteil erreicht: Der Aufbau und das Verhalten eines Bruchs k¨onnen intern modifiziert werden, ohne dass sich die Schnittstelle nach außen a¨ ndert.
4.1.2
Deklaration der Klasse Bruch
Um eine Klasse in C++ verwenden zu k¨onnen, muss sie zun¨achst deklariert werden. Dies geschieht, indem eine Struktur deklariert wird, in der die prinzipiellen Eigenschaften der Klasse aufgelistet werden. Sie enth¨alt sowohl die besprochenen Komponenten f¨ur den Z¨ahler und den Nenner als auch die Funktionen, die die Schnittstelle ausmachen. Damit die Deklaration zum Kompilieren aller Module, in denen die Klasse Bruch gebraucht wird, verwendet werden kann, wird sie in einer Headerdatei untergebracht. Sinnvollerweise tr¨agt die Datei den Namen der Klasse und erh¨alt deshalb am besten den Namen bruch.hpp. Die erste Version der Headerdatei bruch.hpp hat insgesamt folgenden Aufbau, auf den anschließend im Einzelnen eingegangen wird:
// klassen/bruch1.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse Bruch */ class Bruch { /* privat: kein Zugriff von außen */ private: int zaehler; int nenner; /* o¨ ffentliche Schnittstelle */
Sandini Bib
136
Kapitel 4: Programmieren von Klassen
public: // Default-Konstruktor
Bruch(); // Konstruktor aus int (Z¨ahler)
Bruch(int);
// Konstruktor aus zwei ints (Z¨ahler und Nenner)
Bruch(int, int); // Ausgabe
};
void print();
} // **** ENDE Namespace Bsp ******************************** #endif
/* BRUCH_HPP */
Pr¨aprozessor-Anweisungen Die komplette Headerdatei wird durch Pr¨aprozessor-Anweisungen eingeschlossen, mit deren Hilfe vermieden wird, dass die darin befindlichen Deklarationen bei mehrfachem Einbinden der Datei mehrfach durchgef¨uhrt werden:
#ifndef BRUCH_HPP #define BRUCH_HPP
// ist nur beim ersten Durchlauf erf¨ullt, denn // BRUCH_HPP wird beim ersten Durchlauf definiert
...
#endif Mit #ifndef ( if not defined“) werden die folgenden Zeilen bis zum korrespondierenden #endif ” nur verarbeitet, wenn die Konstante BRUCH_HPP nicht definiert ist. Da sie bei der ersten Verarbeitung definiert wird, werden die Anweisungen bei jedem weiteren Mal ignoriert. Da es immer passieren kann, dass Headerdateien u¨ ber unterschiedliche Wege von einer Quelldatei mehrfach eingebunden werden, sollten Deklarationen in Headerdateien immer durch derartige Anweisungen eingeschlossen werden.
4.1.3
Die Klassenstruktur
Die eigentliche Deklaration einer Klasse erfolgt in einer so genannten Klassenstruktur. Dort werden alle Komponenten (Attribute und Funktionen) der Klasse deklariert. Das Schlusselwort ¨ class Die Deklaration beginnt mit dem Schl¨usselwort class. Es folgen ein Symbol f¨ur den Namen der Klasse und der in geschweifte Klammern eingeschlossene und durch ein Semikolon abgeschlossene Klassenrumpf, in dem die Eigenschaften der Klasse deklariert werden:
Sandini Bib
4.1 Die erste Klasse: Bruch
137
class Bruch { ...
}; Wenn man eine Bibliothek definiert, die aus mehreren Klassen besteht, stellt jeder Klassenname ein globales Symbol dar, das u¨ berall verwendet werden kann. Dies kann bei der Verwendung unterschiedlicher Bibliotheken schnell zu Namenskonflikten f¨uhren. Aus diesem Grund wird die Klasse Bruch innerhalb eines Namensbereiches (englisch: namespace) deklariert:
namespace Bsp {
// Beginn Namensbereich Bsp
class Bruch { ...
}; // Ende Namensbereich Bsp
}
Damit befindet sich lediglich das Symbol Bsp im globalen G¨ultigkeitsbereich. Genau genommen definieren wir also Bruch in Bsp“. Die Notation, um diese Klasse anzusprechen, lautet in C++ ” Bsp::Bruch. Alle Klassen und auch andere Symbole sollte man immer einem Namensbereich zuordnen. Dies vermeidet nicht nur Konflikte, sondern macht auch deutlich, welche Elemente eines Programms logisch zusammengeh¨oren. In der objektorientierten Modellierung bezeichnet man eine derartige Gruppierung auch als Paket (englisch: package). Weitere Details zu Namensbereichen werden in Abschnitt 3.3.8 und Abschnitt 4.3.5 erl¨autert. Zugriffsschlusselw¨ ¨ orter Innerhalb der Klassenstruktur befinden sich Zugriffsschl¨usselw¨orter, die die einzelnen Komponenten gruppieren. Damit wird festgelegt, welche Komponenten der Klasse intern sind und auf welche auch von außen, bei der Anwendung der Klasse, zugegriffen werden kann. Die Komponenten zaehler und nenner sind privat:
class Bruch { private: int zaehler; int nenner; ...
}; Durch das Schl¨usselwort private werden alle folgenden Komponenten als privat deklariert. In Klassen ist dies zwar die Defaulteinstellung, es sollte zur besseren Lesbarkeit aber dennoch explizit herausgestellt werden. Die Tatsache, dass die Komponenten privat sind, bedeutet, dass auf sie nur im Rahmen der Implementierung der Klasse Zugriff besteht. Der Anwender, der einen Bruch verwendet, hat keinen direkten Zugriff darauf. Er kann diesen nur indirekt erhalten, indem entsprechende Funktionen zur Verf¨ugung gestellt werden.
Sandini Bib
138
Kapitel 4: Programmieren von Klassen
Zugriff hat der Anwender der Klasse Bruch nur auf die o¨ ffentlichen Komponenten. Diese werden durch das Schl¨usselwort public gekennzeichnet. Es handelt sich typischerweise um die Funktionen, die die Schnittstelle der Objekte dieser Klasse nach außen zum Anwender bilden:
class Bruch { ...
};
public: void print ();
Es ist nicht zwingend, dass alle Elementfunktionen o¨ ffentlich und alle anderen Komponenten privat sind. Bei gr¨oßeren Klassen ist es durchaus nicht untypisch, dass es klasseninterne Hilfsfunktionen gibt, die als private Elementfunktionen deklariert werden. Genauso ist es prinzipiell m¨oglich, Komponenten, die keine Funktionen sind, mit einem o¨ ffentlichen Zugriff zu versehen. Dies ist aber im Allgemeinen nicht sinnvoll, da damit der Vorteil, die Objekte nur u¨ ber eine wohldefinierte Schnittstelle manipulieren zu k¨onnen, aufgehoben wird. Zugriffsdeklarationen k¨onnen in einer Klassendeklaration beliebig oft auftreten, wodurch der Zugriff auf die folgenden Komponenten mehrfach wechseln kann:
class Bruch { private: ...
public: ...
private: ...
}; Strukturen und Klassen Aus der Sicht eines C-Programmierers kann eine Klasse als eine C-Struktur betrachtet werden, die einfach nur mit zus¨atzlichen Eigenschaften ausgestattet wurde (Zugriffskontrolle und Funktionen als Komponenten). Es ist sogar so, dass die Eigenschaften von Klassen in C++ auch f¨ur Strukturen eingef¨uhrt werden. Statt des Schl¨usselwortes class kann deshalb auch immer das Schl¨usselwort struct verwendet werden. Der einzige Unterschied ist, dass mit dem Schl¨usselwort struct alle Komponenten defaultm¨aßig o¨ ffentlich sind. Jede C-Struktur ist im C++-Sinne also eine Klasse mit lauter o¨ ffentlichen Komponenten. In der Praxis empfehle ich, um unn¨otige Verwirrung zu vermeiden, f¨ur Klassen immer das Schl¨usselwort class zu verwenden. Lediglich wenn in C++ Strukturen im herk¨ommlichen CSinne eingesetzt werden (ohne Funktionen als Komponenten und mit o¨ ffentlichem Zugriff auf alle Komponenten), verwende ich zur Abgrenzung das Schl¨usselwort struct. Leider gibt es Literatur, in der selbst f¨ur Beispiele zur Programmierung mit Klassen das Schl¨usselwort struct verwendet wird.
Sandini Bib
4.1 Die erste Klasse: Bruch
4.1.4
139
Elementfunktionen
Eine Komponente, die in der Struktur der Klasse Bruch deklariert wird, ist die Elementfunktion print():
class Bruch { ...
void print ();
// Ausgabe eines Bruchs
...
}; Sie dient dazu, einen Bruch auszugeben. Da sie keinen Wert zur¨uckliefert, hat sie den R¨uckgabetyp void. Man beachte dabei, dass der Bruch, der jeweils ausgegeben werden soll, nicht als Parameter u¨ bergeben wird. Er ist in einer Elementfunktion automatisch vorhanden, da der Aufruf einer Elementfunktion immer an ein bestimmtes Objekt dieser Klasse gebunden ist:
Bsp::Bruch x; ...
x.print();
// Elementfunktion print() f¨ur Objekt x aufrufen
Die Funktion print() wird mit dem Punkt-Operator f¨ur einen Bruch ohne (weitere) Parameter aufgerufen. Im objektorientierten Sprachgebrauch wird durch den Aufruf von x.print() dem Objekt x als Instanz der Klasse Bruch die Nachricht print ohne Parameter geschickt.
4.1.5
Konstruktoren
Die ersten drei Funktionen, die in der Klasse Bruch deklariert werden, sind spezielle Funktionen. Sie legen fest, auf welche Weise ein Bruch erzeugt ( konstruiert“) werden kann. Ein solcher Kon” struktor (englisch: constructor) ist daran zu erkennen, dass er als Funktionsnamen den Namen der Klasse tr¨agt. Aufgerufen wird ein Konstruktor immer dann, wenn eine Instanz (ein konkretes Objekt) der entsprechenden Klasse angelegt wird. Das ist z.B. beim Deklarieren einer entsprechenden Variablen der Fall. Nachdem f¨ur das Objekt der notwendige Speicherplatz angelegt wurde, werden die Anweisungen im Konstruktor ausgef¨uhrt. Sie dienen typischerweise dazu, das angelegte Objekt zu initialisieren, indem die Datenelemente des Objekts mit sinnvollen Startwerten versehen werden. Bei der Klasse Bruch werden drei Konstruktoren deklariert:
class Bruch { ... // Default-Konstruktor
Bruch (); // Konstruktor aus int (Z¨ahler)
Bruch (int);
Sandini Bib
140
Kapitel 4: Programmieren von Klassen // Konstruktor aus zwei ints (Z¨ahler und Nenner)
Bruch (int, int); ...
}; Das bedeutet, dass ein Bruch auf dreierlei Arten angelegt werden kann:
Ein Bruch kann ohne Argument angelegt werden. Der Konstruktor wird z.B. aufgerufen, wenn ein Objekt der Klasse Bruch ohne weitere Parameter deklariert wird:
Bsp::Bruch x;
// Initialisierung mit Default-Konstruktor
Einen Konstruktor ohne Parameter nennt man auch Default-Konstruktor. Ein Bruch kann mit einem Integer als Argument erzeugt werden. Wie wir bei der Implementierung des Konstruktors noch sehen werden, wird dieser Parameter als ganze Zahl interpretiert, mit der der Bruch initialisiert wird. Ein solcher Konstruktor wird z.B. durch eine Deklaration aufgerufen, bei der der Bruch mit einem Objekt des passenden Typs initialisiert wird:
Bsp::Bruch y = 7;
// Initialisierung mit int-Konstruktor
Diese Art der Initialisierung wurde von C u¨ bernommen. In C++ wurde aber auch eine neue Schreibweise zur Initialisierung eines Objekts eingef¨uhrt, die genauso verwendet werden kann:
Bsp::Bruch y(7);
// Initialisierung mit int-Konstruktor
Ein Bruch kann mit zwei ganzzahligen Argumenten erzeugt werden. Diese Parameter werden zum Initialisieren von Z¨ahler und Nenner verwendet. Um einen solchen Konstruktor aufrufen zu k¨onnen, wurde die gerade vorgestellte neue Schreibweise zur Initialisierung eines Objekts eingef¨uhrt, denn sie erlaubt es, mehrere Argumente zu u¨ bergeben:
Bsp::Bruch w(7,3);
// Initialisierung mit int/int-Konstruktor
Konstruktoren haben noch eine ungew¨ohnliche Besonderheit: Sie besitzen keinen R¨uckgabetyp (auch nicht void). Es sind also keine Funktionen oder Prozeduren im herk¨ommlichen Sinne. Klassen ohne Konstruktoren Werden f¨ur eine Klasse keine Konstruktoren definiert, k¨onnen trotzdem Instanzen (konkrete Objekte) der Klasse angelegt werden. Diese Objekte werden dann allerdings nicht initialisiert. Es gibt also sozusagen einen vordefinierten Default-Konstruktor, der nichts macht. Damit Objekte keinen undefinierten Zustand erhalten, sollte dieser Fall unbedingt vermieden werden. Wenn es den Zustand nicht initialisiert“ geben soll, dann sollte stattdessen eine ” Boolesche Komponente eingef¨uhrt werden, die diesen Sachverhalt u¨ ber den Konstruktor definitiv festh¨alt. Werden dann Operationen f¨ur einen Bruch mit nicht definiertem Wert aufgerufen, k¨onnen entsprechende Fehlermeldungen ausgegeben werden. Bei einem Bruch, der, da er nicht initialisiert wurde, einen beliebigen Zustand besitzen kann, ist das nicht m¨oglich.
Sandini Bib
4.1 Die erste Klasse: Bruch
141
Sobald Konstruktoren definiert werden, k¨onnen Objekte nur noch u¨ ber diese angelegt werden. Wird kein Default-Konstruktor, wohl aber ein anderer Konstruktor definiert, ist ein Anlegen eines ¨ Objekts der Klasse ohne Parameter also nicht mehr m¨oglich. Auf diese Weise kann die Ubergabe von Werten zur Initialisierung erzwungen werden.
4.1.6
¨ Uberladen von Funktionen
Die Tatsache, dass – wie im Fall der Konstruktoren – mehrere Funktionen den gleichen Namen besitzen d¨urfen, ist eine grunds¨atzliche Eigenschaft von C++. Funktionen (nicht nur Elementfunktionen) d¨urfen beliebig u¨ berladen werden. ¨ Das Uberladen (englisch: overloading) von Funktionen bedeutet, dass diese den gleichen Namen tragen und sich nur in ihren Parametern unterscheiden. Welche Funktion aufzurufen ist, wird durch Anzahl und Typ der Parameter entschieden. Eine Unterscheidung nur durch den R¨uckgabetyp ist nicht zul¨assig. ¨ Das folgende Beispiel zeigt das Uberladen einer globalen Funktion zum Berechnen des Quadrats f¨ur Integer und Gleitkommawerte: // Deklaration
int quadrat (int); double quadrat (double);
// Quadrat eines ints // Quadrat eines doubles
void f () { // Aufruf
}
quadrat(713); quadrat(4.378);
// ruft Quadrat eines ints auf // ruft Quadrat eines doubles auf
Die Funktion ließe sich im gleichen Programm zus¨atzlich f¨ur die Klasse Bruch u¨ berladen: // Deklaration
int quadrat (int); // Quadrat eines ints double quadrat (double); // Quadrat eines doubles Bsp::Bruch quadrat (Bsp::Bruch); // Quadrat eines Bruchs void f () { Bsp::Bruch b; ... // Aufruf
}
quadrat(b);
// ruft Quadrat eines Bruchs auf
¨ Beim Uberladen von Funktionen sollte nat¨urlich darauf geachtet werden, dass die u¨ berladenen Funktionen im Prinzip das Gleiche machen. Da jede Funktion f¨ur sich implementiert wird, liegt dies in der Hand des Programmierers.
Sandini Bib
142
Kapitel 4: Programmieren von Klassen
Parameter-Prototypen Damit eine Unterscheidung der Funktionen u¨ ber die Parameter u¨ berhaupt m¨oglich ist, m¨ussen diese mit Parameter-Prototypen deklariert werden. Das bedeutet, der Typ eines Parameters muss bei der Deklaration angegeben werden und steht bei der Definition einer Funktion direkt in der Liste der Parameter: // Deklaration
int quadrat (int, int); // Definition
int quadrat (int a, int b) { return a * b; } Parameter-Prototypen k¨onnen auch in ANSI-C definiert werden. In C brauchten die Parameter bei einer Deklaration allerdings nicht unbedingt angegeben zu werden. Im Vergleich zu C gibt es deshalb einen wichtigen Unterschied: Die Deklaration
void f (); definiert in C eine Funktion mit beliebig vielen Parametern. In C++ bedeutet dies aber, dass die Funktion auf jeden Fall keine Parameter besitzt. Die Schreibweise von ANSI-C zur Deklaration einer Funktion ohne Parameter
void f (void); kann in C++ ebenfalls verwendet werden, ist aber un¨ublich. Falls eine Funktion eine variable Anzahl von Argumenten besitzt, kann dies in der Deklaration durch drei Punkte (mit oder ohne Komma davor) angegeben werden:
int printf (char*, ...); Eine solche Angabe nennt man im englischen Ellipsis, was man im Deutschen etwa mit Auslassung oder Unvollst¨andigkeit u¨ bersetzen kann.1
4.1.7
Implementierung der Klasse Bruch
Die in der Deklaration der Klasse Bruch deklarierten Elementfunktionen m¨ussen nat¨urlich auch implementiert werden. Dies geschieht typischerweise in einem eigenen Modul, das f¨ur die Klasse angelegt wird. Es tr¨agt den Namen Bruch.cpp. Es ist von Vorteil, f¨ur jede Klasse eine eigene Quelldatei zu verwenden. Damit m¨ussen Programme nur die Module der Klassen dazubinden, die auch verwendet werden. Zus¨atzlich unterst¨utzt diese Vorgehensweise nat¨urlich auch die Idee der objektorientierten Programmierung, 1
In vielen B¨uchern wird der Begriff ellipsis“ auch als Ellipse“ u¨ bersetzt, womit offensichtlich nicht das ” ” mathematische Objekt, sondern die sprachwissenschaftliche Bezeichnung f¨ur eine Ersparung von Redetei” len“ gemeint ist (ein Duden ist manchmal eine wirklich interessante Sache).
Sandini Bib
4.1 Die erste Klasse: Bruch
143
da jede Art von Objekt f¨ur sich betrachtet wird (es gibt nat¨urlich auch gute Gr¨unde, von dieser Regel abzuweichen, etwa wenn zwei Klassen logisch und technisch sehr eng zusammengeh¨oren). Die Quelldatei der ersten Version der Klasse Bruch sieht wie folgt aus:
// klassen/bruch1.cpp // Headerdatei mit der Klassen-Deklaration einbinden
#include "bruch.hpp" // Standard-Headerdateien einbinden
#include #include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Default-Konstruktor */ Bruch::Bruch () : zaehler(0), nenner(1) {
// Bruch mit 0 initialisieren
// keine weiteren Anweisungen
} /* Konstruktor aus ganzer Zahl */ Bruch::Bruch (int z) : zaehler(z), nenner(1) {
// Bruch mit z 1tel initialisieren
// keine weiteren Anweisungen
} /* Konstruktor aus Z¨ahler und Nenner */ Bruch::Bruch (int z, int n) // Z¨ahler und Nenner wie u¨ bergeben initialisieren : zaehler(z), nenner(n) { // 0 als Nenner ist allerdings nicht erlaubt
if (n == 0) { // Programm mit Fehlermeldung beenden
}
}
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
Sandini Bib
144
Kapitel 4: Programmieren von Klassen
/* print */ void Bruch::print () { std::cout << zaehler << '/' << nenner << std::endl; } } // **** ENDE Namespace Bsp ******************************** Am Anfang wird die Deklaration der Klasse eingebunden, deren Elementfunktionen hier definiert werden:
#include "bruch.hpp" Dies ist notwendig, damit der Compiler z.B. weiß, welche Komponenten die Klasse Bruch besitzt. Anschließend werden verschiedene andere Headerdateien eingebunden, die die Datentypen und die Funktionen deklarieren, die von der Klasse verwendet werden. In diesem Fall sind das zwei Dateien:
#include #include Die erste Include-Anweisung bindet die Standardelemente von C++ f¨ur Ein- und Ausgaben ein (siehe Seite 29 und Seite 202). Die zweite Include-Anweisung bindet eine Headerdatei ein, in der verschiedene Standardfunktionen deklariert werden, die auch schon in C zur Verf¨ugung stehen. In C tr¨agt diese Datei den Namen <stdlib.h>. Damit die Symbole zum Namensbereich der Standardbibliothek std geh¨oren, werden sie f¨ur C++ in der Datei deklariert. Generell stehen die Standardfunktionen von C auch in C++ zur Verf¨ugung. Die dazugeh¨origen Headerdateien haben statt der Endung .h das Pr¨afix c. In diesem Programm wird konkret f¨ur die Funktion std::exit() und die dazugeh¨orige Konstante EXIT_FAILURE eingebunden. Mit std::exit() kann man ein Programm im Fehlerfall abbrechen (siehe Seite 128). Man beachte, dass die Include-Anweisungen zwei unterschiedliche Formen haben. Im einen Fall wird die einzubindende Datei in doppelten Anf¨uhrungszeichen, im anderen Fall in spitzen Klammern eingeschlossen:
#include "bruch.hpp" #include Die Form mit den spitzen Klammern ist f¨ur externe Dateien vorgesehen. Die dazugeh¨origen Dateien werden in den Verzeichnissen gesucht, die vom Compiler als System-Verzeichnis angesehen werden.2 Die in doppelten Anf¨uhrungsstrichen angegebenen Dateien werden zus¨atzlich Auf Unix-Systemen kann man diese Verzeichnisse meistens mit der Option -I definieren. Microsoft Visual C++ bietet dazu die M¨oglichkeit der Angabe zus¨atzlicher Include-Verzeichnisse“. ”
2
Sandini Bib
4.1 Die erste Klasse: Bruch
145
auch im lokalen Verzeichnis gesucht. Falls sie dort nicht gefunden werden, wird ebenfalls in den System-Verzeichnissen nachgesehen (siehe auch Seite 56). Der Bereichsoperator Beim Betrachten der Funktionen f¨allt zun¨achst auf, dass vor s¨amtlichen Funktionsnamen der Ausdruck
Bruch:: steht. Der Operator :: ist der Bereichsoperator. Er ordnet einem nachfolgenden Symbol den G¨ultigkeitsbereich der davorstehenden Klasse zu. Er besitzt h¨ochste Priorit¨at. In diesem Fall wird damit zum Ausdruck gebracht, dass es sich jeweils um eine Elementfunktion der Klasse Bruch handelt. Das bedeutet, dass die Funktion nur f¨ur Objekte dieser Klasse aufgerufen werden kann. Damit ist aber gleichzeitig definiert, dass es innerhalb der Funktionen immer einen Bruch gibt, f¨ur den die Funktion aufgerufen wurde und auf dessen Komponenten zugegriffen werden kann. Implementierung der Konstruktoren Zun¨achst werden die Konstruktoren implementiert. Sie sind, wie schon erw¨ahnt, daran zu erkennen, dass der Funktionsname gleich dem Klassennamen ist, also Bruch::Bruch lautet. Der Default-Konstruktor initialisiert den Bruch mit 0 (genauer mit 01 ):
Bruch::Bruch () : zaehler(0), nenner(1) {
// Bruch mit 0 initialisieren
// keine weiteren Anweisungen
} Bei jedem Erzeugen eines Bruchs ohne Argumente wird dieser Konstruktor aufgerufen. Damit wird sichergestellt, dass auch ein Bruch, der ohne Argumente zur Initialisierung angelegt wird, keinen undefinierten Wert besitzt. An dieser Stelle zeigt sich gleich eine Besonderheit der Konstruktoren. Da sie zur Initialisierung der Objekte dienen, verf¨ugen sie u¨ ber die M¨oglichkeit, noch vor den eigentlichen Anweisungen der Funktion die initialen Werte der Komponenten festzulegen. Getrennt durch einen Doppelpunkt und vor dem eigentlichen Funktionsk¨orper k¨onnen f¨ur jedes Attribut die initialen Werte angegeben werden. Es handelt sich dabei um eine so genannte Initialisierungsliste. Sie wirkt so, als h¨atte man f¨ur Br¨uche, die unter diesen Umst¨anden angelegt werden, die folgenden Deklarationen in Bezug auf ihre Komponenten get¨atigt:
int zaehler(0); int nenner(1); Alternativ h¨atte man die Werte auch mit Hilfe herk¨ommlicher Anweisungen zuweisen k¨onnen:
Bruch::Bruch () { zaehler = 0; nenner = 1; }
Sandini Bib
146
Kapitel 4: Programmieren von Klassen
Es gibt jedoch kleine Unterschiede in der Semantik dieser beiden Formen – so wie es Unterschiede zwischen
int x = 0;
// x anlegen und dabei sofort mit 0 initialisieren
bzw.
int x(0);
// x anlegen und dabei sofort mit 0 initialisieren
auf der einen und
int x; x = 0;
// x anlegen // in einem separaten Schritt 0 zuweisen
auf der anderen Seite gibt. Diese Unterschiede spielen hier zwar noch keine Rolle; man sollte sich aber gleich an die Syntax mit der Initialisierungsliste gew¨ohnen. Der zweite Konstruktor wird aufgerufen, wenn beim Erzeugen des Objekts ein Integer u¨ bergeben wird. Dieser Parameter wird als ganze Zahl interpretiert. Entsprechend wird der Z¨ahler mit dem u¨ bergebenen Parameter und der Nenner mit 1 initialisiert:
Bruch::Bruch (int z) : zaehler(z), nenner(1) {
// Bruch mit z 1tel initialisieren
// keine weiteren Anweisungen
} Der dritte Konstruktor erh¨alt zwei Parameter, die er zum Initialisieren von Z¨ahler und Nenner verwendet. Dieser Konstruktor wird immer dann aufgerufen, wenn beim Anlegen eines Bruchs zwei Integer u¨ bergeben werden. Falls als Nenner allerdings 0 u¨ bergeben wird, wird das Programm mit einer entsprechenden Fehlermeldung beendet:
Bruch::Bruch (int z, int n) // Z¨ahler und Nenner wie u¨ bergeben initialisieren : zaehler(z), nenner(n) { // 0 als Nenner ist allerdings nicht erlaubt if (n == 0) { // Programm mit Fehlermeldung beenden
}
}
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
Fehlerbehandlung in Konstruktoren Die Tatsache, dass in diesem Beispiel bei einer fehlerhaften Initialisierung das Programm beendet wird, ist sehr radikal. Es w¨are besser, den Fehler zu melden und abh¨angig von der Programmsituation darauf zu reagieren. Das Problem ist nur, dass Konstruktoren keine herk¨ommlichen Funktionen sind, die explizit aufgerufen werden und einen R¨uckgabewert zur¨uckliefern k¨onnen. Sie werden implizit durch Deklarationen aufgerufen – und Deklarationen haben keinen R¨uckgabewert.
Sandini Bib
4.1 Die erste Klasse: Bruch
147
Das Programm wird deshalb mangels besserer Alternativen abgebrochen. Um einen solchen Programmabbruch zu vermeiden, muss also in allen Programmen, in denen die Klasse verwendet wird, darauf geachtet werden, dass als Nenner nicht 0 u¨ bergeben wird. Es gibt nat¨urlich noch andere Alternativen zum Abbruch. So k¨onnte der Bruch auch einen Default-Wert erhalten. Dies erscheint aber nicht sinnvoll, da damit ein offensichtlicher Fehler (Nenner d¨urfen nicht 0 sein) nicht behandelt, sondern einfach nur ignoriert wird. Es gibt auch die M¨oglichkeit, eine weitere Hilfskomponente f¨ur Br¨uche einzuf¨uhren, die festh¨alt, dass der Bruch noch nicht korrekt initialisiert wurde. Diese Komponente m¨usste dann aber auch ausgewertet und bei jeder Operation beachtet werden. Die beste M¨oglichkeit, mit solchen Fehlern umzugehen, stellt das C++-Konzept zur Ausnahmebehandlung dar, das bereits in Abschnitt 3.6 vorgestellt wurde. Dieses Konzept erlaubt es, auch bei einer Deklaration eine Fehlerbehandlung auszul¨osen, die aber nicht unbedingt zum Abbruch f¨uhren muss, sondern von der Programmumgebung, in der sie auftritt, abgefangen und entsprechend der aktuellen Situation behandelt werden kann. Wir werden die Klasse Bruch in Abschnitt 4.7 auf diesen Mechanismus umstellen. Implementierung von Elementfunktionen Als N¨achstes folgt die Definition der Elementfunktion print(), die einen Bruch ausgibt. Dazu gibt diese Funktion einfach den Z¨ahler und den Nenner mit Hilfe der Techniken der C++Standardbibliothek auf dem Standard-Ausgabekanal, std::cout, in der Form z¨ahler/nenner“ ” aus:
void Bruch::print () { std::cout << zaehler << '/' << nenner << std::endl; } Da es sich um eine Elementfunktion handelt, muss sie immer f¨ur ein bestimmtes Objekt aufgerufen werden, damit zaehler und nenner zugeordnet werden k¨onnen. Dies bedeutet, dass es bei Elementfunktionen eine neue Art von G¨ultigkeitsbereich gibt, der bei der Zuordnung von Symbolen ber¨ucksichtigt wird: den G¨ultigkeitsbereich der Klasse, zu der die Funktion geh¨ort. Die Deklaration von einem in einer Elementfunktion verwendeten Symbol wird zun¨achst im lokalen G¨ultigkeitsbereich der Funktion, dann in der Klassendeklaration und schließlich im globalen G¨ultigkeitsbereich gesucht. Durch w.print() wird diese Funktion f¨ur das Objekt w aufgerufen. Daraus folgt, dass in diesem Fall in der Funktion mit zaehler der Z¨ahler von w, also w.zaehler, angesprochen wird. Bei einem Aufruf der Funktion durch x.print() wird entsprechend x.zaehler angesprochen. Man beachte, dass die Komponente zaehler im Anwendungsprogramm nicht direkt angesprochen werden kann, da sie als privat deklariert ist. Nur die Elementfunktionen einer Klasse haben Zugriff auf die privaten Komponenten dieser Klasse.
4.1.8
Anwendung der Klasse Bruch
Die vorgestellte erste Version der Klasse Bruch kann bereits in einem Anwendungsprogramm eingesetzt werden.
Sandini Bib
148
Kapitel 4: Programmieren von Klassen
Dazu muss in der entsprechenden Quelldatei die Headerdatei der Klasse eingebunden werden. Aufgrund der Typbindung von C++ wird ansonsten die Deklaration einer Variablen vom Typ Bruch abgelehnt. Der Compiler braucht außerdem Kenntnis u¨ ber die Komponenten eines Bruchs, damit entsprechend viel Speicherplatz angelegt werden kann. Mit Hilfe der Klassendeklaration wird auch gepr¨uft, ob alle Zugriffe auf Objekte der Klasse Bruch zul¨assig sind. Unter der Annahme, dass das Anwendungsprogramm btest.cpp heißt, ergibt sich das in ¨ Abbildung 4.3 dargestellte Ubersetzungsschema: Die Headerdatei bruch.hpp mit der Klassendeklaration wird von der Quelldatei mit der Klassenimplementierung und der Quelldatei mit dem Anwendungsprogramm eingebunden. Die beiden Quelldateien werden vom C++-Compiler zun¨achst in Objektdateien u¨ bersetzt (diese haben typischerweise die Endung .o oder .obj). Von einem Linker werden die Objektdateien dann zum ausf¨uhrbaren Programm btest (oder ¨ btest.exe) zusammengebunden. Das Ubersetzen und Binden ist auf den meisten Systemen auch in einem Schritt m¨oglich.
KlassenImplementierung
Bruch.cpp
KlassenSpezifikation
Bruch.hpp
Anwendung
btest.cpp
C++ Compiler
Bruch.o
Linker
C++ Compiler
btest
btest.o
¨ Abbildung 4.3: Ubersetzungsschema f¨ur die Klasse Bruch
Das folgende Programm ist ein erstes Anwendungsbeispiel der ersten Version der Klasse Bruch und zeigt, was mit Br¨uchen bereits alles gemacht werden kann:
// klassen/btest1.cpp // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { Bsp::Bruch x; Bsp::Bruch w(7,3); // Bruch w ausgeben
// Initialisierung durch Default-Konstruktor // Initialisierung durch int/int-Konstruktor
Sandini Bib
4.1 Die erste Klasse: Bruch
149
w.print(); // Bruch w wird an Bruch x zugewiesen
x = w;
// 1000 in einen Bruch umwandeln und w zuweisen
w = Bsp::Bruch(1000); // x und w ausgeben
}
x.print(); w.print();
Die Ausgabe des Programms lautet:
7/3 7/3 1000/1 Deklarationen Im Programm werden zun¨achst die Variablen x und w deklariert:
Bsp::Bruch x; Bsp::Bruch w(7,3);
// Initialisierung durch Default-Konstruktor // Initialisierung durch int/int-Konstruktor
Der Vorgang, der hier stattfindet, kann unterschiedlich benannt werden:
Im nicht-objektorientierten Sprachgebrauch werden zwei Variablen vom Typ Bsp::Bruch angelegt. In objektorientierter Lesart werden zwei Instanzen (konkrete Objekte) der Klasse Bruch angelegt.
Konkret wird sowohl f¨ur x als auch f¨ur w Speicherplatz angelegt und der entsprechende Konstruktor aufgerufen. Dies geschieht f¨ur x in folgenden Schritten:
Zun¨achst wird der Speicherplatz f¨ur das Objekt angelegt. Dessen Zustand ist zun¨achst nicht definiert:
x:
zaehler:
?
nenner:
?
Da x ohne einen Wert zur Initialisierung deklariert wird, wird anschließend der DefaultKonstruktor aufgerufen:
Bruch::Bruch () : zaehler(0), nenner(1)
// Bruch mit 0 initialisieren
Sandini Bib
150
Kapitel 4: Programmieren von Klassen
{ // keine weiteren Anweisungen
} Durch dessen Initialisierungsliste wird der Bruch x also mit 0 ( 01 ) initialisiert:
x:
zaehler: nenner:
0 1
Die Initialisierung von w geschieht entsprechend in folgenden Schritten:
Zun¨achst wird wieder der Speicherplatz f¨ur das Objekt angelegt, dessen Zustand zun¨achst nicht definiert ist:
w:
zaehler:
?
nenner:
?
Da w mit zwei Argumenten initialisiert wird, wird der Konstruktor aufgerufen, der f¨ur zwei Integer als Parameter definiert ist:
/* Konstruktor aus Z¨ahler und Nenner */ Bruch::Bruch (int z, int n) // Z¨ahler und Nenner wie u¨ bergeben initialisieren : zaehler(z), nenner(n) { // 0 als Nenner ist allerdings nicht erlaubt if (n == 0) { // Programm mit Fehlermeldung beenden
}
}
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
Mit den u¨ bergebenen Parametern initialisiert der Konstruktor den Z¨ahler und den Nenner und legt somit den Bruch 73 an:
w:
zaehler: nenner:
Anschließend wird noch gepr¨uft, ob der Nenner 0 ist.
7 3
Sandini Bib
4.1 Die erste Klasse: Bruch
151
Zerst¨oren von Objekten Zerst¨ort werden die angelegten Objekte automatisch, wenn vom Programm der G¨ultigkeitsbereich der Objekte verlassen wird. Genau so, wie beim Anlegen automatisch Speicherplatz f¨ur das Objekt angelegt wird, wird er bei der Zerst¨orung des Objekts auch wieder automatisch freigegeben. Bei lokalen Objekten geschieht dies, wenn der Block, in dem sie deklariert werden, wieder verlassen wird:
void f () { Bsp::Bruch x; Bsp::Bruch w(7,3);
// Erzeugung und Initialisierung von x // Erzeugung und Initialisierung von w
...
}
// automatische Zerst¨orung von x und w
Es ist allerdings m¨oglich, Funktionen zu definieren, die unmittelbar vor der Freigabe des Speicherplatzes aufgerufen werden. Sie bilden das Gegenst¨uck zu den Konstruktoren und heißen Destruktoren. Sie k¨onnen z.B. dazu dienen, eine entsprechende R¨uckmeldung auszugeben oder einen Z¨ahler f¨ur die Anzahl der existierenden Objekte zur¨uckzusetzen. Typisch ist auch die Freigabe von explizit angelegtem Speicherplatz, der zu einem Objekt geh¨ort. Da bei der Klasse Bruch kein Bedarf f¨ur Destruktoren besteht, werden sie erst sp¨ater in Abschnitt 6.1 eingef¨uhrt. Statische und globale Objekte Statische oder globale Objekte werden beim Programmstart angelegt und beim Programmende zerst¨ort. Dies hat eine wichtige Konsequenz: Die Funktion main() ist nicht unbedingt die erste Funktion eines Programms, die aufgerufen wird. Vorher werden alle Konstruktoren f¨ur statische Objekte aufgerufen, die wiederum beliebige Hilfsfunktionen aufrufen k¨onnen. Ebenso k¨onnen nach dem Beenden von main() oder einem Aufruf von exit() noch Destruktoren f¨ur statische und globale Objekte aufgerufen werden. Nachfolgend wird vor dem Aufruf von main() f¨ur das globale Objekt zweiDrittel der Konstruktor der Klasse Bruch aufgerufen:
/* globaler Bruch * - Erzeugung und Initialisierung (¨uber Konstruktor) beim Programmstart * - automatische Zerst¨orung (¨uber Destruktor) beim Programmende */
Bruch zweiDrittel(2,3); int main () { ...
} Da Konstruktoren beliebige Hilfsfunktionen aufrufen k¨onnen, k¨onnen auf diese Weise bei komplexen Klassen vor dem Aufruf von main() bereits erhebliche Dinge geschehen. Ein Beispiel daf¨ur w¨are eine Klasse f¨ur Objekte, die ge¨offnete Dateien repr¨asentieren. Wird ein globales Ob-
Sandini Bib
152
Kapitel 4: Programmieren von Klassen
jekt dieser Klasse deklariert, wird durch den Aufruf des Konstruktors noch vor dem Aufruf von main() eine entsprechende Datei ge¨offnet. Felder von Objekten Konstruktoren werden f¨ur jedes erzeugte Objekt einer Klasse aufgerufen. Wenn ein Feld (Array) von zehn Br¨uchen deklariert wird, wird also auch zehnmal der Default-Konstruktor aufgerufen:
Bsp::Bruch werte[10];
// Feld von zehn Br¨uchen
Dabei k¨onnen auch Werte zur Initialisierung der Elemente im Feld angegeben werden, wie das folgende Beispiel zeigt:
Bsp::Bruch bm[5] = { Bsp::Bruch(7,2), Bsp::Bruch(42), Bsp::Bruch(), 13 }; Angelegt werden f¨unf Br¨uche (bm[0] bis bm[4]). bm[0] wird mit dem int/int-Konstruktor initialisiert, da zwei Parameter u¨ bergeben werden. bm[1] und bm[3] werden mit dem int-Konstruktor initialisiert, da ein Integer u¨ bergeben wird. bm[2] und bm[4] werden mit dem DefaultKonstrukor initialisiert, da kein Argument angegeben wird (bei bm[2] wegen der leeren Klammer und bei bm[4], da daf¨ur u¨ berhaupt kein Wert zur Initialisierung mehr angegeben wird). Auch diese f¨unf Br¨uche werden zerst¨ort, wenn der G¨ultigkeitsbereich, in dem sie erzeugt werden, verlassen wird. Aufruf von Elementfunktionen Mit der Anweisung
w.print() wird f¨ur w die Elementfunktion print() aufgerufen, die den Bruch ausgibt. F¨ur w wird sozusagen auf dessen Klassenkomponente print() zugegriffen. Da das in diesem Fall eine Funktion ist, bedeutet das, dass diese dadurch f¨ur w aufgerufen wird. In C++ kann auch ein Zeiger (siehe Abschnitt 3.7.1) auf einen Bruch definiert werden. In einem solchen Fall kann der Zugriff auf eine Komponente bzw. der Aufruf einer Elementfunktion u¨ ber den Operator -> erfolgen:
Bsp::Bruch x; // Bruch Bsp::Bruch* xp; // Zeiger auf Bruch xp = &x;
// xp zeigt auf x
xp->print();
// print() f¨ur das, worauf xp zeigt, aufrufen
Zuweisungen Mit der Anweisung
x = w; wird dem Bruch x der Bruch w zugewiesen.
Sandini Bib
4.1 Die erste Klasse: Bruch
153
Eine solche Zuweisung ist m¨oglich, obwohl sie in der Klassenspezifikation nicht deklariert worden ist. F¨ur jede Klasse ist n¨amlich automatisch ein Default-Zuweisungsoperator vordefiniert. Er ist so implementiert, dass er komponentenweise zuweist. Das bedeutet, dass dem Z¨ahler von x der Z¨ahler von w und dem Nenner von x entsprechend der Nenner von w zugewiesen wird. Der Zuweisungsoperator kann f¨ur eine Klasse auch selbst definiert werden. Dies ist vor allem dann notwendig, wenn ein komponentenweises Zuweisen nicht korrekt w¨are. Typischerweise ist das dann der Fall, wenn Zeiger als Komponenten verwendet werden. In Abschnitt 6.1.5 wird anhand der Klasse String ausf¨uhrlich darauf eingegangen.
4.1.9
Erzeugung tempor¨arer Objekte
In der folgenden Anweisung wird dem Bruch w der in einen Bruch umgewandelte Wert zugewiesen:
1000
// 1000 in einen Bruch umwandeln und w zuweisen
w = Bsp::Bruch(1000); Der Ausdruck
Bsp::Bruch(1000) erzeugt einen tempor¨aren Bruch, wobei der dazugeh¨orige Konstruktor f¨ur einen Parameter aufgerufen wird. Es handelt sich im Prinzip um eine erzwungene Typumwandlung der ganzzahligen Konstante 1000 in den Typ Bruch. Durch die Anweisung
w = Bsp::Bruch(1000); wird also ein tempor¨ares Objekt der Klasse Bruch angelegt, u¨ ber den entsprechenden Konstruktor mit 1000 1 initialisiert, dem Objekt w zugewiesen und nach Beendigung der Anweisung wieder zerst¨ort. Beim Erzeugen tempor¨arer Objekte einer Klasse muss ein entsprechender Konstruktor definiert sein, dem die Argumente zur Umwandlung u¨ bergeben werden. In diesem Fall k¨onnen deshalb tempor¨are Br¨uche mit keinem, einem oder zwei Argumenten erzeugt werden:
Bsp::Bruch() Bsp::Bruch(42) Bsp::Bruch(16,100)
4.1.10
// tempor¨aren Bruch mit dem Wert 0/1 erzeugen // tempor¨aren Bruch mit dem Wert 42/1 erzeugen // tempor¨aren Bruch mit dem Wert 16/100 erzeugen
UML-Notation
Bei der Modellierung von Klassen hat sich die UML-Notation als Standard etabliert. Sie dient dazu, Aspekte von objektorientierten Programmen grafisch darzustellen. Abbildung 4.4 stellt die UML-Notation der Klasse Bruch dar. Bei der UML-Notation werden Klassen durch Rechtecke dargestellt. In den Rechtecken befinden sich, jeweils durch horizontale Linien getrennt, der Klassenname, die Attribute und die Operationen. F¨uhrende Minuszeichen bei Attributen und Operationen bedeutet, dass diese privat
Sandini Bib
154
Kapitel 4: Programmieren von Klassen
B s p : : B r u c h -
z a e h l e r : -
n e n n e r :
i n t i n t
+
B r u c h ( ) +
B r u c h ( z : i n t )
+
B r u c h ( z : i n t , n : i n t ) +
p r i n t ( )
Abbildung 4.4: UML-Notation der Klasse Bruch
sind. F¨uhrende Pluszeichen stehen f¨ur o¨ ffentlichen Zugriff. Falls eine Operation einen R¨uckgabetyp besitzt, wird dieser durch einen Doppelpunkt getrennt, hinter der Operation angegeben. Je nach Stand der Modellierung k¨onnen bei der UML-Notation Details entfallen. So k¨onnen z.B. der Paketname (der Namensbereich), die f¨uhrenden Zeichen zur Sichtbarkeit, die Datentypen der Attribute oder die Parameter der Operationen entfallen. Auch kann ganz auf die Angabe von Attributen und Operationen verzichtet werden. Die k¨urzeste Form einer UML-Notation f¨ur die Klasse Bruch w¨are also ein Rechteck, in dem nur das Wort Bruch steht.
4.1.11
Zusammenfassung
Klassen werden durch Klassenstrukturen mit dem Schl¨usselwort class deklariert.
Die Komponenten einer Klasse besitzen Zugriffsrechte, die durch die Schl¨usselw¨orter public und private vergeben werden. Der Default-Zugriff ist private.
Klassen sollten wie alle anderen globalen Symbole in einem Namensbereich deklariert werden.
Eine Struktur ist in C++ das gleiche wie eine Klasse, nur dass statt class das Schl¨usselwort struct verwendet wird und der Default-Zugriff public ist. Die Komponenten der Klassen k¨onnen auch Funktionen (so genannte Elementfunktionen) sein. Sie bilden typischerweise die o¨ ffentliche Schnittstelle zu den Komponenten, die den Zustand des Objekts definieren (es kann aber auch private Hilfsfunktionen geben). Elementfunktionen haben Zugriff auf alle Komponenten ihrer Klasse. F¨ur jede Klasse ist ein Default-Zuweisungsoperator vordefiniert, der komponentenweise zuweist.
Sandini Bib
4.1 Die erste Klasse: Bruch
155
Konstruktoren sind spezielle Elementfunktionen, die beim Erzeugen von Objekten einer Klasse aufgerufen werden und dazu dienen, diese zu initialisieren. Sie tragen als Funktionsnamen den Namen der Klasse und besitzen keinen R¨uckgabetyp, auch nicht void. Alle Funktionen m¨ussen mit Parameter-Prototypen deklariert werden. Funktionen k¨onnen u¨ berladen werden. Das bedeutet, dass der gleiche Funktionsname mehrfach vergeben werden darf, solange sich die Parameter in ihrer Anzahl oder ihren Typen unterscheiden. Eine Unterscheidung nur durch den R¨uckgabetyp ist nicht erlaubt. Der Bereichsoperator ::“ ordnet einem Symbol den G¨ultigkeitsbereich einer bestimmten ” Klasse zu. Er besitzt h¨ochste Priorit¨at. Zur Modellierung von Klassen wird standardm¨aßig die UML-Notation verwendet.
Sandini Bib
156
Kapitel 4: Programmieren von Klassen
4.2 Operatoren fur ¨ Klassen In C++ gibt es die M¨oglichkeit, Operatoren f¨ur eigene Klassen zu definieren. Dadurch ist es z.B. m¨oglich, Br¨uche wie andere Zahlen zu behandeln:
Bsp::Bruch a, b, c; ...
if (a < c) { c = a * b; } In diesem Abschnitt wird deshalb eine neue Version der Klasse Bruch vorgestellt, die es erm¨oglicht, bei der Arbeit mit Br¨uchen Operatoren zu verwenden. In diesem Zusammenhang wird auch das Schl¨usselwort this eingef¨uhrt, mit dem in einer Elementfunktion das Objekt, f¨ur das diese aufgerufen wurde, als Ganzes angesprochen werden kann.
4.2.1
Deklaration von Operatorfunktionen
Die M¨oglichkeit, Operatoren f¨ur Klassen zu definieren und anzuwenden, wird bei der Klasse Bruch exemplarisch an drei Operatoren aufgezeigt:
Operator *
zum Multiplizieren zweier Br¨uche
Operator *= zum multiplikativen Zuweisen eines Bruchs Operator <
zum Vergleichen zweier Br¨uche auf kleiner als“ ”
Die Klassenstruktur der Klasse Bruch enth¨alt nun zus¨atzlich die Deklaration der neuen Operatoren und dadurch insgesamt den folgenden Aufbau, der anschließend erl¨autert wird:
// klassen/bruch2.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse Bruch */ class Bruch { /* privat: kein Zugriff von außen */ private: int zaehler; int nenner;
Sandini Bib
4.2 Operatoren f¨ur Klassen
157
/* o¨ ffentliche Schnittstelle */ public: // Default-Konstruktor
Bruch (); // Konstruktor aus int (Z¨ahler)
Bruch (int);
// Konstruktor aus zwei ints (Z¨ahler und Nenner)
Bruch (int, int); // Ausgabe
void print (); // neu: Multiplikation mit anderem Bruch
Bruch operator * (Bruch); // neu: multiplikative Zuweisung
Bruch operator *= (Bruch); // neu: Vergleich mit anderem Bruch
};
bool operator < (Bruch);
} // **** ENDE Namespace Bsp ******************************** #endif
// BRUCH_HPP
Hinzugekommen sind die Deklarationen f¨ur die Operatoren. Diese werden mit Hilfe des Schl¨usselworts operator deklariert. Man beachte, dass bei der Deklaration der Operatoren jeweils nur ein Operand als Parameter angegeben ist, obwohl es sich in allen F¨allen um zweistellige Operatoren handelt. Denn auch bei Operatorfunktionen, die f¨ur eine Klasse definiert werden, existiert ein Objekt, f¨ur das die Operation jeweils aufgerufen wird. Dies ist immer der erste bzw. bei einstelligen Operatoren der einzige Operand und er wird auch hier nicht als Parameter u¨ bergeben. Der u¨ bergebene Parameter ist also jeweils der zweite Operand. Die Deklaration
class Bruch { ...
Bruch operator * (Bruch); ...
};
Sandini Bib
158
Kapitel 4: Programmieren von Klassen
ist also so zu verstehen, dass der Operator * f¨ur den Fall deklariert wird, dass der erste Operand ein Bruch ist (da die Deklaration in class Bruch stattfindet), und dass der zweite Operand ebenfalls ein Bruch ist (da als Parameter der Typ Bruch deklariert wird), Da vor operator * der Typ Bruch angegeben wird, wird als Ergebnis ein neuer Bruch zur¨uckgeliefert. Dies ist die konsequente Anwendung des objektorientierten Ansatzes, dass jede Operation eigentlich nur eine Nachricht an einen Empf¨anger ist, die eventuell Parameter besitzt. In objektorientierter Lesart wird nicht global die Multiplikation zweier Br¨uche sondern an einen Bruch die Nachricht bilde Produkt mit u¨ bergebenem Bruch“ gesendet. Die Nachricht ist in C++ die ” Funktion, und der Empf¨anger der Nachricht ist das Objekt, f¨ur das die Funktion aufgerufen wird. Entsprechend bedeutet die Deklaration
class Bruch { ...
bool operator < (Bruch); ...
}; dass der Operator < f¨ur den Fall deklariert wird, dass der erste Operand und der zweite Parameter Br¨uche sind (Deklaration innerhalb der Klasse Bruch und Bruch als Parametertyp) und ein Boolescher Wert zur¨uckgeliefert wird. Da C++ eine formatfreie Sprache ist, k¨onnen die Leerzeichen bei der Deklaration von Operatorfunktionen auch wegfallen:
class Bruch { ...
Bruch operator*(Bruch); bool operator<(Bruch); ...
}; ¨ Uberladen von Operatoren Auch Operatoren k¨onnen u¨ berladen werden. Man k¨onnte z.B. die Multiplikation eines Bruchs mit verschiedenen Typen f¨ur den zweiten Operanden deklarieren:
class Bruch { ...
Bruch operator * (Bruch); Bruch operator * (int);
// Produkt mit Bruch als zweitem Operanden // Produkt mit int als zweitem Operanden
...
}; Dies gilt aber nur f¨ur eigene Datentypen. Die Operationen der fundamentalen Datentypen, die von C++ (wie auch von C) vordefiniert werden (char, int, float etc.), sind fest vordefiniert und k¨onnen auf diese Weise nicht erweitert werden.
Sandini Bib
4.2 Operatoren f¨ur Klassen
4.2.2
159
Implementierung von Operatorfunktionen
Auch die Operatorfunktionen m¨ussen nat¨urlich implementiert werden. Die erste Version der Klasse Bruch muss deshalb um die deklarierten Operatorfunktionen erweitert werden und erh¨alt folgenden Aufbau:
// klassen/bruch2.cpp // Headerdatei mit der Klassen-Deklaration einbinden
#include "bruch.hpp" // Standard-Headerdateien einbinden
#include #include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Default-Konstruktor */ Bruch::Bruch () : zaehler(0), nenner(1) {
// Bruch mit 0 initialisieren
// keine weiteren Anweisungen
} /* Konstruktor aus ganzer Zahl */ Bruch::Bruch (int z) : zaehler(z), nenner(1) {
// Bruch mit z 1tel initialisieren
// keine weiteren Anweisungen
} /* Konstruktor aus Z¨ahler und Nenner */ Bruch::Bruch (int z, int n) { /* neu: Ein negatives Vorzeichen im Nenner kommt in den Z¨ahler * Dies vermeidet u.a. eine Sonderbehandlung beim Operator < */ if (n < 0) { zaehler = -z; nenner = -n; }
Sandini Bib
160
Kapitel 4: Programmieren von Klassen
else { zaehler = z; nenner = n; } // 0 als Nenner ist allerdings nicht erlaubt
if (n == 0) { // Programm mit Fehlermeldung beenden
}
}
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
/* print */ void Bruch::print () { std::cout << zaehler << '/' << nenner << std::endl; } /* neu: Operator * */ Bruch Bruch::operator * (Bruch b) { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (zaehler * b.zaehler, nenner * b.nenner);
/* neu: Operator *= */ Bruch Bruch::operator *= (Bruch b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt, f¨ur das die Operation aufgerufen wurde (erster Operand), zur¨uckliefern
}
return *this;
Sandini Bib
4.2 Operatoren f¨ur Klassen
161
/* neu: Operator < */ bool Bruch::operator< (Bruch b) { /* Ungleichung einfach mit Nennern ausmultiplizieren * - da die Nenner nicht negativ sein k¨onnen, kann * der Vergleich dadurch nicht umgedreht werden */
}
return zaehler * b.nenner < b.zaehler * nenner;
} // **** ENDE Namespace Bsp ******************************** Bei allen drei Operatoren spielen zwei Br¨uche eine Rolle, da es sich jeweils um zweistellige Operatoren handelt, die definiert werden. Durch die Zuordnung an die Klasse Bruch u¨ ber den Bereichsoperator (Bruch::) wird festgelegt, dass die Operatorfunktion f¨ur einen Bruch als ersten Operanden aufgerufen wird. Der Parametertyp Bruch legt jeweils den Typ des zweiten Operanden fest. Der erste Operand ist also automatisch vorhanden, da die Funktion f¨ur ihn aufgerufen wird. Das bedeutet, dass seine Komponenten direkt angesprochen werden k¨onnen. Mit zaehler und nenner wird also der erste Operand angesprochen. Jeder andere Z¨ahler und Nenner muss u¨ ber den Namen des dazugeh¨origen Objekts angesprochen werden. Beim zweiten Operanden wird der Z¨ahler deshalb u¨ ber den Namen des Parameters mit b.zaehler und der Nenner entsprechend mit b.nenner angesprochen. Die Tatsache, dass u¨ berhaupt ein Zugriff auf private Komponenten eines anderen Objekts gleichen Typs m¨oglich ist, ist nicht selbstverst¨andlich. Die Datenkapselung ist in C++ nur typbezogen. Andere objektorientierte Sprachen wie Smalltalk definieren sie objektbezogen, was bedeutet, dass auch auf die Parameter der implementierten Klasse nur u¨ ber o¨ ffentliche Funktionen zugegriffen werden darf. Da zu jeder Funktion noch spezielle Vor¨uberlegungen oder Anmerkungen notwendig sind, wird nach diesen allgemeinen Hinweisen nun auf die Implementierung der Operatorfunktionen im Einzelnen eingegangen. Multiplikation mit dem Operator
Der Operator * multipliziert ein Bruch-Objekt mit einem anderen Bruch. Der erste Operand ist das Objekt, f¨ur das die Operation aufgerufen wird, der zweite Operand wird als Parameter u¨ bergeben. Zur¨uckgeliefert wird der Ergebnis-Bruch, wozu Z¨ahler und Nenner jeweils multipliziert werden. Auf ein K¨urzen wird der Einfachheit halber verzichtet. Im ersten Anlauf k¨onnte die Funktion folgendermaßen implementiert werden:
Sandini Bib
162
Kapitel 4: Programmieren von Klassen
Bruch Bruch::operator * (Bruch b) { // Ergebnis-Bruch anlegen Bruch ergebnis; // Produkt jeweils zuweisen
ergebnis.zaehler = zaehler * b.zaehler; ergebnis.nenner = nenner * b.nenner; // Ergebnis zur¨uckliefern
return ergebnis;
}
Diese Implementierung hat jedoch einen Laufzeitnachteil: Es wird unn¨otigerweise ein zus¨atzliches lokales Bruch-Objekt ergebnis verwendet. Dieses Objekt muss angelegt und wieder freigegeben werden. Beim Anlegen wird auch noch der Default-Konstruktor aufgerufen, der das Objekt zun¨achst fehlerhaft“ mit 01 initialisiert. Anschließend werden erst die richtigen“ Werte ” ” zugewiesen. Bei der tats¨achlichen Implementierung
Bruch Bruch::operator * (Bruch b) { return Bruch (zaehler * b.zaehler, nenner * b.nenner); } entf¨allt die falsche“ Initialisierung des lokalen Objekts. Stattdessen wird ein tempor¨arer neuer ” Bruch erzeugt, der auch gleich als R¨uckgabewert zur¨uckgeliefert wird. Indem man auf diese Weise das unn¨otige Erzeugen von Objekten vermeidet, kann zum Teil erheblich Zeit gespart werden. Vor allem bei komplexen Klassen mit zeitaufw¨andigen Initialisierungen macht sich dies bemerkbar. Vergleich mit dem Operator < Der Vergleichsoperator < stellt fest, ob ein Bruch, f¨ur den die Operation aufgerufen wurde (erster Operand), kleiner ist als der u¨ bergebene Parameter (zweiter Operand). An dieser Stelle gibt es eine kleine Falle: Um bei der Rechnung im Zahlenbereich der ganzen Zahlen zu bleiben, bietet es sich an, die Ungleichung auf beiden Seiten zun¨achst mit den beiden Nennern zu multiplizieren. Dabei muss allerdings beachtet werden, dass sich bei der Multiplikation mit einem negativen Nenner das Vergleichszeichen jeweils umkehrt. Es gilt: a b
<
c d
8 < ,: a
d < c
a
d > c
b
falls b und d beide positiv oder beide negativ sind
b
falls entweder b oder d negativ ist
Sandini Bib
4.2 Operatoren f¨ur Klassen
163
Deshalb m¨usste die Implementierung der Operation eigentlich eine entsprechende Fallunterscheidung enthalten:
int Bruch::operator< (Bruch b) { if (nenner * b.nenner > 0) { // die Multiplikation mit Nennern ist OK
return zaehler * b.nenner < b.zaehler * nenner; } else { // die Multiplikation mit Nennern dreht den Vergleich um
}
}
return zaehler * b.nenner > b.zaehler * nenner;
Diese Fallunterscheidung l¨asst sich aber vermeiden, indem sichergestellt wird, dass der Nenner eines Bruchs nicht negativ sein kann. Da nicht nur s¨amtliche Operationen f¨ur existierende Br¨uche, sondern auch das Initialisieren neuer Br¨uche selbst definiert wird, ist dies m¨oglich. Die einzige Stelle, an der ein Bruch mit negativem Nenner entstehen kann (n¨amlich im int/int-Konstruktor), wird deshalb so umgeschrieben, dass ein eventuell vorhandenes negatives Vorzeichen des Nenners in den Z¨ahler gezogen wird:
Bruch::Bruch (int { if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
z, int n) -z; -n; z; n;
...
} Damit kann das Umdrehen des Vergleichs bei negativem Nenner entfallen und der Vergleichsoperator folgendermaßen implementiert werden:
bool Bruch::operator < (Bruch b) { return zaehler * b.nenner < b.zaehler * nenner; } Dies ist ein gutes Beispiel f¨ur den Vorteil der Datenkapselung: Solange die Schnittstelle nicht ver¨andert wird, kann die Verwaltung eines Bruchs intern in der Klasse beliebig implementiert
Sandini Bib
164
Kapitel 4: Programmieren von Klassen
und unter verschiedenen Gesichtspunkten optimiert werden. H¨atte der Anwender eines Bruchs direkten Zugriff auf die Komponenten zaehler und nenner, w¨are dies nicht m¨oglich. Multiplikative Zuweisung mit dem Operator = C++ besitzt zahlreiche Zuweisungsoperatoren. Neben der eigentlichen Zuweisung mit dem Operator = gibt es noch die wertver¨andernden Zuweisungen der Form +=, *= und so weiter (siehe Abschnitt 3.2.3). Auch diese wertver¨andernden Zuweisungen k¨onnen f¨ur eigene Klassen definiert werden. Geschieht dies nicht, gibt es keinen Automatismus, der etwa daf¨ur sorgt, dass mit der Definition der Multiplikation auch die multiplikative Zuweisung nach obigem Muster definiert ist. Im Gegenteil, der Programmierer einer Klasse muss einen solchen Operator selbst definieren und bei der Implementierung auch selbst darauf achten, dass f¨ur seine Datentypen auch die Analogie der fundamentalen Datentypen weiterhin gilt: x op= y entspricht x = x op y Andersherum formuliert, ist f¨ur abstrakte Datentypen also keineswegs mehr sichergestellt, dass diese Analogie gilt. Klassen, f¨ur die das nicht der Fall ist, verstoßen aber sicherlich gegen das Ziel lesbaren Codes. Betrachten wir nach diesen Vorbemerkungen nun die Implementierung der multiplikativen Zuweisung. Sie ist so implementiert, dass sie sich wie eine Multiplikation mit Zuweisung verh¨alt:
Bruch Bruch::operator *= (Bruch b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt, f¨ur das die Operation aufgerufen wurde, zur¨uckliefern
}
return *this;
An dieser Stelle wird das Schl¨usselwort this verwendet. Der Ausdruck *this steht in allen Elementfunktionen f¨ur das Objekt, f¨ur das diese Funktion bzw. Operation aufgerufen wurde. In jeder Elementfunktion ist this automatisch als Zeiger auf das Objekt definiert, f¨ur das die Funktion aufgerufen wurde (Zeiger werden in Abschnitt 3.7.1 eingef¨uhrt). Der Ausdruck
*this dereferenziert diesen Zeiger und liefert das Objekt, auf das dieser Zeiger zeigt. Damit steht dieser Ausdruck f¨ur das Objekt, f¨ur das eine Elementfunktion jeweils aufgerufen wurde. Handelt es sich dabei um eine Operatorfunktion, bezeichnet *this also den ersten Operanden. Wenn also die Operation
x *= y aufgerufen wird, dann steht *this f¨ur den ersten Operanden x, w¨ahrend der zweite Operand y als Parameter b u¨ bergeben wird.
Sandini Bib
4.2 Operatoren f¨ur Klassen
165
*this wird außerdem in einer Return-Anweisung am Ende der Operation verwendet. Das bedeutet, die Operation *= liefert das Objekt, dem der neue Wert zugewiesen wurde, als R¨uckgabewert zur¨uck. Ein Aufruf wie x *= y ist also nicht unbedingt eine abgeschlossene Anweisung, sondern liefert auch etwas und kann somit Teil eines gr¨oßeren Ausdrucks sein. So k¨onnte man z.B. eine Bedingung
if ((x *= 2) < 10)
// Falls x nach Verdoppelung kleiner als 10 ist
formulieren. Die Tatsache, dass das Objekt, f¨ur das eine Zuweisung aufgerufen wurde, zur¨uckgeliefert wird, ist eine Eigenschaft, die in C++ wie in C grunds¨atzlich f¨ur alle fundamentalen Datentypen gilt. Verwendet wird dies vor allem beim Zuweisungsoperator, um Ausdr¨ucke wie
x = y = 10;
// 10 an y und dann y (also 10) an x zuweisen
oder // R¨uckgabewert von fopen() an fp zuweisen und dann auf NULL testen if ((fp = fopen(...)) != NULL) { ...
} zu erm¨oglichen. Auch die Default-Zuweisungsoperatoren von Klassen liefern das Objekt, dem etwas zugewiesen wurde, zur¨uck. Dies kann man ausnutzen, um den Operator *= noch pr¨agnanter zu formulieren:
Bruch Bruch::operator *= (Bruch b) { // ”x *= y” ==> ”x = x * y” und x zur¨uckliefern return *this = *this * b; } Man kann u¨ ber die Lesbarkeit solcher Ausdr¨ucke sicherlich streiten. F¨ur die einen ist das die Eleganz, f¨ur die anderen das Chaotische an C. Der Programmierer hat in C++ den Vorteil, dass er f¨ur seine eigenen Klassen selbst definieren kann, ob die Zuweisungsoperatoren das Objekt zur¨uckliefern sollen oder nicht. Wer dies nicht will, kann die multiplikative Zuweisung einfach so definieren, dass kein R¨uckgabewert zur¨uckgeliefert wird:
void Bruch::operator *= (Bruch b) { // ”x *= y” ==> ”x = x * y” return *this = *this * b; } Man beachte aber, dass dies in C++ eher un¨ublich ist. Anwender der Klasse werden u¨ blicherweise davon ausgehen, dass Zuweisungsoperatoren ein Teil eines gr¨oßeren Ausdrucks sein k¨onnen.
Sandini Bib
166
Kapitel 4: Programmieren von Klassen
Laufzeit kontra Konsistenz Der Operator *= ist in der vorgestellten Version als Umsetzung auf die Operatoren = und * implementiert. Damit ist sichergestellt, dass die Anweisung x *= a immer dasselbe leistet wie die Anweisung x = x * a. Diese Implementierung birgt aber einen m¨oglichen Laufzeitnachteil: Es wird noch eine weitere Funktion f¨ur die Multiplikation aufgerufen, die ein tempor¨ares Objekt f¨ur den R¨uckgabewert erzeugt, das dann noch zugewiesen wird. Man kann die Semantik des Operators nat¨urlich auch direkt implementieren:
Bruch Bruch::operator *= (Bruch b) { // Z¨ahler und Nenner direkt ausmultiplizieren
zaehler *= b.zaehler; nenner *= b.nenner; // Objekt, f¨ur das die Operation aufgerufen wurde, zur¨uckliefern
}
return *this;
Dies birgt aber die Gefahr der Inkonsistenz. Wenn z.B. die Definition der Multiplikation ge¨andert wird (z.B. weil zus¨atzlich gek¨urzt wird), muss auch die Definition dieser Operation ge¨andert werden. Beide Arten der Implementierung sind m¨oglich und haben Vor- und Nachteile. In der Praxis muss hier deshalb zwischen der Gefahr von Inkonsistenzen und dem Laufzeitnachteil abgewogen werden. Eine derartige Abw¨agung ist typisch und tritt beim Design von Klassen (und Software im Allgemeinen) h¨aufig auf. Wenn man sich daf¨ur entscheidet, den Operator direkt zu implementieren, sollte man aber unbedingt darauf achten, dass die multiplikative Zuweisung der Multiplikation mit Zuweisung entspricht. Dazu geh¨ort auch, dass der Operator *= das Objekt, f¨ur das er aufgerufen wird, (also den ersten Operanden) ver¨andert, der Operator * dies aber nicht tut.
4.2.3
Anwendung von Operatorfunktionen
Nachdem es nun m¨oglich ist, Br¨uche zu multiplizieren und zu vergleichen, kann diese F¨ahigkeit auch in Anwendungsprogrammen eingesetzt werden. Das folgende Programm zeigt eine m¨ogliche Anwendung der neuen Version der Klasse Bruch:
// klassen/btest2.cpp // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { Bsp::Bruch x; Bsp::Bruch w(7,3);
// Bruch x deklarieren // Bruch w deklarieren
Sandini Bib
4.2 Operatoren f¨ur Klassen
167
// Bruch w ausgeben
w.print();
// x das Quadrat von w zuweisen
x = w * w;
// solange x < 1000 while (x < Bsp::Bruch(1000)) { // x mit a multiplizieren
x *= w;
// und ausgeben
}
}
x.print();
Mit der Anweisung
x = w * w; wird dem Bruch x das Quadrat des Bruchs w zugewiesen. Dies sind zwei Operationen:
Zun¨achst wird die Operation * mit zwei Br¨uchen als Operanden (jeweils w) aufgerufen. Die Operation liefert, wie in der Klasse deklariert, als Ergebnis einen neuen tempor¨aren Bruch zur¨uck. Dieser zur¨uckgelieferte Bruch wird anschließend dem Bruch x zugewiesen. Hier wird wieder der Default-Zuweisungsoperator verwendet, der komponentenweise zuweist.
Die Schreibweise mit den Operatoren ist nur eine einfachere Art, die in der Spezifikation deklarierte Operatorfunktion aufzurufen. Eigentlich findet folgender Aufruf statt:
x.operator=(w.operator*(w));
// entspricht: x = w * w;
F¨ur w wird die Elementfunktion operator* mit dem Parameter w aufgerufen. Dies verdeutlicht erneut den Sachverhalt, dass in der objektorientierten Betrachtungsweise der erste Operand jeweils das Objekt ist, dem als Nachricht die Aufforderung zu einer Operation mit dem zweiten Operanden als Parameter gesendet wird. Wir bilden also nicht global das Produkt von zwei Werten, sondern wenden uns an den ersten Operanden mit der Bitte, das Produkt von sich selbst mit einem zweiten u¨ bergebenen Operanden auszurechnen und zur¨uckzuliefern. Das Ergebnis wird als Parameter verwendet, der beim Aufruf von operator= f¨ur das Objekt x u¨ bergeben wird. Wir wenden uns also an x mit der Bitte, den Wert des als Parameter u¨ bergebenen zweiten Operanden zu u¨ bernehmen. Die Schreibweise mit Angabe von operator kann in C++-Programmen auch wirklich zum Aufruf von Operatorfunktionen verwendet werden. Wenn man so will, ist die Tatsache, dass statt w.operator*(w) auch w * w geschrieben werden kann, eine Konzession an die Tatsache, dass wir es gew¨ohnt sind, mit Operatoren so umzugehen, und es auch als u¨ bersichtlicher empfinden.
Sandini Bib
168
Kapitel 4: Programmieren von Klassen
4.2.4
Globale Operatorfunktionen
Wie alle Funktionen k¨onnen auch Operatorfunktionen nicht objektorientiert (also als Komponente einer Klasse), sondern als globale Funktion definiert werden. Wenn der Compiler auf den Ausdruck
x * y trifft, hat er grunds¨atzlich zwei M¨oglichkeiten der Interpretation:
eine prozedurale Interpretation als operator*(x,y) und eine objektorientierte Interpretation als x.operator*(y).
Die objektorientierte Interpretation hat den Vorteil, dass sie direkt zur Klasse geh¨ort und damit Zugriff auf private Komponenten von x und, sofern y zur gleichen Klasse geh¨ort, y hat. Die prozedurale Interpretation geh¨ort nicht zur Klasse. In diesem Fall ist der erste bzw. einzige Operand nicht das Objekt, f¨ur das die Operation aufgerufen wird, sondern alle Operanden sind Parameter. Entsprechend ist dann innerhalb der Operatorfunktion auch kein this definiert. Da derartige Implementierungen von Operatorfunktionen nicht zur Klasse geh¨oren, k¨onnen sie auch sp¨ater als Hilfsfunktion“ vom Anwender erg¨anzt werden. Die folgende Operatorfunktion ” k¨onnte z.B. vom Anwender der Klasse Bruch als Erg¨anzung definiert werden:
/* Produkt von int mit Bruch * - global definiert */
Bsp::Bruch operator* (int i, Bsp::Bruch b) { return Bsp::Bruch(i) * b; } Da es sich nicht um eine Elementfunktion handelt (daran erkennbar, dass Bruch:: vor operator fehlt), m¨ussen beide Operanden als Parameter angegeben werden. Es handelt sich also um die zweistellige Operation *, die Multiplikation, die hier f¨ur einen Integer als ersten und einen Bruch als zweiten Operanden definiert wird. Da die Operation nicht zur Klasse Bruch geh¨ort, besteht nat¨urlich auch kein Zugriff auf private Komponenten des zweiten Operanden. Es kann also nicht einfach der Z¨ahler von b mit i multipliziert werden. Deshalb wird der erste Operand i in einen Bruch umgewandelt, und dann wird die in der Klasse Bruch f¨ur zwei Br¨uche definierte Multiplikation aufgerufen. Ein weiteres Beispiel zeigt die folgende Definition:
/* Negation * - global definiert */
Bsp::Bruch operator - (Bsp::Bruch b) { return b * Bsp::Bruch(-1); }
Sandini Bib
4.2 Operatoren f¨ur Klassen
169
Da es sich auch hier nicht um eine Elementfunktion handelt (Bruch:: fehlt), ist der angegebene Parameter der einzige Operand. Es handelt sich also um die einstellige Operation -, die Negation, die hier f¨ur einen Bruch definiert wird. Da auch diese Operation nicht zur Klasse geh¨ort, besteht nat¨urlich auch hier kein Zugriff auf private Komponenten des Operanden. Es kann also nicht einfach der Z¨ahler von b mit -1 multipliziert werden. Deshalb wird der ganze Bruch mit Hilfe der f¨ur Br¨uche o¨ ffentlich definierten Multiplikation mit einer in einen Bruch umgewandelten -1 multipliziert. Anwendbar w¨aren die Operationen z.B. wie folgt:
Bsp::Bruch x, y; ...
y = 3 * -x; Der Bruch x wird mit der global definierten Negation negiert und als zweiter Operand mit dem Integer 3 multipliziert. Das Ergebnis wird dem Bruch y zugewiesen.
4.2.5
Grenzen bei der Definition eigener Operatoren
Die bisher vorgestellten Beispiele zeigten die M¨oglichkeit, eigene Operatoren zu definieren. Bei der Definition von Operatorfunktionen gibt es aber allerdings Einschr¨ankungen. So ist es z.B. nicht m¨oglich, jeden beliebigen Operator f¨ur eigene Klassen zu definieren. Im Einzelnen bleibt zu selbst definierten Operatoren deshalb Folgendes festzuhalten:
Die Menge m¨oglicher Operatoren ist festgelegt und kann nicht erweitert werden. So ist es z.B. nicht m¨oglich, einen neuen Operator **“ zum Potenzieren zu definieren. ” Diese Einschr¨ankung erfolgte aus mehreren Gr¨unden. Zum einen erleichtert sie die Arbeit der Compiler erheblich. Zum anderen h¨atte man eine Regelung f¨ur die Festlegung von Syntax, Priorit¨at usw. von neuen Operatoren gebraucht, wof¨ur es keine triviale L¨osung gibt. Die Priorit¨at, die Syntax und die Auswertungsreihenfolge der Operatoren sind ebenfalls festgelegt und k¨onnen nicht ge¨andert werden. So hat die Multiplikation immer eine h¨ohere Priorit¨at als die Addition und wird von rechts nach links ausgewertet. Ein einstelliger Operator = kann nicht definiert werden. Dies ist sicherlich von Vorteil, da dadurch der Programm-Code f¨ur Objekte fremder Klassen bez¨uglich der Auswertung von Ausdr¨ucken besser nachzuvollziehen ist. Außerdem werden dadurch die Anforderungen an einen Compiler vereinfacht. Die f¨ur die fundamentalen Datentypen vordefinierten Operationen werden nicht automatisch auf abstrakte Datentypen u¨ bertragen. So ist z.B. mit der Multiplikation und der Zuweisung nicht automatisch der Operator *= definiert, sondern er muss, wie am Beispiel der Klasse Bruch gezeigt, explizit definiert werden. Das bedeutet aber auch, dass die f¨ur fundamentale Datentypen geltende Eigenschaft x *= a entspricht x = x * a“ nicht automatisch f¨ur Klassen gilt, da die Operatoren auch ” anders implementiert werden k¨onnen. Dies sollte aus Gr¨unden der Lesbarkeit nat¨urlich unbedingt vermieden werden.
Sandini Bib
170
Kapitel 4: Programmieren von Klassen
Die Operatoren f¨ur die fundamentalen Datentypen (int, char, Zeiger etc.) sind festgelegt und k¨onnen weder umdefiniert noch erweitert werden. Allerdings kann die Verkn¨upfung eines abstrakten Datentyps mit einem fundamentalen Datentyp als globale Funktion oder als Elementfunktion mit den hier angegebenen Einschr¨ankungen frei definiert werden. Folgende Operatoren k¨onnen nicht f¨ur eigene Datentypen u¨ berladen werden:
. :: sizeof .* ?: ¨ Ein Uberladen ist nicht erlaubt, da die Operatoren bereits eine vordefinierte Bedeutung f¨ur ¨ alle Objekte besitzen oder, wie im Fall des Operators ?:, die M¨oglichkeit des Uberladens einfach nicht als lohnenswert erachtet wurde (?: ist der einzige dreistellige Operator). Der Operator .* wird in Abschnitt 9.4 vorgestellt. Auf Seite 571 befindet sich eine Liste aller C++-Operatoren.
4.2.6
Besonderheiten spezieller Operatoren
F¨ur einige spezielle Operatoren gelten Besonderheiten, die im Folgenden aufgezeigt werden. Der Zuweisungsoperator = F¨ur jede Klasse ist ein Default-Zuweisungsoperator definiert. Er weist komponentenweise zu und liefert das Objekt zur¨uck, dem der neue Wert zugewiesen wurde. Falls das nicht sinnvoll ist, kann der Zuweisungsoperator f¨ur eine Klasse auch selbst definiert werden. Soll keine Zuweisung m¨oglich sein, muss der Zuweisungsoperator einfach nur privat deklariert werden. Die folgende Deklaration w¨urde z.B. eine Zuweisung zweier Br¨uche unm¨oglich machen:3
class Bruch { ...
private: // Zuweisung verboten, da privat
};
Bruch operator = (Bruch);
Da eine komponentenweise Zuweisung bei Klassen mit Zeigern als Komponenten meistens fehlerhaft ist, muss der Zuweisungsoperator f¨ur solche Klassen selbst definiert werden. Detailliertere Informationen und Beispiele dazu befinden sich in Abschnitt 6.1.5. 3
Der Zuweisungsoperator sollte eigentlich mit einer anderen Syntax definiert werden, wozu uns aber hier noch das Sprachmittel der Referenz fehlt. Auf Seite 366 befindet sich die korrekte Syntax.
Sandini Bib
4.2 Operatoren f¨ur Klassen
171
Inkrement- und Dekrement-Operatoren Die einstelligen Inkrement- und Dekrement-Operatoren ++ und -- gibt es f¨ur die fundamentalen Datentypen jeweils in zwei Versionen, als Pr¨afix- und als Postfix-Notation (siehe auch Seite 42). In der Pr¨afix-Notation
++x liefert der Ausdruck ++x bei fundamentalen Datentypen den Wert von x nach der Inkrementierung (erst wird erh¨oht, dann wird der Wert geliefert). In der Postfix-Notation
x++ liefert der Ausdruck x++ bei fundamentalen Datentypen den Wert von x vor der Inkrementierung (erst wird der Wert geliefert, dann wird erh¨oht). Dieser Unterschied wird z.B. gern bei einem Zugriff auf ein Element in einem Feld (Array) angewendet:
x = elems[i++]; In diesem Beispiel wird der Index i inkrementiert, und wird x der Wert des Elements mit dem Index i vor der Inkrementierung zugewiesen. Diese Unterscheidung kann auch f¨ur eigene Datentypen implementiert werden. Da es sich in beiden F¨allen aber um einstellige Operatoren handelt, wurde zur Unterscheidung festgelegt, dass der Postfix-Operator im Gegensatz zum Pr¨afix-Operator grunds¨atzlich mit einem DummyParameter vom Typ int deklariert werden muss. Die nachfolgende Deklaration definiert z.B. einen Pr¨afix- und einen Postfix-Inkrement-Operator f¨ur die Klasse X:
class X { ...
};
public: // Pr¨afix-Notation (++x) operator ++ (); operator ++ (int); // Postfix-Notation (x++)
Analog zu fundamentalen Datentypen sollten beide Implementierungen ein Objekt in irgendeiner Weise inkrementieren. Der Unterschied sollte auch hier nur im R¨uckgabewert der Operation bestehen. F¨ur den Dekrement-Operator gilt Entsprechendes. Der Indexoperator [ ] Auch der Indexoperator4 [ ], mit dem u¨ ber einen Index auf ein bestimmtes Element innerhalb eines Feldes zugegriffen werden kann, kann f¨ur eigene Klassen definiert werden. Dadurch erhalten solche Objekte einen Feld-Charakter, obwohl es gar keine Felder sind. Der Indexoperator ist ein zweistelliger Operator, wobei der u¨ bergebene Index der zweite Operand ist. Bei Elementfunktionen wird der Index also als Parameter u¨ bergeben. 4
Er wird auch als Indizierungsoperator oder Subskriptionsoperator bezeichnet.
Sandini Bib
172
Kapitel 4: Programmieren von Klassen
Ein typisches Beispiel daf¨ur sind Klassen f¨ur Mengen, in denen dann mit [ ] auf das i-te Element in der Menge zugegriffen werden kann. Die folgende Deklaration definiert z.B. den Operator [ ] f¨ur eine Klasse, deren konkrete Objekte (Instanzen) jeweils eine Menge von Personen sind:
class MengeVonPersonen { ...
public: // i-tes Element aus der Menge (also eine Person) zur¨uckliefern Person operator [] (int i); ...
}; Auf ein Objekt der Klasse MengeVonPersonen kann dann mit dem Operator [ ] zugegriffen werden, womit dann ein Element der Menge, also eine Person, angesprochen wird:
void f (MengeVonPersonen alleAngestellten) { Person p; // erstes Element aus Menge alleAngestellten an person zuweisen
person = alleAngestellten[0]; ...
} Da der Typ des Parameters frei definierbar ist, kann der Index einen beliebigen Datentyp be¨ sitzen. Durch die M¨oglichkeit des Uberladens kann der Operator auch f¨ur verschiedene IndexDatentypen unterschiedlich implementiert werden. Auf diese Weise k¨onnen so genannte assoziative Felder (assoziative Arrays) implementiert werden. So w¨are es beispielsweise auch m¨oglich, beim Zugriff auf die Menge von Personen mit dem Operator [ ] als Index keinen ganzzahligen Wert, sondern den Namen der Person zu u¨ bergeben, die zur¨uckgeliefert werden soll. Die entsprechende Deklaration sieht dann wie folgt aus:
class MengeVonPersonen { ...
public: // i-tes Element aus der Menge (also eine Person) zur¨uckliefern Person operator [] (int i); // Person zu u¨ bergebenem Namen aus der Menge zur¨uckliefern
Person operator [] (std::string name); ...
}; Damit ist dann folgender Aufruf m¨oglich:
Sandini Bib
4.2 Operatoren f¨ur Klassen
173
void f (MengeVonPersonen alleAngestellten) { Person p; // Element "nico" aus Menge alleAngestellten an person zuweisen
person = alleAngestellten["nico"]; ...
} In Abschnitt 6.2.1 wird ein Beispiel f¨ur die Definition eines Indexoperators vorgestellt. Die Zeiger-Operatoren * und -> Genauso wie es sinnvoll sein kann, Objekte zu definieren, die sich wie Felder (Arrays) verhalten, kann es sinnvoll sein, Objekte zu definieren, die sich wie Zeiger verhalten. Da man die Semantik der Operationen frei definieren kann, kann man derartige zeigerartige Objekte mit gewisser Intelligenz versehen. Aus diesem Grund werden derartige Objekte auch Smart-Pointer genannt. In Abschnitt 9.2.1 wird darauf genauer eingegangen. Der Aufruf-Operator () Auch der Funktionsaufruf ist ein Operator, der f¨ur eigene Klassen definiert werden kann. Diese sicherlich etwas u¨ berraschende Tatsache auszunutzen, hat allerdings seltsame Konsequenzen. Seine Deklaration sieht wie folgt aus:
class X { ...
}
public: void operator () ();
// Operator () ohne Parameter deklarieren
Mit einer solchen Deklaration ist folgender Aufruf m¨oglich:
void f() { X a; a(); }
// NEIN, nicht Funktion a(), sondern // Operation () f¨ur Objekt a aufrufen
Es sieht so aus, als w¨urde eine globale Funktion a() aufgerufen; es handelt sich aber um den Aufruf des Operators () f¨ur das Objekt a. Dies ist sicherlich so verwirrend, dass es nicht sinnvoll erscheint, von der M¨oglichkeit, diesen Operator zu definieren, Gebrauch zu machen. Es kann aber Sinn machen, Objekte zu definieren, die sich wie Funktionen verhalten. Derartige Objekte nennt man auch Funktionsobjekte, oder kurz Funktoren. Sie werden auch in der C++-Standardbibliothek verwendet. Darauf wird in Abschnitt 9.2.2 eingegangen.
Sandini Bib
174
4.2.7
Kapitel 4: Programmieren von Klassen
Zusammenfassung
F¨ur die Objekte einer Klasse k¨onnen Operatoren definiert werden. Sie werden mit dem Schl¨usselwort operator als Operatorfunktionen deklariert. Operatoren k¨onnen f¨ur Klassen u¨ berladen werden. Die Menge m¨oglicher Operatoren, ihre Priorit¨at, Syntax und Auswertungsreihenfolge ist fest definiert und kann nicht ge¨andert werden. Operatoren sollten immer so implementiert werden, dass sie tun, was man grunds¨atzlich von ihnen erwartet. Das entspricht in der Regel dem, was mit ihnen bei fundamentalen Datentypen verbunden wird. Wenn Operatoren f¨ur eigene Klassen definiert werden, sollten z.B. folgende Beziehungen weiterhin gelten: x + y ver¨andert weder x noch y entspricht x + (-y) x - y entspricht x = x op y x op= y entspricht ++x x = x + 1 entspricht x += 1 entspricht (*p).k entspricht p[0] p->k In einer Elementfunktion bezeichnet this einen Zeiger auf das Objekt, f¨ur das die Funktion aufgerufen wurde. *this ist also immer das Objekt, f¨ur das eine Elementfunktion jeweils aufgerufen wurde. Bei Operatoren, die als Elementfunktion definiert werden, ist dies der erste bzw. einzige Operand.
Auf Seite 571 befindet sich eine Liste aller C++-Operatoren.
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
175
4.3 Laufzeit- und Codeoptimierungen In den bisherigen Abschnitten wurde gezeigt, wie eine Klasse implementiert werden kann, die bestimmten Anforderungen gen¨ugen soll (erzeugen, multiplizieren, ausgeben etc.). Das Was“ ” ¨ (Was soll eine Klasse leisten?) stand im Vordergrund der Uberlegungen, die zu den ersten Versionen f¨uhrten. In diesem und dem folgenden Abschnitt wird nun der Blick auf das Wie“ gelenkt ” (Wie leistet es die Klasse?). Dabei steht vor allem ein Aspekt im Vordergrund, der bereits bei der Verbreitung von C eine nicht unerhebliche Rolle gespielt hat: das Laufzeitverhalten. Auch in C++ hat das Laufzeitverhalten eine sehr hohe Priorit¨at. So gibt es einige Sprachmittel, die aus objektorientierter Sicht nicht notwendig sind und nur dazu dienen, die Laufzeit zu verbessern. Ihr Einsatz kann im Zweifelsfall auch auf Kosten objektorientierter Konzepte gehen. Zur Verbesserung der Laufzeit wird in diesem Abschnitt zun¨achst auf Inline-Funktionen und Deklarationen im Block eingegangen. In Abschnitt 4.4 wird dann ein weiteres wesentliches Sprachmittel zur Laufzeitverbesserung, n¨amlich Referenzen, eingef¨uhrt. Zus¨atzlich werden in diesem Abschnitt Default-Argumente und Using-Anweisungen vorgestellt. Beide Sprachmittel dienen allerdings weniger zur Verbesserung der Laufzeit (eher im Gegenteil), sondern zur Einsparung von Quellcode.
4.3.1
Die Klasse Bruch mit ersten Optimierungen
In der neuen Version der Klasse Bruch wird Folgendes ge¨andert:
Die drei Konstruktoren werden mit Hilfe von Default-Argumenten f¨ur den Z¨ahler und f¨ur den Nenner zu einer Funktion zusammengefasst. Zwei Funktionen (print() und operator*()) werden als so genannte Inline-Funktionen in der Headerdatei nicht nur deklariert, sondern auch gleich implementiert. Damit kann der Compiler Code generieren, der die Funktionen nicht aufruft, sondern die Anweisungen der Funktionen u¨ bernimmt. Da Funktionsaufrufe Zeit kosten, kann dies Laufzeit sparen. Da es keine Namenskonflikte gibt, werden bei der Verwendung der Klasse Bruch alle Symbole im Namensbereich Bsp als global definiert. Dies erspart es, Bsp:: jedes Mal angeben zu m¨ussen.
Headerdatei Die Headerdatei der Klasse Bruch erh¨alt mit der Einf¨uhrung der neuen Sprachmittel nun insgesamt folgenden Aufbau:
// klassen/bruch3.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include
Sandini Bib
176
Kapitel 4: Programmieren von Klassen
// **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse Bruch */ class Bruch { private: int zaehler; int nenner; public: /* neu: Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner in einem */
Bruch (int = 0, int = 1); /* Ausgabe * - neu: inline definiert */
void print () { std::cout << zaehler << '/' << nenner << std::endl; } // Multiplikation
Bruch operator * (Bruch); // multiplikative Zuweisung
Bruch operator *= (Bruch); // Vergleich
};
bool operator < (Bruch);
/* Operator * * - neu: inline definiert */
inline Bruch Bruch::operator * (Bruch b) { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
}
177
*/ return Bruch (zaehler * b.zaehler, nenner * b.nenner);
} // **** ENDE Namespace Bsp ******************************** #endif
// BRUCH_HPP
¨ Bevor auf die Anderungen im Einzelnen eingegangen wird, soll noch die dazugeh¨orige neue Quelldatei vorgestellt werden. Quelldatei ¨ in der Headerdatei nur In der Quelldatei der Klasse Bruch wird entsprechend den Anderungen noch ein Konstruktor definiert. Die Definition der beiden inline definierten Operatoren entf¨allt:
// klassen/bruch3.cpp // Headerdatei der Klasse einbinden
#include "bruch.hpp" // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* neu: Default-Konstruktor, Konstruktor aus ganzer Zahl und * Konstruktor aus Z¨ahler und Nenner in einem * - Default f¨ur z: 0 * - Default f¨ur n: 1 */
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
if (n == 0) {
// Programm mit Fehlermeldung beenden
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
} if (n < 0) { zaehler = -z;
Sandini Bib
178
}
Kapitel 4: Programmieren von Klassen
nenner = -n; } else { zaehler = z; nenner = n; }
/* Operator *= */ Bruch Bruch::operator *= (Bruch b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
/* Operator < */ bool Bruch::operator < (Bruch b) { // da die Nenner nicht negativ sein k¨onnen, reicht:
}
return zaehler * b.nenner < b.zaehler * nenner;
} // **** ENDE Namespace Bsp ******************************** ¨ Die in der Deklaration und in der Implementierung erfolgten Anderungen der Klasse Bruch werden in den folgenden Abschnitten erl¨autert.
4.3.2
Default-Argumente
In der neuen Version der Klasse Bruch werden die bisher verwendeten drei Konstruktoren zu einer Funktion zusammengefasst: F¨ur den Fall, dass der zweite Parameter (der Nenner) nicht u¨ bergeben wird, wird der Default-Wert 1 verwendet. Wird auch der erste Parameter (der Z¨ahler) nicht u¨ bergeben, wird daf¨ur 0 angenommen:
class Bruch { ...
Bruch (int = 0, int = 1); ...
};
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
179
Die angegebenen Default-Werte werden vom Compiler automatisch eingesetzt, wenn zu wenig Parameter u¨ bergeben werden. Da dies bereits beim Aufruf geschieht, ist es f¨ur die Implementierung der Funktion unerheblich, woher die Parameter kommen. Die Implementierung geht deshalb von zwei Parametern aus:
Bruch::Bruch (int z, int n) { ...
} Damit eine eindeutige Zuordnung m¨oglich ist, d¨urfen Default-Werte nur am Ende einer Parameterliste definiert werden. Beim Aufruf werden die u¨ bergebenen Argumente den Parametern der Reihe nach zugewiesen. Das erste Argument ist in jedem Fall der erste Parameter, das zweite Argument ist der zweite Parameter und so weiter. Sind alle Argumente aufgebraucht, erhalten die restlichen Parameter ihre Default-Werte. Es ist also eindeutig definiert, dass es sich bei der ¨ Ubergabe von nur einem Argument immer um den ersten Parameter handelt. Selbstverst¨andlich k¨onnen auch globale Funktionen Default-Werte besitzen. Die folgende globale Funktion max() liefert z.B. das Maximum von zwei bis vier vorzeichenfreien ganzen Zahlen:
unsigned max (unsigned, unsigned, unsigned = 0, unsigned = 0); Beim Aufruf der Funktion mit zwei Argumenten wird f¨ur die letzten beiden Parameter jeweils der Default-Wert 0 eingesetzt. Bei der Deklaration von Default-Argumenten muss beachtet werden, dass ein Leerzeichen vor dem Zuweisungsoperator steht, wenn es sich bei einem Parameter um einen Zeiger handelt,
void f (char* = "hallo");
// OK
Ansonsten wird angenommen, dass der Operator *= verwendet wird, und es wird eine entsprechende Fehlermeldung ausgegeben:
void f (char*= "hallo");
// FEHLER
Es ist nicht erlaubt, Default-Argumente in einer zweiten Deklaration neu zu definieren. F¨ur selbst definierte Operatoren sind Default-Argumente nicht zugelassen. Der Datentyp einer Funktion wird durch die Tatsache, dass manche Parameter Default-Werte besitzen, nicht beeinflusst. Ein Zeiger auf die Funktion max() m¨usste als Zeiger auf eine Funktion deklariert werden, der vier unsigneds u¨ bergeben werden:
unsigned (* funkzeiger) (unsigned, unsigned, unsigned, unsigned); Um auch hier wieder Default-Werte verwenden zu k¨onnen, m¨ussen diese wiederum definiert werden:
unsigned (* funkzeiger) (unsigned, unsigned, unsigned = 0, unsigned = 0); Wenn eine Funktion auch u¨ ber einen Funktionszeiger aufgerufen werden kann, k¨onnen zwar unterschiedliche Default-Werte verwendet werden – man sollte dies aber nie tun. Ein direkter Aufruf von max() mit zwei Parametern f¨uhrt sonst zu einem anderen Ergebnis als der Aufruf u¨ ber den Funktionszeiger, was sehr verwirrend sein kann.
Sandini Bib
180
Kapitel 4: Programmieren von Klassen
Default-Argumente k¨onnen Laufzeit kosten Eines sollte jedoch beachtet werden: Default-Argumente fassen mehrere Funktionen zusammen, ¨ ersparen Schreibarbeit, f¨uhren zu weniger Programm-Code und erh¨ohen die Konsistenz (Anderungen m¨ussen nur an einer Stelle durchgef¨uhrt werden). Die Laufzeit kann sich durch DefaultArgumente aber verl¨angern. Wird die Funktion max() z.B. mit zwei Argumenten aufgerufen, werden dennoch alle vier Parameter verglichen. Die neue Version der Klasse Bruch liefert daf¨ur ebenfalls ein Beispiel. Wird der Konstruktor ohne Argumente aufgerufen, wird der dann f¨ur den Nenner eingesetzte Default-Wert, obwohl dies nicht notwendig ist, auf 0 und einen negativen Wert gepr¨uft. Um einen solchen Laufzeitnachteil zu vermeiden, d¨urfen nur zwei Konstruktoren der Klasse Bruch zusammengefasst werden:
class Bruch { ...
public: /* Default-Konstruktor und Konstruktor f¨ur ganzzahligen Wert * - inline deklariert */
Bruch (int z = 0) : zaehler(z), nenner(1) { } /* Konstruktor aus Z¨ahler und Nenner
* - mit Sonderbehandlung f¨ur Nenner <= 0 */
Bruch (int, int); ...
};
4.3.3
Inline-Funktionen
Funktionen k¨onnen inline deklariert werden. Das Wort inline“ bedeutet dabei, dass ein Funk” tionsaufruf durch die Anweisungen der aufgerufenen Funktion ersetzt werden kann. Damit dies m¨oglich ist, werden bei der Deklaration in der Headerdatei auch gleich die entsprechenden Anweisungen definiert. Inline-Funktionen k¨onnen auf zweierlei Arten definiert werden:
Entweder wird die Funktion mit dem Schl¨usselwort inline in der Headerdatei definiert:
class Bruch { ...
bool operator < (Bruch); ...
};
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
181
inline bool Bruch::operator < (Bruch b) { return zaehler * b.nenner < b.zaehler * nenner; }
oder die Funktion wird bei der Deklaration direkt in der Klassenstruktur implementiert:
class Bruch { ...
void print () { std::cout << zaehler << '/' << nenner << std::endl; } ...
}; In beiden F¨allen wird damit dem Compiler die M¨oglichkeit gegeben, statt eines Funktionsaufrufs Code zu generieren, der die Anweisungen in der Funktion direkt ausf¨uhrt. Ein Aufruf wie
b.print() kann der Compiler also als
std::cout << b.zaehler << '/' << b.nenner << std::endl; u¨ bersetzen, da er weiß, dass dies semantisch den gleichen Effekt hat. Inline-Funktionen ersetzen damit die in C u¨ blichen Makros und haben einen wichtigen Vorteil: Inline-Funktionen sind kein blinder“ Textersatz, sondern besitzen semantisch betrachtet ” keinen Unterschied zu normalen Funktionen. So findet genauso wie bei Funktionen eine Typu¨ berpr¨ufung statt, und es existieren genau die gleichen G¨ultigkeitsbereiche. Auch sind keine Seiteneffekte durch eine fehlende Klammerung oder durch doppelten Ersatz eines Parameters wie n++ m¨oglich. F¨ur die Semantik eines Programms ist es deshalb absolut unerheblich, ob Funktionen inline deklariert werden oder nicht. Lediglich die Laufzeit wird dadurch beeinflusst. Aus diesem Grund steht es auch jedem Compiler frei, Inline-Funktionen doch nicht inline zu generieren oder Nicht-Inline-Funktionen inline zu generieren. Ein guter Compiler kann selbst entscheiden, ob es sich lohnt, f¨ur einen Funktionsaufruf Code zu generieren, der den Aufruf tats¨achlich durchf¨uhrt oder die Anweisungen der Funktion direkt ausf¨uhrt. Oft kann dies auch u¨ ber Compiler-Optionen beeinflusst werden. Um eine Ersetzung vornehmen zu k¨onnen, muss allerdings die Implementierung der Funktion bereits beim Kompilieren des Funktionsaufrufs bekannt sein. Aus diesem Grund m¨ussen Inline-Funktionen, die in mehr als einer Quelldatei verwendet werden sollen, in der Headerdatei implementiert werden. Damit erkl¨art sich auch, weshalb das Schl¨usselwort inline u¨ berhaupt angegeben werden muss: Es verhindert die Fehlermeldung, dass eine Funktion aufgrund mehrfacher Einbindung der Headerdatei in verschiedenen Quelldateien mehrfach definiert ist. Die Tatsache, dass Inline-Funktionen in Headerdateien implementiert werden, hat allerdings auch Nachteile. So bietet sich z.B. die M¨oglichkeit zur Manipulation der Implementierung einer Inline-Funktion. Diese M¨oglichkeit besteht bei einer Implementierung in einer kompilierten Quelldatei nicht. Hier zeigt sich bereits, dass in C++ ein Laufzeitvorteil zu Lasten streng ob-
Sandini Bib
182
Kapitel 4: Programmieren von Klassen
jektorientierter Konzepte gehen kann. Implementierungsdetails in der Klassenspezifikation unterzubringen, kann man aus streng objektorientierter Sicht als fragw¨urdig betrachten. Dies ist ein Punkt, der immer wieder zu heftigen Diskussionen Anlass gibt. F¨ur die einen disqualifiziert sich C++ damit als objektorientierte Sprache, f¨ur die anderen ist der Laufzeitvorteil das, was C++ erst interessant macht.
4.3.4
Optimierungen aus Anwendersicht
¨ F¨ur ein Anwendungsprogramm haben die in diesem Kapitel vorgestellten Anderungen an der Implementierung der Klasse Bruch keine Bedeutung. Es kann weiterhin die auf Seite 166 vorgestellte Version verwendet werden. Hier soll aber dennoch ein leicht abge¨andertes Anwendungsprogramm vorgestellt werden, anhand dessen die M¨oglichkeit aufgezeigt wird, Deklarationen inmitten von Bl¨ocken vorzunehmen und Angaben von Namensbereichen nur einmal machen zu m¨ussen:
// klassen/btest3.cpp // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { using namespace Bsp; // neu: alle Symbole des Namensbereichs std sind global Bruch w(7,3);
// Bruch w deklarieren
w.print();
// Bruch w ausgeben
// neu: x deklarieren und mit dem Quadrat von w initialisieren
Bruch x = w*w;
}
// solange x < 1000 while (x < Bruch(1000)) { // x mit w multiplizieren und ausgeben x *= w; x.print(); }
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
4.3.5
183
Using-Direktiven
Namensbereiche wurden eingef¨uhrt, um Namenskonflikte zu vermeiden und es deutlich zu machen, wenn mehrere Symbole zusammengeh¨oren. Dabei k¨onnen diese Symbole auch auf mehrere Dateien verteilt werden. Mit Hilfe des Schl¨usselworts using kann man allerdings die mitunter erm¨udende immer wiederkehrende Angabe des Namensbereiches vermeiden. Dabei gibt es zwei M¨oglichkeiten:
eine Using-Deklaration eine Using-Direktive
Using-Deklaration Mit einer Using-Deklaration wird ein Name aus einem Namensbereich lokal zugreifbar. Durch die Angabe
using Bsp::Bruch; wird Bruch im aktuellen G¨ultigkeitsbereich zu einem lokalen Synonym f¨ur Bsp::Bruch. Derartige Deklarationen entsprechen einer Deklaration lokaler Variablen und k¨onnen auch globale Variablen u¨ berdecken. Beispiel:
namespace X { int i, j, k; }
// Namensbereich X
int k;
// globales k
void usingDeklarationen() { int j = 0; // Name i aus Namensbereich X lokal zugreifbar using X::i; using X::j; // FEHLER: j ist in f2() zweimal deklariert using X::k; // u¨ berdeckt globales k
}
++i; ++k;
// X::i // X::k
Using-Direktiven Eine Using-Direktive erm¨oglicht es, alle Namen eines Namensbereiches ohne Qualifizierung anzusprechen. Alle Symbole, die sich in diesem Namensbereich befinden, sind damit auch global definiert, was entsprechende Konflikte ausl¨osen kann. Durch die Direktive
using namespace Bsp; werden alle Symbole des Namensbereichs Bsp im aktuellen G¨ultigkeitsbereich zu globalen Variablen.
Sandini Bib
184
Kapitel 4: Programmieren von Klassen
Befindet sich im globalen G¨ultigkeitsbereich bereits ein Symbol, das sich auch im global gewordenen Namensbereich befindet, wird bei dessen Verwendung eine Fehlermeldung ausgegeben. Beispiel:
namespace X { int i, j, k; }
// Namensbereich X
int k;
// globales k
void usingDirektive() { int j = 0; using namespace X; ++i; ++j; ++k; }
// Namensbereich X global zugreifbar // X::i // lokales j // FEHLER: X::k oder globales k?
Man beachte, dass man Using-Direktiven nie in einem Kontext verwenden sollte, in dem nicht klar ist, welche Symbole bekannt sind. Eine Using-Direktive kann ansonsten dazu f¨uhren, dass sich die Sichtbarkeit anderer Symbole a¨ ndert, was zu Mehrdeutigkeiten und sogar zu ge¨andertem Verhalten f¨uhren kann (ein Funktionsaufruf findet pl¨otzlich eine ganz andere Funktion als ohne die Direktive). Generell ist die Verwendung von Using-Direktiven deshalb mit Vorsicht zu genießen. Insbesondere deren Verwendung in Headerdateien ist untragbar schlechtes Design. Koenig-Lookup Auch ohne die Verwendung von using ist es nicht immer notwendig, den Namensbereich eines Symbols mit anzugeben. Sofern man eine Operation mit Argumenten aufruft, wird die Operation auch in allen Namensbereichen der u¨ bergebenen Argumente gesucht. Diese Regel wird als Koenig-Lookup bezeichnet. Beispiel:
#include "bruch.hpp" // erg¨anzende Definition von globalen Operationen in Namensbereich Bsp
namespace Bsp { void hilfsfunktion (Bruch); } ...
Bsp::Bruch x; ...
hilfsfunktion(x);
// OK, Bsp::hilfsfunktion() wird gefunden
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
4.3.6
185
Deklarationen zwischen Anweisungen
In der neuen Version des Anwendungsprogramms ist außerdem bemerkenswert, dass die Deklaration von x nicht am Anfang, sondern erst mitten im Anweisungsblock, nachdem bereits einige Anweisungen durchgef¨uhrt wurden, stattfindet:
w.print();
// Anweisung
Bruch x = w*w;
// Deklaration mit Initialisierung
In C++ ist es m¨oglich, dass Variablen an beliebiger Stelle in einem Block deklariert werden. Ihr G¨ultigkeitsbereich reicht dann von der Deklaration bis zum Blockende. Eingef¨uhrt wurde dies, um zu vermeiden, dass Variablen unter Umst¨anden zu einem Zeitpunkt deklariert werden m¨ussen, zu dem sie noch nicht initialisiert werden k¨onnen. Wenn zum Anlegen eines Objekts ein Parameter gebraucht wird, der erst noch ermittelt werden muss, kann die Deklaration aufgeschoben werden, bis die entsprechenden Anweisungen durchgef¨uhrt wurden. Die Alternative w¨are ein falsches“ Initialisieren etwa mit dem Default-Konstruktor, wodurch ” das Objekt zwar initialisiert wird, diese Initialisierung sp¨ater aber wieder korrigiert werden muss. Dies kostet vor allem bei gr¨oßeren Objekten unn¨otig Zeit. Und wenn kein Default-Konstruktor definiert ist, k¨onnte gar kein Objekt angelegt werden. Man kann außerdem Klassen definieren, deren Objekte ihren Wert oder Teile ihrer Daten nicht modifizieren k¨onnen. Diese Objekte k¨onnen erst dann erzeugt werden, wenn ihr Initialwert bekannt ist. Diese Art der Deklaration birgt nat¨urlich die Gefahr, dass Programme schwerer lesbar werden, da die Deklaration von Variablen jetzt nicht mehr auf den Beginn der ge¨offneten Bl¨ocke beschr¨ankt bleibt. Aus diesem Grund sollte von der M¨oglichkeit, Deklarationen nicht am Anfang eines Blocks vorzunehmen, nur im Ausnahmef¨allen Gebrauch gemacht werden. Es sollte sich zumindest immer um den Beginn eines logischen Abschnitts handeln. Deklarationen im Kopf der For-Schleife Eine in der Praxis h¨aufig vorkommende Anwendung von Deklarationen mitten im Code ist die For-Schleife. Sie l¨asst sich in C++ so implementieren, dass die Laufvariable erst im Schleifenkopf deklariert wird:
for (int i=0; i
} i ist dabei nur innerhalb der For-Schleife bekannt. Eine zweite Schleife mit einer weiteren Deklaration von i w¨are problemlos. Die hier gezeigte For-Schleife ist also gleichbedeutend mit folgender Implementierung:
{
int i; for (i=0; i
}
}
Sandini Bib
186
Kapitel 4: Programmieren von Klassen
Eine derartige Deklaration hat in der Praxis aber (noch) ein kleines Problem: In alten C++Versionen war i noch bis zum Ende des Blocks, in dem sich die gesamte For-Schleife befindet, bekannt. i war also auch noch nach der For-Schleife deklariert. Der Schleifenkopf z¨ahlte n¨amlich nicht zu dem Block, der den Schleifenk¨orper enth¨alt. Eine zweite For-Schleife im gleichen Block konnte somit nicht genauso implementiert werden:
for (int i=0; i
} ...
for (int i=0; i
// FEHLER: i zum zweiten Mal deklariert
...
} Diese sicherlich unsch¨one Eigenschaft wurde im Rahmen der Standardisierung von C++ inzwischen korrigiert. In Bedingungen von If-, Switch-, While- und For-Anweisungen d¨urfen Deklarationen erscheinen, die dann aber nur f¨ur den G¨ultigkeitsbereich dieser Anweisung gelten. F¨ur die gesamte Anweisung wird sozusagen ein eigener Block er¨offnet. Aufgrund der relativen sp¨aten ¨ Anderung der Sprache gibt es aber leider immer noch Systeme, f¨ur die i auch nach der Schleife noch bekannt ist, was bei der Implementierung portablen Codes unter Umst¨anden beachtet werden muss.
4.3.7
Copy-Konstruktoren
Im Beispielprogramm wird der Bruch x bei seiner Deklaration auch gleich mit dem tempor¨aren Bruch, der sich aus der Operation a * a ergibt, initialisiert:
Bruch w(7,3); ...
Bruch x = w*w;
// Deklaration und Initialisierung von x
Diese Schreibweise zur Initialisierung von x ist gleichbedeutend mit der Schreibweise
Bruch x(w*w);
// Deklaration und Initialisierung von x
und bedeutet, dass x mit einem Bruch (dem tempor¨aren Ergebnis von w*w) als Argument angelegt wird. Bei der Erzeugung von x wird also ein Konstruktor aufgerufen, der als Parameter wiederum einen Bruch enth¨alt. Einen solchen Konstruktor, der ein Objekt anhand eines existierenden Objekts gleichen Typs erzeugt, nennt man Copy-Konstruktor, da er im Prinzip eine Kopie eines existierenden Objekts anlegt. Wie der Zuweisungsoperator ist auch dies eine Funktion, die nicht explizit deklariert werden muss, da sie f¨ur jede Klasse automatisch definiert wird. Der Default-Copy-Konstruktor kopiert komponentenweise. Jede Komponente des zu erzeugenden Objekts wird mit der entsprechenden Komponente des als Parameter u¨ bergebenen Objekts initialisiert. Ist eine solche Komponente selbst ein Objekt einer Klasse, so wird auch daf¨ur der Copy-Konstruktor aufgerufen. In unserem
Sandini Bib
4.3 Laufzeit- und Codeoptimierungen
187
Beispiel werden also der Z¨ahler und der Nenner von x mit dem Z¨ahler und dem Nenner vom Ergebnis von w*w initialisiert. Auch hier kann es sein, dass ein komponentenweises Kopieren zum Anlegen von Kopien nicht geeignet ist. Dies ist typischerweise bei Verwendung von Zeigern als Komponenten der Fall. Deshalb kann und muss der Copy-Konstruktor f¨ur bestimmte Zwecke manchmal selbst definiert werden. In Abschnitt 6.1.3 wird darauf eingegangen. Copy-Konstruktor und Zuweisung Man beachte, dass es in C++ einen Unterschied zwischen einer Deklaration mit gleichzeitiger Initialisierung und einer Deklaration ohne Initialisierung mit sp¨aterer Zuweisung gibt. Die Initialisierung
Bsp::Bruch tmp; ...
Bsp::Bruch x = tmp;
// Copy-Konstruktor
bzw.
Bsp::Bruch tmp; ...
Bsp::Bruch x(tmp);
// Copy-Konstruktor
ist etwas v¨ollig anderes als die Zuweisung:
Bsp::Bruch tmp; ...
Bsp::Bruch x; x = tmp;
// Default-Konstruktor // Zuweisung
Im ersten Fall wird n¨amlich der Copy-Konstruktor aufgerufen, w¨ahrend im zweiten Fall zun¨achst der Default-Konstruktor und danach der Zuweisungsoperator aufgerufen wird. Prinzipiell kann es sogar sein, dass x im ersten Fall am Ende einen anderen Zustand besitzt als im zweiten Fall, da Konstruktoren und Zuweisungsoperator selbst implementiert werden und somit zu verschiedenen Ergebnissen f¨uhren k¨onnen. Aus Gr¨unden der Lesbarkeit sollte dies nat¨urlich im Allgemeinen nicht der Fall sein.
4.3.8
Zusammenfassung
F¨ur die Parameter von Funktionen k¨onnen Default-Argumente definiert werden. Diese werden verwendet, wenn die entsprechenden Parameter beim Funktionsaufruf nicht u¨ bergeben werden. Funktionen und Operatorfunktionen k¨onnen inline deklariert werden. Damit erh¨alt der Compiler die M¨oglichkeit, einen Funktionsaufruf durch die Anweisungen der Funktion zu ersetzen. Dies ist f¨ur die Semantik eines Programms v¨ollig unerheblich und dient nur dazu, die Laufzeit zu verk¨urzen. Deklarationen m¨ussen nicht unbedingt am Blockanfang, sondern k¨onnen an beliebiger Stelle erfolgen. Ihr G¨ultigkeitsbereich erstreckt sich von der Deklaration bis zum Blockende.
Sandini Bib
188
Kapitel 4: Programmieren von Klassen
Einen Konstruktor, der ein neues Objekt anhand eines existierenden Objekts gleichen Typs erzeugt, nennt man Copy-Konstruktor. Es existiert ein Default-Copy-Konstruktor, der komponentenweise kopiert. Mit Hilfe von Using-Deklarationen und Using-Direktiven kann die Qualifizierung eines Namensbereichs entfallen. Eine Funktion wird automatisch auch in allen Namensbereichen der u¨ bergebenen Argumente gesucht. Using-Direktiven sollten nie in Headerdateien verwendet werden.
Sandini Bib
4.4 Referenzen und Konstanten
189
4.4 Referenzen und Konstanten Dieser Abschnitt f¨uhrt in ein neues Sprachmittel von C++ ein, die Referenz. Dabei muss zun¨achst klargestellt werden, dass es sich bei Referenzen in C++ um etwas anderes als Zeiger (siehe Abschnitt 3.7.1) handelt. Die mitunter im Umfeld von C vorgenommene Gleichstellung der Begriffe Zeiger und Referenz sollte in C++ deshalb nicht beibehalten werden, um unn¨otige Verwirrung zu vermeiden. Beides sind allerdings Verweise auf andere Objekte. Aus diesem Grund werde ich den Begriff Verweis als Oberbegriff, der f¨ur Zeiger oder Referenz“ ” steht, verwenden. Referenzen sind ein wesentliches Sprachmittel zur Laufzeitverbesserung. Es gibt aber auch noch andere Gr¨unde, die ihre Einf¨uhrung in C++ notwendig machten. C-Programmierer werden dabei feststellen, dass die Verwendung von Referenzen auf Kosten einer unter C bestehenden Sicherheit bei der Parameter¨ubergabe geht.
4.4.1
Copy-Konstruktor und Parameterubergabe ¨
In C++ gilt, dass Parameter (wenn nicht anders angegeben) immer Kopien der u¨ bergebenen Argumente sind (Call-by-value-Mechanismus). Das bedeutet, dass mit jedem Funktionsaufruf, bei dem ein Bruch u¨ bergeben wird, automatisch der Copy-Konstruktor aufgerufen wird. Das Gleiche ¨ gilt f¨ur die Ubergabe eines R¨uckgabewerts. Der Aufruf des Operators *= ist ein Beispiel daf¨ur: Bei dem Ausdruck
x *= w der gleichbedeutend ist mit
x.operator*= (w) wird von w, da es als Parameter u¨ bergeben wird, eine Kopie angelegt, die in der Operatorfunktion verwendet wird. Der Parameter b in der Implementierung der Operator-Funktion ist also eine Kopie von w, die mit Hilfe des Default-Copy-Konstruktors erzeugt wurde und ohne Auswirkung auf die Aufrufumgebung innerhalb der Funktion manipuliert werden kann:
Bruch Bruch::operator *= (Bruch b) // b ist lokale Kopie vom zweiten Operanden // und k¨onnte beliebig manipuliert werden
{
*this = *this * b;
// ”x *= y” ==> ”x = x * y”
return *this; // Kopie vom ersten Operanden wird zur¨uckgeliefert
} Genauso wird von der Funktion eine Kopie von *this (in unserem Beispiel also eine Kopie von x) zur¨uckgeliefert. Diese Kopie wird ebenfalls mit Hilfe des Copy-Konstruktors als tempor¨ares Objekt erzeugt.
Sandini Bib
190
Kapitel 4: Programmieren von Klassen
Ein Anlegen von Kopien ist f¨ur einfache Datentypen wie ints, floats und Zeiger zun¨achst akzeptabel. Bei Objekten mit vielen mehr oder weniger komplexen Komponenten kann dies aber zu einem erheblichen Laufzeitnachteil f¨uhren, da mit jeder Parameter¨ubergabe eine Kopie des Objekts angelegt werden muss. Die unter C u¨ bliche Alternative w¨are es, Zeiger zu u¨ bergeben. Die Parameter sind dann Adressen der zu ver¨andernden Objekte. Von diesen werden zwar ebenfalls Kopien angelegt, doch zeigt auch eine Kopie einer Adresse auf dasselbe Objekt und erm¨oglicht es so, auf dessen Wert zuzugreifen. Zeiger emulieren damit das Gegenst¨uck zu call-by-value, n¨amlich call-by-reference. Wenn die Laufzeitnachteile, die durch das Anlegen von Kopien bei der Parameter¨ubergabe entstehen, nur mit Zeigern umgangen werden k¨onnten, w¨are es nicht m¨oglich, mit eigenen Datentypen wie mit fundamentalen Datentypen rechnen zu k¨onnen. Eine Multiplikation von Br¨uchen m¨usste dann mit einem Zeiger als zweitem Operanden deklariert und aufgerufen werden. Im Anwendungsprogramm h¨atte eine Multiplikation dann die Form:
x * &a
// x mit a (ohne Kopie) multiplizieren
Aus diesem Grund wurden Referenzen eingef¨uhrt. Sie erm¨oglichen es, Argumente zu u¨ bergeben, ohne dass Kopien angelegt oder Zeiger verwendet werden m¨ussen. Es gibt nun aber zwei Probleme:
Da die M¨oglichkeit besteht, dass das Objekt, das als Parameter u¨ bergeben wurde, ver¨andert wird, kann keine Konstante mehr u¨ bergeben werden. Dies betrifft z.B. tempor¨are Objekte, die grunds¨atzlich konstant sind. Es droht die Gefahr, dass Objekte, die zur Laufzeitersparnis als Referenz u¨ bergeben werden, in der aufgerufenen Funktion versehentlich ge¨andert werden.
Hier kommt uns jedoch die M¨oglichkeit zugute, Objekte als Konstanten deklarieren zu k¨onnen. Damit wird per Deklaration ausgeschlossen, dass der Wert eines u¨ bergebenen Parameters ver¨andert werden kann.
4.4.2
Referenzen
Eine als Referenz deklarierte Variable ist nichts weiter als ein zweiter Name, der f¨ur ein existierendes Objekt vergeben wird. Deklariert wird eine Referenz durch ein zus¨atzliches &. Die folgende Anweisung deklariert z.B. r als Referenz (zweiter Name) f¨ur a:
int& r = x;
// r ist eine Referenz (ein zweiter Name) f¨ur x
Obwohl die Schreibweise es C-Programmierern suggeriert, sollte man sich unbedingt von der Vorstellung l¨osen, dass es sich um einen Zeiger handelt. Eine Referenz ist vom Typ her kein Zeiger, sondern hat den gleichen Typ wie das Objekt, f¨ur das sie steht. Ihre Anwendung unterscheidet sich in keiner Weise von der f¨ur das Objekt, f¨ur das sie einen zweiten Namen definiert. Nach obiger Deklaration ist es v¨ollig unerheblich, ob im Folgenden r oder x verwendet wird. Die Deklaration einer Referenz erzeugt also kein neues Objekt, sondern definiert f¨ur ein existierendes Objekt eine alternative Bezeichnung. Das Objekt, f¨ur das sie steht, muss bei der Deklaration angegeben werden und kann nicht gewechselt werden.
Sandini Bib
4.4 Referenzen und Konstanten
191
Das folgende Beispiel soll dies verdeutlichen:
int x = 7; int y = 13; int& r = x;
// normale Variable mit 7 initialisiert // normale Variable mit 13 initialisiert // Referenz (zweiter Name) f¨ur x
Mit der Deklaration der Referenz r wird diese auch gleich mit x initialisiert:
x
7
y
13
r Wenn hier nun r verwendet wird, ist dies gleichbedeutend mit der Verwendung von x:
r = y;
// r bzw. x wird der Wert von y zugewiesen
Eine Zuweisung an r entspricht somit einer Zuweisung an x. Im Gegensatz zur Initialisierung wird hier r also kein neues Objekt zugeordnet, sondern ein neuer Wert zugewiesen, den somit auch x erh¨alt:
x
13
y
13
r
Referenzen als Parameter Wenn man Parameter als Referenzen deklariert, werden diese bei einem Funktionsaufruf mit den u¨ bergebenen Argumenten initialisiert. Es handelt sich dann aber um keine Kopie, sondern ¨ um einen zweiten Namen f¨ur das u¨ bergebene Argument. Jede Anderung, die in der Funktion am Parameter durchgef¨uhrt wird, wird damit auch am Argument, das u¨ bergeben wurde, durchgef¨uhrt (Call-by-reference-Mechanismus).5 Auf diese Weise l¨asst sich zum Beispiel eine Funktion implementieren, die zwei Argumente vertauscht:
void swap (int& a, int& b) { int tmp;
} 5
tmp = a; a = b; b = tmp;
Pascal-Programmierer kennen Referenzen als Parameter bereits. Sie werden dort durch das Schl¨usselwort
var bei der Parameterdeklaration realisiert.
Sandini Bib
192
Kapitel 4: Programmieren von Klassen
Durch einen Aufruf mit zwei Integer-Variablen werden wirklich deren Werte vertauscht:
int main () { using namespace std; int x = 7; int y = 13; cout << "x: " << x << " y: " << y << endl; swap (x, y); }
// x: 7 y: 13
// vertauscht Werte von x und y
cout << "x: " << x << " y: " << y << endl;
// x: 13 y: 7
a wird beim Aufruf von swap() ein zweiter Name f¨ur x und b ein zweiter Name f¨ur y. Eine Zuweisung an a a¨ ndert also x und eine Zuweisung an b a¨ ndert y. Ein derartiges Sprachmittel hat Vor- und Nachteile, und f¨ur C-Programmierer, denen das Sprachmittel der Referenzen nicht zur Verf¨ugung steht, kann dies je nach Sichtweise die Erf¨ullung eines Traums oder das Wahrwerden eines Alptraums sein:
Der Vorteil von Referenzen ist, dass zum Ver¨andern von Argumenten keine Zeiger mehr u¨ bergeben werden m¨ussen. F¨ur die obige Funktion reicht der Aufruf swap(x,y)“, und die Werte ” werden vertauscht. In C muss man f¨ur den gleichen Effekt swap(&x,&y)“ aufrufen und die ” swap()-Funktion mit Zeigern implementieren. Der Nachteil von Referenzen ist, dass man in C++ damit nicht mehr am Aufruf erkennen kann, ob die u¨ bergebenen Argumente ver¨andert werden k¨onnen. Bei einem Aufruf von swap(x,y)“ ist in C garantiert, dass beide Argumente nicht modifiziert werden k¨onnen, ” da auf jeden Fall Kopien angelegt werden. Um in C++ zu wissen, ob ein Argument innerhalb einer Funktion manipuliert werden kann, muss man sich die Deklaration der Funktion ansehen.
Man beachte, dass die Tatsache, dass es sich um Referenzen und nicht um Kopien handelt, wirklich einzig und allein von der Deklaration der Parameter abh¨angt. Die Anweisungen in der Funktion verwenden a und b, als w¨aren sie als einfache Integer definiert. Anwendung von Referenzen bei Klassen Bei der Klasse Bruch bietet es sich z.B. an, die Operatorfunktion *= mit Referenzen zu deklarieren, um das Anlegen von Kopien zu vermeiden:
Bruch& Bruch::operator *= (Bruch& b) { // ”x *= y” ==> ”x = x * y” *this = *this * b;
Sandini Bib
4.4 Referenzen und Konstanten
193
// Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
Durch die Deklaration von b als Referenz ist b nun keine Kopie vom zweiten Operanden mehr, sondern ein zweiter Name f¨ur ihn. Auch der R¨uckgabewert ist nun eine Referenz, also ein zweiter Name f¨ur das Objekt, das zur¨uckgeliefert wird. Es wird also der ge¨anderte erste Operand selbst und keine Kopie von ihm zur¨uckgeliefert. Man beachte, dass die Anweisungen in der Funktion nicht ge¨andert wurden. Wer zun¨achst Schwierigkeiten beim Lesen der Deklaration hat, sollte sich zum Umgang mit Referenzen einfach vorstellen, die Zeichen f¨ur die Referenz-Deklaration & w¨aren nicht vorhanden.
4.4.3
Konstanten
Referenzen als Konstanten Bei der Operatorfunktion *= gibt es nun allerdings noch ein Problem: Da es per Deklaration m¨oglich ist, dass der Wert des u¨ bergebenen Parameters ge¨andert wird, muss es sich um ein Objekt handeln, dem etwas zugewiesen werden darf. Damit aber scheiden sowohl Konstanten als auch tempor¨are Objekte als Parameter aus. Ein Aufruf wie
x *= Bsp::Bruch(2,3); oder
x *= w * w; w¨are nicht m¨oglich. Aus diesem Grund sollte ein als Referenz u¨ bergebener Parameter, wenn er nicht ver¨andert wird, unbedingt als Konstante deklariert werden. Es ergibt sich die folgende Definition des Operators *=:
Bruch& Bruch::operator *= (const Bruch& b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
Damit kann als zweiter Operand auch eine Konstante oder ein tempor¨ares Objekt verwendet werden.
Sandini Bib
194
Kapitel 4: Programmieren von Klassen
Auch den R¨uckgabewert kann man als konstante Referenz deklarieren: const Bruch& Bruch::operator *= (const Bruch& b)
{
// ”x *= y” ==> ”x = x * y”
*this = *this * b;
// Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
Dies bedeutet, dass von der Operation der erste Operand nicht a¨ nderbar zur¨uckgeliefert wird. Man kann dem zur¨uckgelieferten Wert dann zum Beispiel nichts mehr zuweisen. Der Aufruf
if ((x *= 2) < 7)
// falls x nach Verdoppelung kleiner als 7 ist
w¨are also m¨oglich; der Aufruf
(x *= 2) *= x
// x verdoppeln und danach quadrieren
ergibt aber eine Fehlermeldung, die ohne die Deklaration des R¨uckgabewerts als Konstante nicht entsteht. Um Missverst¨andnissen vorzubeugen sei darauf hingewiesen, dass die Verwendung x in dem Fall nur im Rahmen seiner Verwendung als erster Operand vom Operator *= eingeschr¨ankt werden w¨urde. Selbstverst¨andlich kann x nach der Operation weiter ver¨andert werden:
x *= 2; x *= x;
// OK
Insofern w¨are es zu u¨ berlegen, Objekte, die als Referenz zur¨uckgeliefert werden, immer auch als Konstanten zur¨uckzuliefern, um Manipulationen von R¨uckgabewerten innerhalb der gleichen Anweisung auszuschließen. Es ist in C++ bei Zuweisungen allerdings u¨ blich, den ersten Operanden als nicht-konstante Referenz zur¨uckzuliefern. Konstanten allgemein Jedes Symbol kann in C++ als Konstante definiert werden. Bei seiner Deklaration muss einfach zus¨atzlich das Schl¨usselwort const angegeben werden. Compiler testen in diesem Fall, ob der Wert auch wirklich nicht ver¨andert wird. Beispiele:
const int MAX = 3; const double pi = 3.141592654; Konstanten ersetzen die in C u¨ bliche Praxis, f¨ur den Programm-Code geltende Konstanten u¨ ber die Pr¨aprozessor-Anweisung #define zu definieren. Die blinde“ Ersetzung durch den Pr¨apro” zessor wird durch eine Deklaration eines Symbols ersetzt, f¨ur das genau wie bei Variablen eine strenge Typpr¨ufung durchgef¨uhrt wird und ein G¨ultigkeitsbereich vorgegeben ist. Der einzige aber entscheidende Unterschied zu Variablen ist, dass ihr Wert nicht ver¨andert werden darf. Daraus folgt, dass ihr Wert bei der Deklaration festgelegt werden muss. Jede Konstante muss also bei der Deklaration initialisiert werden.
Sandini Bib
4.4 Referenzen und Konstanten
195
Auf diese Weise l¨asst sich auch ein Bruch als Konstante deklarieren:
const Bsp::Bruch mwst(16,100); Wenn f¨ur die Operation *= als zweiter Operand eine Konstante zugelassen ist, ist daf¨ur auch der Aufruf x *= mwst erlaubt.
4.4.4
Konstanten-Elementfunktionen
Was ist aber mit dem ersten Operanden? Bei der Operation *= wird dieser ver¨andert, und es macht somit Sinn, dass als Operand keine Konstante verwendet werden kann. Bei der reinen Multiplikation (w * w) macht es hingegen Sinn, dass auch der erste Operand, f¨ur den der Operator aufgerufen wird, eine Konstante sein kann. Er wird dadurch schließlich nicht ver¨andert. Damit dies m¨oglich ist, muss eine Elementfunktion, die f¨ur konstante Objekte aufgerufen werden kann, als solche gekennzeichnet werden. Eine solche Konstanten-Elementfunktion (englisch: constant member function) wird durch das Schl¨usselwort const zwischen der Parameterliste und dem Funktionsk¨orper deklariert. Die Multiplikation muss dazu wie folgt deklariert werden:
Bruch Bruch::operator * (const Bruch& b) const { return Bruch (zaehler*b.zaehler, nenner*b.nenner); } Das const am Ende der Deklaration legt fest, dass der Bruch, f¨ur den diese Operation aufgerufen wird (also der erste Operand), nicht ver¨andert wird. Jeder Versuch, zaehler oder nenner zu manipulieren, wird deshalb zu einem Syntaxfehler f¨uhren. Man beachte, dass bei der Multiplikation keine Referenz zur¨uckgeliefert werden darf, da es sich beim R¨uckgabewert um ein lokales Objekt handelt. Ansonsten wird ein zweiter Name f¨ur etwas zur¨uckgeliefert, das mit Beendigung der Funktion gar nicht mehr existiert, n¨amlich der durch den Ausdruck
Bruch (zaehler*b.zaehler, nenner*b.nenner) erzeugte tempor¨are Bruch. Die meisten Compiler erkennen einen derartigen Fehler und geben eine entsprechende Warnung aus. Da eine Kopie und keine Referenz zur¨uckgeliefert wird, muss der R¨uckgabewert nicht mit const deklariert werden. Tempor¨are Objekte sind grunds¨atzlich konstant.
4.4.5
Die Klasse Bruch mit Referenzen
Nun ist es an der Zeit, sich die neue Version der Klasse Bruch mit einem entsprechend ge¨anderten Anwendungsprogramm anzusehen. Headerdatei Die Headerdatei deklariert nun die Funktionen, soweit es sinnvoll ist, mit Referenzen und Konstanten und hat folgenden Aufbau:
Sandini Bib
196
Kapitel 4: Programmieren von Klassen
// klassen/bruch4.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { private: int zaehler; int nenner; public: /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1); // Ausgabe (inline definiert)
void print () const { std::cout << zaehler << '/' << nenner << std::endl; } // Multiplikation
Bruch operator * (const Bruch&) const; // multiplikative Zuweisung
const Bruch& operator *= (const Bruch&); // Vergleich
};
bool operator < (const Bruch&) const;
/* Operator * * - inline definiert */
Sandini Bib
4.4 Referenzen und Konstanten
197
inline Bruch Bruch::operator * (const Bruch& b) const { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (zaehler * b.zaehler, nenner * b.nenner);
} // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Die Elementfunktionen print(), operator*() und operator<() ver¨andern das Objekt, f¨ur das sie aufgerufen werden (bzw. den ersten Operanden), nicht und werden deshalb als Konstanten-Elementfunktionen deklariert. Bei allen drei Operatorfunktionen wird der Parameter jetzt außerdem als Referenz u¨ bergeben. Durch die Deklaration als Konstante kann es sich dabei ebenfalls um Konstanten oder tempor¨are Objekte handeln. Quelldatei In der Quelldatei werden die Deklarationen entsprechend angepasst. Die Implementierung der Funktionen musste aber nirgendwo ge¨andert werden:
// klassen/bruch4.cpp // Headerdatei der Klasse einbinden
#include "bruch.hpp" // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Default-Konstruktor, Konstruktor aus ganzer Zahl, * Konstruktor aus Z¨ahler und Nenner * - Default f¨ur z: 0 * - Default f¨ur n: 1 */
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
Sandini Bib
198
Kapitel 4: Programmieren von Klassen
if (n == 0) { // Programm mit Fehlermeldung beenden
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
}
} if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
-z; -n; z; n;
/* Operator *= */ const Bruch& Bruch::operator *= (const Bruch& b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
/* Operator < */ bool Bruch::operator < (const Bruch& b) const { // da die Nenner nicht negativ sein k¨onnen, reicht:
}
return zaehler * b.nenner < b.zaehler * nenner;
} // **** ENDE Namespace Bsp ******************************** Anwendung Die ge¨anderten Deklarationen haben auf das Anwendungsprogramm u¨ berhaupt keinen Einfluss. Allerdings k¨onnen auch hier nun, soweit es sinnvoll ist, konstante Objekte verwendet werden:
Sandini Bib
4.4 Referenzen und Konstanten
199
// klassen/btest4.cpp // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { using namespace Bsp; // alle Symbole des Namensbereichs std sind global // neu: Bruch w als Konstante deklarieren const Bruch w(7,3);
w.print();
// Bruch a ausgeben
// x deklarieren und mit dem Quadrat von w initialisieren Bruch x = w * w;
}
// solange x < 1000 while (x < Bruch(1000)) { // x mit w multiplizieren und ausgeben x *= w; x.print(); }
print() kann f¨ur die Bruch-Konstante a nur aufgerufen werden, weil die Funktion als Konstanten-Elementfunktion deklariert wird. Man beachte außerdem, dass der Vergleich x < Bruch(1000) nur deshalb m¨oglich ist, weil der als zweite Operand an die Operatorfunktion < u¨ bergebene Parameter eine Konstante sein darf. Der Ausdruck Bruch(1000) liefert n¨amlich ein tempor¨ares Objekt, das wie alle tempor¨aren Objekte grunds¨atzlich konstant ist.
4.4.6
Zeiger auf Konstanten und Zeigerkonstanten
Zum Schluss dieses Abschnitts noch einige Anmerkungen zu der Arbeit mit Konstanten: Auch Zeiger k¨onnen auf Konstanten verweisen. Konstantheit ist allerdings Teil des Datentyps. Insofern muss schon bei der Deklaration festgelegt werden, dass das, auf was sie zeigen, nicht ver¨andert werden darf. Solche Zeiger auf Konstanten d¨urfen dann allerdings auch auf Variablen zeigen (aber nat¨urlich nicht umgekehrt). Beispiel:
int i; const int c = 77;
// int-Variable // int-Konstante
Sandini Bib
200
Kapitel 4: Programmieren von Klassen
const int* ip;
// Zeiger auf int-Konstante
ip = &c;
// OK: ip zeigt auf Konstante c
ip = &i;
// OK: ip zeigt jetzt auf Variable i
*ip = 33;
// FEHLER: i darf u¨ ber ip nicht ver¨andert werden
In diesem Fall wird das, worauf ip verweist, als konstant betrachtet (unabh¨angig davon, ob dies wirklich der Fall ist). Der Versuch, das, worauf ip verweist, zu manipulieren, wird deshalb mit einer entsprechenden Fehlermeldung vom Compiler quittiert. Im Gegensatz dazu wird eine Zeigerkonstante, also ein Zeiger, der immer auf das gleiche Objekt zeigt, wie folgt deklariert:
int i; const int c = 77;
// int-Variable // int-Konstante
int* const ip = &i;
// Zeigerkonstante auf i
ip = 33;
// OK: i wird 33 zugewiesen
ip = &c;
// FEHLER: ip darf nicht auf etwas anderes verweisen
In diesem Fall verweist der Zeiger ip konstant auf das Objekt, das ihm bei Initialisierung u¨ bergeben wurde. Der Versuch, ihn auf etwas anderes zeigen zu lassen, wird vom Compiler mit einer entsprechenden Fehlermeldung quittiert. Das, worauf er verweist, darf aber manipuliert werden. Selbstverst¨andlich ist auch eine Kombination beider Konstantheiten m¨oglich. Im folgenden Beispiel zeigt s konstant auf eine Zeichenfolge, deren Inhalt ebenfalls konstant ist:
const char* const s = "hallo"; Positionierung von const Das Schl¨usselwort const kann sowohl vor als auch hinter dem dazugeh¨origen Datentyp stehen:
const int i2 = 13; int const i1 = 12;
// OK // OK
Meistens wird das const aber vorangestellt. Entsprechend k¨onnen auch konstante Referenzen unterschiedlich deklariert werden:
void f1 (const int &); // OK void f2 (int const &); // OK Hinter dem Referenz-Zeichen ist es allerdings nicht erlaubt:
void f2 (int & const); // FEHLER Steht das const bei Zeigern zwischen dem Datentyp und dem Stern geh¨ort es zum Datentyp, auf den verwiesen wird. Die Deklaration
Sandini Bib
4.4 Referenzen und Konstanten
int const * ip;
201
// definiert Zeiger auf konstanten int
ist also a¨ quivalent zur folgenden Deklaration:
const int * ip;
// definiert Zeiger auf konstanten int
Konstanten werden zu Variablen Eine Warnung soll in Verbindung mit Konstanten noch ausgesprochen werden: Konstanten bieten keine absolute Sicherheit vor Ver¨anderung, da es u¨ ber eine explizite Typumwandlung prinzipiell m¨oglich ist, Konstanten zu Variablen zu machen. Sofern sich die Konstante nicht im Read-OnlyBereich eines Programms befindet, wird das funktionieren. Es gibt sogar Beispiele, in denen die Umwandlung einer Konstanten in eine Variable sinnvoll oder notwendig sein kann. Abschnitt 6.2.4 geht genauer darauf ein und stellt in dem Zusammenhang das Schl¨usselwort mutable vor. In Abschnitt 1 wurde dazu außerdem schon das Schl¨usselwort const_cast vorgestellt. An dieser Stelle muss auch darauf hingewiesen werden, dass nicht alle Compiler die Verwendung von Konstanten als Variablen als Fehler melden. Manche Compiler geben in einem solchen Fall auch nur eine Warnung aus (der GCC geh¨ort zum Beispiel dazu). Derartige Warnungen sollten allerdings immer als Fehler betrachtet werden.
4.4.7
Zusammenfassung
Durch die Angabe von & im Typ einer Deklaration wird eine Referenz deklariert. Eine Referenz ist ein (tempor¨arer) zweiter Name“ f¨ur ein existierendes Objekt. ” Durch Verwendung von Referenzen bei der Deklaration von Parametern und R¨uckgabewerten kann das Anlegen von Kopien verhindert werden (call-by-reference statt call-by-value). Referenzen m¨ussen bei der Deklaration initialisiert werden und k¨onnen das Objekt, f¨ur das sie stehen, nicht wechseln. Durch das Schl¨usselwort const k¨onnen Konstanten deklariert werden. Ein const hinter der Parameterliste einer Elementfunktion definiert eine so genannte Konstanten-Elementfunktion. In ihr darf das Objekt, f¨ur das sie aufgerufen wurde, nicht ver¨andert werden. Damit kann dieses Objekt auch eine Konstante sein.
const sollte immer verwendet werden, wenn etwas nicht ver¨andert werden soll oder kann. Damit wird insbesondere sichergestellt, dass man auch tempor¨are Objekte als Argumente verwenden kann.
Sandini Bib
202
Kapitel 4: Programmieren von Klassen
4.5 Ein- und Ausgabe mit Streams Wir verwenden bereits seit l¨angerem Objekte und Symbole der C++-Standardbibliothek zur Einund Ausgabe, wie std::cout, std::cerr und std::endl. Die Objekte und die dazugeh¨origen Klassen geh¨oren nicht zum Sprachkern von C++. Ihre Programmierung ist eine standardisierte Anwendung der Sprache, die mit den bisher vorgestellten Sprachmitteln und einigen Erg¨anzungen m¨oglich ist. In diesem Abschnitt wird nun n¨aher auf die grunds¨atzlichen Eigenschaften der Klassen und Objekte zur Ein- und Ausgabe eingegangen. Dazu geh¨ort insbesondere die M¨oglichkeit, die Ein- und Ausgabetechnik auf eigene Datentypen auszudehnen. Da zum vollen Verst¨andnis der Stream-Bibliotheken wichtige Sprachmittel bekannt sein m¨ussen, die noch nicht vorgestellt wurden (vor allem die Vererbung), wird in Kapitel 8 genauer darauf eingegangen und auch ein Blick hinter die Kulissen der Stream-Bibliothek geworfen. Dort werden dann auch Techniken f¨ur formatierte Ein- und Ausgaben sowie f¨ur den Zugriff auf Dateien mit Hilfe von Streams vorgestellt.
4.5.1
Streams
Die Ein- und Ausgabe wird in C++ mit Hilfe von Streams durchgef¨uhrt. Ein Stream ist sozusagen ein Datenstrom“, in dem Zeichenfolgen entlangfließen“. Als konsequente Anwendung der ” ” objektorientierten Idee sind Streams Objekte, deren Eigenschaften in Klassen definiert werden. F¨ur die Standardkan¨ale zur Ein- und Ausgabe sind verschiedene globale Objekte vordefiniert. Stream-Klassen Da es verschiedene Arten von I/O gibt (Eingabe, Ausgabe, Dateizugriff etc.), gibt es auch verschiedene Klassen daf¨ur. Die beiden wichtigsten sind:
istream Die Klasse istream ist ein Eingabestrom“ (input stream), von dem Daten gelesen wer” den k¨onnen. ostream Die Klasse ostream ist ein Ausgabestrom“ (output stream), auf den Daten ausgegeben ” werden k¨onnen.
Beide Klassen werden, wie alle Elemente der Standardbibliothek im Namensbereich std definiert. Dies kann man sich in etwa wie folgt vorstellen:
namespace std { class istream { ...
}; class ostream { ...
}; ...
}
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
203
Dies ist allerdings stark vereinfacht. In Wirklichkeit existieren zahlreiche weitere Klassen, und die Klassen werden auch etwas komplizierter definiert. In Abschnitt 8.1 werden die tats¨achlichen Klassen im Einzelnen vorgestellt und ihre Anwendungsm¨oglichkeiten aufgezeigt. Globale Stream-Objekte Zu den Klassen istream und ostream existieren drei globale Objekte, die eine zentrale Rolle bei der Ein- und Ausgabe spielen und die von der Stream-Bibliothek definiert werden:
cin
cin (Klasse istream) ist der Standard-Eingabekanal, von dem ein Programm in der Regel die Eingaben einliest. Er entspricht der C-Variablen stdin und wird von den Betriebssystemen typischerweise der Tastatur zugeordnet. cout
cout (Klasse ostream) ist der Standard-Ausgabekanal, auf den ein Programm in der Regel die Ausgaben schreibt. Er entspricht der C-Variablen stdout und wird von den Betriebssystemen typischerweise dem Bildschirm zugeordnet. cerr
cerr (Klasse ostream) ist der Standard-Fehlerausgabekanal, auf den ein Programm in der Regel die Fehlermeldungen schreibt. Er entspricht der C-Variablen stderr und wird von den Betriebssystemen typischerweise ebenfalls dem Bildschirm zugeordnet. Die Ausgaben auf cerr werden nicht gepuffert.
Durch die Trennung der Ausgaben in normale“ Ausgaben und Fehlermeldungen ist es m¨oglich, ” diese in der Betriebssystemumgebung, durch die das Programm aufgerufen wird, unterschiedlich zu behandeln. Dies resultiert aus dem UNIX-Konzept der Ein- und Ausgabeumlenkung, f¨ur das C urspr¨unglich geschrieben wurde. Die normalen Ausgaben eines Programms k¨onnen damit in eine Datei gelenkt werden. Die Fehlermeldungen werden aber weiterhin auf dem Bildschirm ausgegeben. Auch diese drei Stream-Objekte werden im Namensbereich std definiert, was man sich vereinfacht in etwa wie folgt vorstellen kann:
std::istream cin; std::ostream cout; std::ostream cerr; Es gibt noch ein viertes global vordefiniertes Objekt, clog, das allerdings keine so wesentliche Rolle spielt. Es ist f¨ur Protokollausgaben vorgesehen und gibt seine Ausgaben ebenfalls auf dem Standard-Fehlerausgabekanal, allerdings gepuffert, aus.
4.5.2
Umgang mit Streams
Die Verwendung dieser Objekte f¨ur Ein- und Ausgaben wird nachfolgend an einem einfachen Programm verdeutlicht. Dieses Programm liest zwei ganze Zahlen ein, teilt die erste durch die zweite und gibt das Ergebnis dieser Division aus:
Sandini Bib
204
Kapitel 4: Programmieren von Klassen
// io/iobsp.cpp // Headerdatei f¨ur I/O mit Streams
#include // allgemeine Headerdatei f¨ur EXIT_FAILURE
#include int main() { int x, y;
// Start-String ausgeben
std::cout << "Ganzzahlige Division (x/y)\n\n"; // x einlesen
std::cout << "x: "; if (! (std::cin >> x)) { /* Fehler beim Einlesen * => Fehlermeldung und Programmabbruch mit Fehlerstatus */ std::cerr << "Fehler beim Einlesen eines Integers" << std::endl; return EXIT_FAILURE; } // y einlesen
std::cout << "y: "; if (! (std::cin >> y)) { /* Fehler beim Einlesen * => Fehlermeldung und Programmabbruch mit Fehlerstatus */ std::cerr << "Fehler beim Einlesen eines Integers" << std::endl; return EXIT_FAILURE; } // Fehler, falls y null ist
if (y == 0) { /* Division durch null * => Fehlermeldung und Programmabbruch mit Fehlerstatus */
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
}
205
std::cerr << "Fehler: Division durch 0" << std::endl; return EXIT_FAILURE;
// Operanden und Ergebnis ausgeben
}
std::cout << x << " durch " << y << " ergibt " << x / y << std::endl;
Auch hier wird zun¨achst die Headerdatei f¨ur die Stream-Klassen und globalen Stream-Objekte, , eingebunden: // Headerdatei f¨ur I/O mit Streams
#include In der Zeile
std::cout << "Ganzzahlige Division (x/y):\n\n"; wird zun¨achst ein String-Literal auf den Standard-Ausgabekanal ausgegeben. Dies geschieht, indem f¨ur das Objekt std::cout die Operation << mit einem String als zweitem Operanden aufgerufen wird. Der Operator << ist der Ausgabeoperator f¨ur Streams und wird so definiert, dass das, was jeweils als zweiter Operand u¨ bergeben wird, in den Stream geschrieben wird (die Daten werden sozusagen in Richtung des Pfeiles geschickt). Das Besondere an diesem Operator ist, dass er nicht nur f¨ur alle Standarddatentypen u¨ berladen ist, sondern auch f¨ur beliebige eigene Datentypen u¨ berladen werden kann. Der zweite Operand kann damit jeden beliebigen Datentyp besitzen. Abh¨angig vom Datentyp wird automatisch die dazugeh¨orige Funktion aufgerufen, die den zweiten Operanden (umgewandelt in eine Zeichenfolge) ausgibt. Beispiele:
int i = 7; std::cout << i;
// gibt ‘‘7’’ aus
float f = 4.5; std::cout << f;
// gibt ‘‘4.5’’ aus
Bsp::Bruch b(3,7); std::cout << b;
// gibt, sofern so definiert, ‘‘3/7’’ aus
Dies ist eine wesentliche Verbesserung im Vergleich zur Ein- und Ausgabetechnik in C mit printf():
Das Format des auszugebenden Objekts muss nicht extra angegeben werden, sondern ergibt sich automatisch aus dessen Datentyp. Der Mechanismus ist nicht auf Standarddatentypen beschr¨ankt und somit universell einsetzbar.
Sandini Bib
206
Kapitel 4: Programmieren von Klassen
Mit dem Operator << k¨onnen mehrere Objekte in einer Anweisung ausgegeben werden. Die Ausgabeoperation liefert jeweils den ersten Operanden (also den Stream) zur weiteren Verwendung zur¨uck. Dies erm¨oglicht dann einen verketteten Aufruf des Ausgabeoperators, was in der letzten Zeile des Beispiels gezeigt wird:
std::cout << x << " durch " << y << " ergibt " << x / y << std::endl; Da der Operator << von links nach rechts ausgewertet wird, wird zun¨achst
std::cout << x ausgef¨uhrt. Da dieser Ausdruck wieder std::cout zur¨uckliefert, wird anschließend
cout << " durch " ausgef¨uhrt. Entsprechend werden anschließend der Integer y, gefolgt von der Stringkonstante " ergibt " und dem Ergebnis der Operation x / y (der Divisionsoperator hat eine h¨ohere Priorit¨at) ausgegeben. Besitzen x und y z.B. die Werte 91 und 7, wird Folgendes ausgegeben:
91 durch 7 ergibt 13 Wir k¨onnten uns mit dem jetzigen Wissen bereits die dazu notwendige Implementierung der Klasse ostream vorstellen. F¨ur Objekte dieses Typs wird der Operator << mit allen fundamentalen Datentypen als zweiten Operanden u¨ berladen:
namespace std { class ostream { public: ostream& operator<< ostream& operator<< ostream& operator<< ostream& operator<< ostream& operator<<
(char); (int); (long); (double); (const char*);
// Zeichen ausgeben // ganze Zahl ausgeben // ganze Zahl ausgeben // Gleitkommazahl ausgeben // C-String ausgeben
...
}
};
Beim Aufruf dieser Operatoren wird jeweils der erste Operand (also der Stream selbst) zur¨uckgeliefert:
namespace std { ostream& ostream::operator<< (char) { ...
} ...
}
return *this;
// Low-level-Funktion zum Ausgeben des Zeichens // Stream zur Verkettung zur¨uck
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
207
Manipulatoren Am Ende der meisten Ausgabeanweisungen wird ein so genannter Manipulator ausgegeben:
std::cout << std::endl Manipulatoren sind spezielle Objekte, deren Ausgabe, wie der Name schon sagt, eine Manipulation am Stream durchf¨uhrt. Damit k¨onnen z.B. Ausgabeformate definiert oder Puffer geleert werden. Es wird damit also nicht unbedingt etwas ausgegeben. Der Manipulator endl steht f¨ur endline“ und macht zwei Dinge: ” Er gibt ein Newline (Zeichen '\n') aus und
leert anschließend den Ausgabepuffer (schickt die Ausgabe also auch wirklich mit flush() ab).
Die wichtigsten vordefinierten Manipulatoren werden in Tabelle 4.1 aufgelistet. Manipulator
Klasse
std::flush std::endl std::ends std::ws
std::ostream std::ostream std::ostream std::istream
Bedeutung Ausgabepuffer leeren '\n' ausgeben und Ausgabepuffer leeren '\0' ausgeben und Ausgabepuffer leeren Trennzeichen (Whitespaces) u¨ berlesen
Tabelle 4.1: Die wichtigsten vordefinierten Manipulatoren
Was Manipulatoren genau sind, welche es noch gibt und wie man eigene definieren kann, wird in Abschnitt 8.1.5 erl¨autert. Der Eingabeoperator >> In der Zeile
if (! (cin >> x)) { ...
} wird der Integer x eingelesen. Dies geschieht mit dem Gegenst¨uck zum Ausgabeoperator, dem Eingabeoperator >>:
cin >> x Der Operator >> ist f¨ur Streams so definiert, dass das, was als zweiter Operand u¨ bergeben wird, eingelesen wird (auch hier werden die Daten sozusagen in Richtung des Pfeiles geschickt). Auch der Eingabeoperator kann prinzipiell f¨ur beliebige Datentypen u¨ berladen und verkettet aufgerufen werden:
float f; Bsp::Bruch b; std::cin >> f >> b; Dabei gilt grunds¨atzlich, dass f¨uhrende Trennzeichen (Whitespaces) jeweils u¨ berlesen werden.
Sandini Bib
208
Kapitel 4: Programmieren von Klassen
Man beachte, dass beim Einleseoperator der zweite Operand ein Parameter ist, dessen Wert ver¨andert wird. Die Implementierung dieser Operatoren erfolgt deshalb im Prinzip genauso wie beim Ausgabeoperator – mit der Besonderheit, dass Referenzen verwendet werden:
namespace std { class istream { public: istream& operator>> istream& operator>> istream& operator>> istream& operator>>
(char&); (int&); (long&); (double&);
// Zeichen einlesen // ganze Zahl einlesen // ganze Zahl einlesen // Gleitkommazahl einlesen
...
}
};
Streams und Boolesche Bedingungen Im obigen Beispiel wird der Eingabeoperator allerdings nicht verkettet aufgerufen, da anschließend sofort getestet wird, ob das Einlesen gelungen ist (das Einlesen kann immer schief gehen). Dies geschieht durch einen Aufruf des Operators !. Der Operator ist so definiert, dass er zur¨uckliefert, ob das Einlesen fehlerhaft war. In unserem Beispielprogramm f¨uhrt das zu einer entsprechenden Fehlermeldung und zu einem Programmabbruch mit Fehlerstatus:
if (! (std::cin >> x)) { std::cerr << "Fehler beim Einlesen eines Integers" << std::endl; return EXIT_FAILURE; } Was hier eigentlich geschieht, ist recht trickreich: Der Ausdruck
std::cin >> x liefert keinen Booleschen Wert, sondern, damit ein verketteter Aufruf m¨oglich ist, zun¨achst wieder std::cin. Erst der Operator !, angewendet auf das Stream-Objekt std::cin, liefert einen Booleschen Wert, der angibt, ob dieses Objekt einen fehlerhaften Zustand besitzt. Die Anweisung
if (! (std::cin >> x)) { ...
} entspricht also eigentlich:
std::cin >> x; if (! std::cin) { ...
}
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
209
F¨ur Stream-Objekte ist also der Operator ! so definiert, dass er jeweils einen Booleschen Wert zur¨uckliefert, der aussagt, ob sich der Stream in einem fehlerhaften Zustand befindet:
namespace std { class istream { public: bool operator! ();
// liefert true, falls Stream nicht in Ordnung ist
...
}
};
¨ Uber einen a¨ hnlichen Trick ist auch der positive Test m¨oglich: Boolesche Bedingungen in Ifoder While-Anweisungen verlangen in C++ n¨amlich entweder einen integralen Typ (s¨amtliche Formen von int oder char) oder eine eindeutige Typumwandlung eines Objekts einer Klasse in einen arithmetischen Typ oder einen Zeiger. Da f¨ur Streams eine solche Konvertierungsfunktion definiert wird, ist auch der positive Test m¨oglich:
if (std::cin >> x) { // Einlesen hat geklappt ...
} Auch hier steckt dahinter eigentlich:
std::cin >> x; if (std::cin) { ...
} Dadurch, dass eine Funktion zur impliziten Typumwandlung definiert wird, wird die Boolesche Verwendung des Objekts erst m¨oglich. Was hier genau passiert, wird in den Abschnitten u¨ ber Konvertierungsfunktionen (siehe Seite 234) und deren Anwendung bei Klassen mit dynamischen Komponenten (siehe Seite 382) erl¨autert. Ein typisches Anwendungsbeispiel f¨ur die M¨oglichkeit, den Zustand eines Streams durch Verwendung als Bedingung abzufragen, ist eine Schleife, die Objekte einliest und verarbeitet: // solange obj eingelesen werden kann
while (std::cin >> obj) { // obj verarbeiten (in diesem Fall ausgeben) std::cout << obj << std::endl; } Dies ist das klassische Filterger¨ust von C f¨ur C++-Objekte. Dabei muss aber beachtet werden, dass der Operator >> f¨uhrende Trennzeichen u¨ berliest. Die Version mit char als obj muss deshalb auf eine andere Art implementiert werden (siehe Seite 483). So sch¨on die Anwendung dieser speziellen Operatoren in Booleschen Ausdr¨ucken auch ist, eines muss beachtet werden: Die doppelte Verneinung hebt sich hier nicht auf:
Sandini Bib
210
Kapitel 4: Programmieren von Klassen
std::cin“ ist ein Stream-Objekt der Klasse std::istream. !!std::cin“ ist ein Boolescher Wert, der den Zustand von std::cin beschreibt.
”
” Dieses Beispiel zeigt, dass der Mechanismus mit Vorsicht verwendet werden muss (und auch als fragw¨urdig betrachtet werden kann). Der Ausdruck in der If-Bedingung ist zun¨achst nicht das, was normalerweise erwartet wird, n¨amlich ein Boolescher Wert. Erst eine implizite Typumwandlung, deren Vorhandensein und Bedeutung zum Verst¨andnis des Codes bekannt sein muss, macht den Ausdruck sinnvoll. Wie schon in C kann man auch hier ohne Ende streiten, ob das nun guter Programmierstil ist oder nicht. Unzweifelhaft ist aber sicherlich, dass die Verwendung der Elementfunktion good() (sie liefert zur¨uck, ob ein Stream in einem fehlerfreien Zustand ist) das Programm (noch) lesbarer machen w¨urde:
std::cin >> x; if (! std::cin.good()) { ...
}
4.5.3
Zustand von Streams
Die Verwendung des Operators ! zeigt, dass sich ein Stream in verschiedenen Zust¨anden befinden kann. F¨ur die Darstellung des prinzipiellen Zustands eines Streams werden verschiedene Bitkonstanten als Flags definiert, die in einer internen Stream-Komponente verwaltet werden (siehe Tabelle 4.2). Bitkonstante
goodbit eofbit failbit badbit
Bedeutung alles in Ordnung End-Of-File (Ende der Daten) Fehler: letzter Vorgang nicht korrekt abgeschlossen fataler Fehler: Zustand nicht definiert
Tabelle 4.2: Bitkonstanten f¨ur den Stream-Zustand
Der Unterschied zwischen failbit und badbit besteht im Wesentlichen darin, dass badbit fataler ist:
failbit wird gesetzt, wenn ein Vorgang nicht korrekt durchgef¨uhrt werden konnte, der Stream aber prinzipiell noch verwendet werden kann. badbit wird gesetzt, wenn der Stream prinzipiell nicht mehr in Ordnung ist oder Daten verloren gegangen sind.
Der Zustand der Flags kann mit dazugeh¨origen Elementfunktionen good(), eof(), fail() und bad() ermittelt werden. Zur¨uckgeliefert wird jeweils als Boolescher Wert, ob ein oder mehrere Flags gesetzt sind. Daneben gibt es noch zwei generelle Elementfunktionen zum Setzen und Abfragen dieser Flags, rdstate() und clear() (siehe Tabelle 4.3).
Sandini Bib
4.5 Ein- und Ausgabe mit Streams Elementfunktion
good() eof() fail() bad() rdstate() clear()
211
Bedeutung alles in Ordnung (ios::goodbit gesetzt) End-Of-File (ios::eofbit gesetzt) Fehler (ios::failbit oder ios::badbit gesetzt) fataler Fehler (ios::badbit gesetzt) liefert die aktuell gesetzten Flags l¨oscht oder setzt einzelne Flags
Tabelle 4.3: Funktionen zum Abfragen und Setzen von Zustandsflags
Beim Aufruf von clear() ohne Parameter werden alle Fehlerflags (auch ios::eofbit) gel¨oscht (daher kommt auch der Name clear“): ” // alle Fehlerflags (einschließlich eofbit) zur¨ucksetzen
strm.clear();
Wird aber ein Parameter u¨ bergeben, werden die darin u¨ bergebenen Flags gesetzt und die anderen zur¨uckgesetzt. Das folgende Beispiel testet f¨ur den Stream strm, ob das Failbit-Flag gesetzt ist, und l¨oscht es gegebenenfalls:
if (strm.rdstate() & std::ios::failbit) { std::cout << "Failbit war gesetzt" << std::endl; // alles außer ios::failbit wieder setzen
}
strm.clear (strm.rdstate() & ~std::ios::failbit);
Hier werden die Bit-Operatoren & und ~ angewendet:
Der Operator & verkn¨upft die Bits u¨ ber die UND-Funktion. Nur die Bits, die bei beiden Operanden gesetzt sind, bleiben stehen. Da im zweiten Operanden nur das Failbit gesetzt ist, ist der Ausdruck nur dann ungleich 0 (und damit erf¨ullt), wenn im ersten Operanden auch das Failbit gesetzt ist. Der Operator ~ liefert das Bit-Komplement. Der Ausdruck ~ios::failbit liefert also ein Ergebnis, in dem alle Flags bis auf das Failbit gesetzt sind. Durch die UND-Verkn¨upfung mit allen derzeit gesetzten Flags (rdstate()) bleiben also alle gesetzten Flags bis auf das Failbit stehen.
Neben den Zustandsflags und den Elementfunktionen zum Setzen und Abfragen existieren zahlreiche weitere Komponenten und Funktionen f¨ur Streams. Mit der Elementfunktion get() kann z.B. zeichenweise gelesen werden, ohne dass f¨uhrende Leerzeichen u¨ berlesen werden. Mit anderen Funktionen kann das Ausgabeformat beeinflusst werden. In Abschnitt 8.1 wird detailliert darauf eingegangen.
Sandini Bib
212
4.5.4
Kapitel 4: Programmieren von Klassen
I/O-Operatoren fur ¨ eigene Datentypen
Wie bereits angesprochen wurde, besteht ein wesentlicher Vorteil von Streams darin, dass der Ein- und Ausgabe-Mechanismus auf eigene Datentypen ausgedehnt werden kann. Dazu m¨ussen die Operatoren << und >> f¨ur eigene Datentypen u¨ berladen werden. Dabei gibt es nur ein Problem: Man kann nicht einfach in die Deklaration der Stream-Klassen gehen und dort seinen Operator << bzw. >> definieren. Die Stream-Klassen sind Teil einer geschlossenen Bibliothek. An dieser Stelle hilft eine besondere Eigenschaft von C++ bei der Interpretation des Aufrufs von bin¨aren Operatoren. Trifft ein Compiler auf einen Ausdruck der Form
a * b kann er diesen auf zweierlei Arten auswerten:
Er kann ihn streng objektorientiert im Sinne von
a.operator*(b)
betrachten. Er kann ihn aber auch als globale Verkn¨upfung zweier gleichwertiger Operanden betrachten:
operator*(a,b) Im ersten Fall muss ein entsprechender Operator in der Klasse des Objekts a definiert sein. Im zweiten Fall muss außerhalb jeder Klasse eine Operatorfunktion definiert sein, die beide Operanden verkn¨upft. In diesem Fall ist dann auch wieder der erste Operand ein Parameter. Entsprechendes gilt f¨ur einen Aufruf wie:
std::cout << x x muss entweder einen Datentyp haben, f¨ur den innerhalb der Klasse std::ostream der Operator << definiert ist: namespace std { class ostream { public: ostream& operator<< (typ);
// Parameter ist zweiter Operand
...
}
};
Oder außerhalb jeder Klasse muss ein Operator definiert sein, der beide Operanden mit << verkn¨upft: // beide Operanden sind Parameter:
std::ostream& operator<< (std::ostream&, typ); Das Erstere gilt f¨ur alle fundamentalen Datentypen. Die zweite M¨oglichkeit nutzt man, um diesen Mechanismus auf eigene Datentypen auszudehnen. Braucht man dabei Zugriff auf interne Daten des eigenen Typs, ruft man darin einfach eine Elementfunktion der dazugeh¨origen Klasse auf.
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
213
Wie dies genau aussieht, wird nun anhand der folgenden neuen Version der Klasse Bruch verdeutlicht. Headerdatei der Klasse Bruch mit Stream-I/O Die Headerdatei der Klasse Bruch erh¨alt nun folgenden Aufbau:
// klassen/bruch5.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { private: int zaehler; int nenner; public: /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1); // Multiplikation
Bruch operator * (const Bruch&) const; // multiplikative Zuweisung
const Bruch& operator *= (const Bruch&); // Vergleich
bool operator < (const Bruch&) const; // neu: Ausgabe mit Streams
void printOn (std::ostream&) const; // neu: Eingabe mit Streams
};
void scanFrom (std::istream&);
Sandini Bib
214
Kapitel 4: Programmieren von Klassen
/* Operator * * - inline definiert */
inline Bruch Bruch::operator * (const Bruch& b) const { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (zaehler * b.zaehler, nenner * b.nenner);
/* neu: Standard-Ausgabeoperator * - global u¨ berladen und inline definiert */
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern } /* neu: Standard-Eingabeoperator * - global u¨ berladen und inline definiert */
inline std::istream& operator >> (std::istream& strm, Bruch& b) { b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen return strm; // Stream zur Verkettung zur¨uckliefern } } // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Damit Br¨uche mit dem Standard-Stream-Mechanismus verwendet werden k¨onnen, werden die Ein- bzw. Ausgabeoperatoren (<< bzw. >>) global u¨ berladen:
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern }
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
215
inline std::istream& operator >> (std::istream& strm, Bruch& b) { // Elementfunktion zur Eingabe aufrufen b.scanFrom(strm); return strm; // Stream zur Verkettung zur¨uckliefern } Da wir zum Ein- und Ausgeben Zugriff auf die internen Komponenten des Bruchs (zaehler und nenner) brauchen, wenden sich beide Operatoren an den Bruch und rufen dort eine geeignete Elementfunktion auf. Die Elementfunktion zum Ausgeben ist eine modifizierte Form von print(), bei der nun als zus¨atzlicher Parameter der Stream, auf den der Bruch ausgegeben werden muss, zu u¨ bergeben ist:
class Bruch { ... // Ausgabe mit Streams
void printOn (std::ostream&) const; ...
}; Neu hinzugekommen ist eine Elementfunktion zum Einlesen eines Bruchs: Ihr Parameter ist ein Input-Stream:
class Bruch { ... // Eingabe mit Streams
void scanFrom (std::istream&); ...
}; Bei der Implementierung der globalen Operatorfunktionen ist unbedingt darauf zu achten, dass nie Kopien des manipulierten Streams angelegt werden. Dies w¨urde n¨amlich nicht nur Zeit kosten, sondern auch zu Fehlern f¨uhren: Der Stream wird durch die Operation manipuliert (Puffer a¨ ndern sich, der Status kann in den Fehlerzustand wechseln usw.). Diese Manipulationen w¨urden in einer Kopie aber verloren gehen und k¨onnen zu Inkonsistenzen f¨uhren. Wenn in einem sp¨ateren Ausdruck wieder das Original verwendet wird, ist dessen Zustand nicht ge¨andert und entspricht somit nicht der Realit¨at. Aus diesem Grund m¨ussen Stream-Parameter und -R¨uckgabewerte immer als Referenzen deklariert werden. Quelldatei der Klasse Bruch mit Stream-I/O ¨ vorgenommen. In der Quelldatei der Klasse Bruch wurden entsprechende Anderungen Zun¨achst muss der Konstruktor dahingehend ge¨andert werden, dass die Fehlermeldung, die ausgegeben wird, wenn der Nenner 0 ist, mit dem Stream-Mechanismus auf den Standard-Fehlerausgabekanal cerr ausgegeben wird:
Sandini Bib
216
Kapitel 4: Programmieren von Klassen
if (n == 0) { std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE); } Dann muss die bisherige Ausgabefunktion print() durch die Elementfunktion printOn() ersetzt werden. Sie gibt Z¨ahler und Nenner des Bruchs auf dem u¨ bergebenen Ausgabe-Stream strm in der Form z¨ahler/nenner“ aus: ”
void Bruch::printOn (std::ostream& strm) const { strm << zaehler << '/' << nenner; }
Schließlich kommt die Elementfunktion scanFrom() hinzu, die den Bruch vom u¨ bergebenen Eingabe-Stream strm einliest, indem nacheinander Z¨ahler und Nenner als Integer eingelesen werden:
// klassen/bruch5scan.cpp // **** BEGINN Namespace Bsp ********************************
namespace Bsp { ...
/* neu: scanFrom()
* - Bruch von Stream strm einlesen */
void Bruch::scanFrom (std::istream& strm) { int z, n; // Z¨ahler einlesen
strm >> z; // optionales Trennzeichen ’/’ und Nenner einlesen
if (strm.peek() == '/') { strm.get(); strm >> n; } else { n = 1; } // Lesefehler?
if (! strm) {
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
}
217
return;
// Nenner == 0?
if (n == 0) { // Fail-Bit setzen
}
strm.clear (strm.rdstate() | std::ios::failbit); return;
/* OK, eingelesene Werte zuweisen * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
}
if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
-z; -n; z; n;
} // **** ENDE Namespace Bsp ******************************** Damit das Eingabe- zum Ausgabeformat passt, muss zwischen dem Z¨ahler und dem Nenner das Zeichen '/' stehen. Die Stream-Funktion peek() liefert das n¨achste Zeichen, ohne es auszulesen. Die Stream-Funktion get() liest das Zeichen dann aus (vgl. Seite 480). Die einzulesenden Integerwerte werden zun¨achst in Hilfsvariablen eingelesen. Dahinter steckt die Absicht, dass ein Objekt nur nach einem erfolgreichen Einlesen ver¨andert werden soll (eine g¨angige C++-Konvention). Hierbei k¨onnen aber mehrere Fehler auftreten:
Zum einen kann es sein, dass die Integer nicht eingelesen werden k¨onnen, weil z.B. das n¨achste Zeichen ein Buchstabe ist. In dem Fall wechselt der Stream in einen Fehlerzustand, der durch die folgende If-Abfrage abgepr¨uft wird: // Lesefehler?
if (! strm) { return; } Wie darauf reagiert werden soll, h¨angt eigentlich von der Situation ab. Man k¨onnte z.B. das Programm mit einer Fehlermeldung beenden oder versuchen, den Wert nach der Ausgabe einer Fehlermeldung neu einzulesen. Je nach Situation kann das eine, das andere oder auch
Sandini Bib
218
Kapitel 4: Programmieren von Klassen
beides nicht sinnvoll sein. Durch die M¨oglichkeit der Eingabeumlenkung kann der Wert z.B. auch aus einer Datei oder von einem anderen Prozess kommen. Deshalb sollte der Fehler immer von der Aufrufumgebung behandelt werden. Da der Stream in einen Fehlerzustand wechselt, kann dieser auch von der Aufrufumgebung erkannt und ausgewertet werden. Genau das wird in diesem Fall auch gemacht. Damit ist das Verhalten beim Einlesen eines Bruchs a¨ quivalent zum Einlesen einer ganzen Zahl. Der Vorteil dabei ist, dass das Anwendungsprogramm die Umst¨ande kennt, unter denen die Funktion aufgerufen wurde, und entsprechend reagieren kann. Der Nachteil ist, dass, wenn das Anwendungsprogramm diesen Test nicht durchf¨uhrt, der Lesefehler zun¨achst unbemerkt bleibt. Mit dem Konzept der Ausnahmebehandlung wird ein besserer Mechanismus f¨ur die Fehlerbehandlung eingef¨uhrt. In Abschnitt 4.7 wird darauf eingegangen und eine entsprechend modifizierte Version dieser Einlesefunktion vorgestellt. Zum anderen kann auch bei einem erfolgreichen Einlesen ein Fehler auftreten: Der Nenner kann 0 sein. Auch dieser Fall wird hier als Formatfehler beim Einlesen behandelt: Dazu wird das Fehlerflag des Streams gesetzt, das einen Formatfehler anzeigt:6 // Nenner == 0?
if (n == 0) { // Fail-Bit setzen
}
strm.clear (strm.rdstate() | std::ios::failbit); return;
Auch diesen Fall muss das Anwendungsprogramm behandeln. Insgesamt ergibt sich folgender Aufbau der Quelldatei der Klasse Bruch:
// klassen/bruch5.cpp // Headerdatei der Klasse einbinden
#include "bruch.hpp" // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Default-Konstruktor, Konstruktor aus ganzer Zahl, * Konstruktor aus Z¨ahler und Nenner * - Default f¨ur z: 0 Der Bitoperator | verkn¨upft die Bits mit der ODER-Funktion und liefert damit alle Bits, die in beiden Operanden gesetzt sind.
6
Sandini Bib
4.5 Ein- und Ausgabe mit Streams * - Default f¨ur n: 1 */
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
if (n == 0) { // neu:
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
}
} if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
-z; -n; z; n;
/* Operator *= */ const Bruch& Bruch::operator *= (const Bruch& b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
/* Operator < */ bool Bruch::operator < (const Bruch& b) const { // da die Nenner nicht negativ sein k¨onnen, reicht:
}
return zaehler * b.nenner < b.zaehler * nenner;
219
Sandini Bib
220
Kapitel 4: Programmieren von Klassen
/* neu: printOn()
* - Bruch auf Stream strm ausgeben */
void Bruch::printOn (std::ostream& strm) const { strm << zaehler << '/' << nenner; } /* neu: scanFrom()
* - Bruch von Stream strm einlesen */
void Bruch::scanFrom (std::istream& strm) { int z, n; // Z¨ahler einlesen
strm >> z; // optionales Trennzeichen ’/’ und Nenner einlesen
if (strm.peek() == '/') { strm.get(); strm >> n; } else { n = 1; } // Lesefehler ?
if (! strm) { return; } // Nenner == 0 ?
if (n == 0) { // Fail-Bit setzen
}
strm.clear (strm.rdstate() | std::ios::failbit); return;
/* OK, eingelesene Zahlen zuweisen * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
}
*/ if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
221
-z; -n; z; n;
} // **** ENDE Namespace Bsp ******************************** Anwendung der Klasse Bruch mit Stream-I/O Das Testprogramm f¨uhrt nun seine Ausgaben mit Hilfe der Stream-Mechanismen durch:
// klassen/btest5.cpp // Standard-Headerdateien einbinden
#include #include // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { const Bsp::Bruch a(7,3); Bsp::Bruch x;
// Bruch-Konstante a deklarieren // Bruch-Variable x deklarieren
// Bruch a (neu: mit Stream-Operator) ausgeben
std::cout << a << std::endl; // neu: Bruch x einlesen
std::cout << "Bruch eingeben (zaehler/nenner): "; if (! (std::cin >> x)) { // Eingabefehler: Programmabbruch mit Fehlerstatus
std::cerr << "Fehler beim Bruch-Eingeben" << std::endl; return EXIT_FAILURE;
} std::cout << "Eingabe war: " << x << std::endl;
Sandini Bib
222
}
Kapitel 4: Programmieren von Klassen // solange x < 1000 while (x < Bsp::Bruch(1000)) { // x mit a multiplizieren und (neu: mit Stream-Operator) ausgeben x = x * a; std::cout << x << std::endl; }
Der Aufruf
std::cout << a << std::endl; ruft zun¨achst f¨ur cout und a den f¨ur die Klasse Bruch global u¨ berladenen Ausgabeoperator << auf. Dieser ruft dann f¨ur a die Elementfunktion printOn() auf, die den Bruch schließlich ausgibt. Hinzu kommt, dass im Programm nun gepr¨uft wird, ob das Einlesen des Bruchs erfolgreich war. Falls der Stream nach dem Einlesen nicht in einem fehlerfreien Zustand ist, wird eine entsprechende Fehlermeldung ausgegeben und das Programm beendet:
if (! (std::cin >> x)) { // Eingabefehler: Programmabbruch
}
std::cerr << "Fehler beim Bruch-Eingeben" << std::endl; std::exit(EXIT_FAILURE);
An dieser Stelle k¨onnte auch zun¨achst die Art des Fehlers untersucht und entsprechend reagiert werden:
// klassen/btest5y.cpp while (! (std::cin >> x)) { char c; if (std::cin.bad()) { // fataler Eingabefehler: Programmabbruch
std::cerr << "fataler Fehler bei Bruch-Eingabe" << std::endl; std::exit(EXIT_FAILURE);
} if (std::cin.eof()) {
// End-Of-File: Programmabbruch
std::cerr << "EOF bei Bruch-Eingabe" << std::endl; std::exit(EXIT_FAILURE);
} /* nicht fataler Fehler:
* - Failbit zur¨ucksetzen * - alles bis zum Zeilenende auslesen und
Sandini Bib
4.5 Ein- und Ausgabe mit Streams
223
* nochmal probieren (Schleife!) */
}
std::cin.clear(); while (std::cin.get(c) && c != '\n') { } std::cerr << "Fehler bei Bruch-Eingabe, probier es nochmal: " << std::endl;
Falls beim Einlesen ein Fehler auftritt, der nicht fatal ist, wird mit
cin.clear(); das Fehlerflag zur¨uckgesetzt und mit
while (cin.get(c) && c != '\n') { } der Rest der Zeile ausgelesen. Die Elementfunktion get() liest dabei jeweils ein Zeichen ein und weist es dem u¨ bergebenen Parameter c zu. Wie das Beispiel zeigt, ist die Fehlerbehandlung beim Einlesen eine Aufgabe, die f¨ur eine fehlertolerante Anwendung beliebig kompliziert werden kann. Hier bietet es sich an, Hilfsfunktionen zu schreiben, die bestimmte Datentypen mit der dazugeh¨origen Fehlerbehandlung einlesen.
4.5.5
Zusammenfassung
Die Ein- und Ausgabe (I/O) ist nicht Teil der Sprache C++, sondern wird durch eine StandardKlassenbibliothek realisiert. Es existieren verschiedene Stream-Klassen. Die wichtigsten sind istream f¨ur Objekte, von denen gelesen werden kann, und ostream f¨ur Objekte, auf die etwas ausgegeben werden kann. Streams besitzen einen Zustand, der durch Ein-/Ausgabeoperationen ge¨andert und abgefragt werden kann. Die globalen Objekte cin, cout und cerr sind als Standard-Ein/Ausgabekan¨ale vordefiniert. Zur Ein- und Ausgabe werden typischerweise die Operatoren >> und << verwendet. Sie k¨onnen verkettet aufgerufen werden. Manipulatoren erlauben es, Streams in einer Ein- oder Ausgabeoperation zu manipulieren. ¨ Durch globales Uberladen der I/O-Operatoren kann das Ein/Ausgabe-Konzept auch auf eigene Datentypen u¨ bertragen werden. Streams sollten immer als Referenzen u¨ bergeben werden.
Sandini Bib
224
Kapitel 4: Programmieren von Klassen
4.6 Freunde und andere Typen Dieses Kapitel besch¨aftigt sich mit dem Thema der (automatischen) Typumwandlung. Es geht darum, wie sie definiert werden, wann sie stattfinden und unter welchen Umst¨anden man sie vermeiden sollte. In diesem Zusammenhang wird auch eines der umstrittensten Sprachmittel von C++, das Schl¨usselwort friend, eingef¨uhrt.
4.6.1
Automatische Typumwandlungen
¨ Das bisherige Anwendungsprogramm kann ohne Anderung der Klasse Bruch noch etwas lesbarer gestaltet werden:
// klassen/btest6.cpp // Standard-Headerdateien einbinden
#include #include // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { const Bsp::Bruch a(7,3); Bsp::Bruch x;
// Bruch-Konstante a deklarieren // Bruch-Variable x deklarieren
std::cout << a << std::endl; // Bruch a ausgeben // Bruch x einlesen std::cout << "Bruch eingeben (zaehler/nenner): "; if (! (std::cin >> x)) { // Eingabefehler: Programmabbruch mit Fehlerstatus
std::cerr << "Fehler beim Bruch-Eingeben" << std::endl; return EXIT_FAILURE;
} std::cout << "Eingabe war: " << x << std::endl;
}
// solange x < 1000 // neu: statt while (x < Bsp::Bruch(1000)) while (x < 1000) { // x mit a multiplizieren und ausgeben x = x * a; std::cout << x << std::endl; }
Sandini Bib
4.6 Freunde und andere Typen
225
Der kleine, aber feine Unterschied ist der direkte Vergleich eines Bruchs mit der Zahl 1000:
while (x < 1000) {
// neu: statt while (x < Bsp::Bruch(1000))
...
} Dies ist m¨oglich, da jeder Konstruktor, der mit genau einem Argument aufgerufen werden kann, automatisch eine entsprechende automatische Typumwandlung (implizite Typumwandlung) definiert. Dazu z¨ahlen auch Konstruktoren mit mehr als einem Parameter, die aber jeweils DefaultArgumente besitzen. Der Konstruktor der Klasse Bruch, dem man mit einem int als Parameter aufrufen kann, erm¨oglicht also eine automatische Typumwandlung eines ints in einen Bruch:
namespace Bsp { class Bruch { ... // Konstruktor aus ganzer Zahl definiert automatische Typumwandlung
Bruch (int = 0, int = 1); ...
}
};
int main() { Bsp::Bruch x; ...
while (x < 1000) { // autom. Typumwandlung: 1000 => Bsp::Bruch(1000) ...
}
}
Das bedeutet, dass immer dann, wenn als Parameter ein Bruch verlangt wird, stattdessen ein Integer verwendet werden kann. Um dies auch f¨ur andere Datentypen zu erm¨oglichen, m¨ussen also nur entsprechende Konstruktoren definiert werden. So w¨are es z.B. denkbar, f¨ur Br¨uche eine automatische Typumwandlung f¨ur Gleitkommazahlen zu definieren:
namespace Bsp { class Bruch { ... // Konstruktor f¨ur automatische Typumwandlung von float nach Bsp::Bruch
}
};
Bruch (float);
int main() {
Sandini Bib
226
Kapitel 4: Programmieren von Klassen
Bsp::Bruch x; ...
if (x < 3.7) {
// automatische Typumwandlung: 3.7 => Bsp::Bruch(3.7)
...
} ...
} Eine automatische Typumwandlung kann durch eine direkte Implementierung der Operation mit den richtigen Datentypen vermieden werden. Eine u¨ berladene Funktion, bei der die Parametertypen genau passen, hat eine h¨ohere Priorit¨at als eine Version, f¨ur die eine Typumwandlung notwendig ist. Damit kann ein durch die Typumwandlung entstandener Laufzeitnachteil vermieden werden:
namespace Bsp {
// Vergleich mit einem int bool Bruch::operator < (int i) const { // da die Nenner nicht negativ sein k¨onnen, reicht:
}
}
return zaehler < i * nenner;
Hier muss wieder zwischen dem Implementierungsaufwand und dem Laufzeitnachteil abgewogen werden.
4.6.2
Schlusselwort ¨ explicit
Die Definition einer automatischen Typumwandlung durch einen Konstruktor kann man auch unterbinden. Dazu muss man den Konstruktor mit dem Schl¨usselwort explicit deklarieren:
namespace Bsp { class Bruch { ... // Konstruktor nur f¨ur explizite Typumwandlung von float nach Bsp::Bruch
}
};
explicit Bruch (float);
int main() { Bsp::Bruch x; ...
if (x < 3.7) { ...
}
// FEHLER: keine automatische Typumwandlung m¨oglich
Sandini Bib
4.6 Freunde und andere Typen
227
if (x < Bsp::Bruch(3.7)) {
// OK
...
} ...
} Man beachte, dass in diesem Fall der Unterschied zwischen den beiden Formen zur Initialisierung eines Objekts bei Deklaration zum Tragen kommt: Eine Deklaration der Form
X x; Y y(x);
// explizite Typumwandlung
stellt eine explizite Typumwandlung dar. Eine Deklaration der Form
X x; Y y = x;
// implizite Typumwandlung
stellt dagegen eine implizite Typumwandlung dar. Ist der Konstruktor mit explicit deklariert, ist die zweite Form also nicht m¨oglich.
4.6.3
Friend-Funktionen
Bei der automatischen Typumwandlung f¨ur Elementfunktionen gibt es allerdings ein Problem: Der Vergleich x < 1000 ist zwar m¨oglich, der Vergleich 1000 < x aber nicht:
namespace Bsp { class Bruch { ...
bool operator < (const Bruch&) const; ...
}
};
int main() { Bsp::Bruch x; ...
if (x < 1000)
// OK
...
if (1000 < x) ...
} Der Vergleich
x < 1000
// FEHLER
Sandini Bib
228
Kapitel 4: Programmieren von Klassen
wird als
x.operator< (1000) interpretiert. Da es sich bei 1000 um einen Parameter handelt und es eine eindeutige Typumwandlung gibt, wird daraus automatisch:
x.operator< (Bsp::Bruch(1000)) F¨ur den Vergleich
1000 < x gibt es keine entsprechende Interpretation. Die Interpretation als
1000.operator< (x)
// FEHLER
ist ein Fehler, da ein fundamentaler Datentyp wie int keine Elementfunktionen haben kann. Das Problem besteht also darin, dass eine automatische Typumwandlung nur f¨ur Parameter m¨oglich ist. Da der erste Operand bei der Implementierung der Operation als Elementfunktion kein Parameter ist, ist sie f¨ur diesen also nicht m¨oglich. Eine explizite Typumwandlung durch den Aufruf
Bsp::Bruch(1000) < x
// OK
ist aber m¨oglich. Es gibt allerdings auch hier die M¨oglichkeit, den Ausdruck
1000 < x auf eine andere Weise zu interpretieren (so wie dies schon auf Seite 212 zur Definition des Ausgabeoperators f¨ur eigene Datentypen ausgenutzt wurde). Man kann den Ausdruck auch als globale Verkn¨upfung zweier Operanden betrachten, die beide als Parameter u¨ bergeben werden:
operator< (1000, x) Eine solche Operation muss global und nicht als Elementfunktion deklariert werden:
bool operator < (const Bsp::Bruch&, const Bsp::Bruch&) Da diese Operation keine Elementfunktion ist, besteht kein Zugriff auf private Komponenten der Klasse Bsp::Bruch. Sofern dieser gebraucht wird, kann auch in diesem Fall eine Hilfselementfunktion der Klasse aufgerufen werden, die den eigentlichen Vergleich vornimmt:
namespace Bsp { class Bruch { ...
bool vergleicheMit (const Bruch&) const; ...
}
};
bool operator < (const Bsp::Bruch& op1, const Bsp::Bruch& op2) { return op1.vergleicheMit(op2); }
Sandini Bib
4.6 Freunde und andere Typen
229
Es gibt aber auch noch eine andere M¨oglichkeit: Man kann die Operation mit Hilfe des Schl¨usselworts friend als Freund“ der Klasse deklarieren. Alle Freunde“ einer Klasse besitzen auch ” ” Zugriff auf die privaten Komponenten ( vor Freunden gibt es keine Geheimnisse“). ” ¨ Uber den Umweg einer Deklaration als globale Friend-Funktion ist eine automatische Typumwandlung also sowohl f¨ur den ersten als auch f¨ur den zweiten Parameter m¨oglich:
namespace Bsp { class Bruch { ...
friend bool operator < (const Bruch&, const Bruch&); ...
}
};
int main() { Bsp::Bruch x; ...
if (x < 1000)
// OK
...
if (1000 < x)
// auch OK !
...
} Die Klasse Bruch mit Friend-Funktionen zur automatischen Typumwandlung Um eine automatische Typumwandlung bei der Klasse Bruch an den sinnvollen Stellen zu erm¨oglichen, sollte die Deklaration der Klasse wie folgt ver¨andert werden:
// klassen/bruch6.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { private: int zaehler; int nenner;
Sandini Bib
230
Kapitel 4: Programmieren von Klassen
public: /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1); /* Multiplikation
* - neu: globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend Bruch operator * (const Bruch&, const Bruch&); // multiplikative Zuweisung
const Bruch& operator *= (const Bruch&); /* Vergleich * - neu: globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend bool operator < (const Bruch&, const Bruch&); // Ausgabe mit Streams
void printOn (std::ostream&) const; // Eingabe mit Streams
};
void scanFrom (std::istream&);
/* Operator * * - neu: globale Friend-Funktion * - inline definiert */
inline Bruch operator * (const Bruch& a, const Bruch& b) { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
/* Standard-Ausgabeoperator
Sandini Bib
4.6 Freunde und andere Typen
231
* - global u¨ berladen und inline definiert */
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern } /* Standard-Eingabeoperator * - global u¨ berladen und inline definiert */
inline std::istream& operator >> (std::istream& strm, Bruch& b) { b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen return strm; // Stream zur Verkettung zur¨uckliefern } } // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Hier wird nicht nur der Operator <, sondern auch der Operator * als globale Friend-Funktion de¨ klariert. Dem liegt die entsprechende Uberlegung zugrunde, dass, wenn der Ausdruck x * 1000 durch eine automatische Typumwandlung f¨ur den zweiten Operanden m¨oglich ist, auch 1000 * x m¨oglich sein sollte. Dies bedeutet nun aber nicht, dass alle Operatoren als globale Friend-Funktionen definiert werden sollten. Kandidaten f¨ur Friend-Funktionen sind vielmehr im Allgemeinen nur zweistellige Operatoren, bei denen der erste Operand nicht ver¨andert wird:
F¨ur einstellige Operatoren macht es keinen Sinn, da es sich um eine Funktion der Klasse Bruch handelt und somit zumindest ein Bruch beteiligt sein sollte. F¨ur zweistellige Operatoren, deren Aufruf den ersten Operanden ver¨andert (dies sind im Allgemeinen die Zuweisungsoperatoren), soll das originale Objekt und nicht ein durch eine automatische Typumwandlung tempor¨ar erzeugtes Objekt manipuliert werden.
Implementierung von Friend-Funktionen fur ¨ Klassen Die ge¨anderte Version der Headerdatei zeigt auch, dass die Friend-Funktionen nun anders implementiert werden m¨ussen. Es gibt kein implizit u¨ bergebenes Objekt mehr, dessen Komponenten direkt angesprochen werden k¨onnen. Die in der Headerdatei inline definierte Multiplikation erh¨alt somit einen anderen Aufbau. Statt der bisherigen Implementierung
Sandini Bib
232
Kapitel 4: Programmieren von Klassen
/* Bruch-Multiplikation als Elementfunktion */ inline Bruch Bruch::operator * (const Bruch& b) const { return Bruch (zaehler * b.zaehler, nenner * b.nenner); } lautet die Implementierung nun:
/* Bruch-Multiplikation als globale Friend-Funktion */ inline Bruch operator * (const Bruch& a, const Bruch& b) { return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner); } Es gibt nun also zwei Parameter a und b, von denen jeweils Z¨ahler und Nenner angesprochen werden. Dieser Zugriff ist erlaubt, da es sich um eine Friend-Funktion der Klasse Bruch handelt. Da es kein Objekt gibt, f¨ur das die Funktion aufgerufen werden kann, darf in der Funktion weder this verwendet werden, noch d¨urfen zaehler oder nenner ohne Qualifizierung f¨ur ein Objekt angesprochen werden. Entsprechend muss, da aus der Definition des Vergleichsoperators als Elementfunktion nun eine Definition als globale Funktion geworden ist, auch deren Implementierung in der Quelldatei ge¨andert werden:
// klassen/bruch6.cpp // Headerdatei der Klasse einbinden
#include "bruch.hpp" ... /* Operator < * - neu: globale Friend-Funktion */
bool operator < (const Bruch& a, const Bruch& b) { // da die Nenner nicht negativ sein k¨onnen, reicht:
}
return a.zaehler * b.nenner < b.zaehler * a.nenner;
...
Auch hier werden jetzt beide Operanden als Parameter u¨ bergeben, deren Komponenten jeweils nur u¨ ber den Namen des Parameters angesprochen werden k¨onnen. Man beachte, dass man an der Implementierung einer Funktion nicht erkennen kann, ob und – wenn ja – f¨ur welche Klassen sie eine Friend-Funktion ist. Entscheidend ist einzig und allein die entsprechende Deklaration in der Klasse.
Sandini Bib
4.6 Freunde und andere Typen
233
Normale“ Elementfunktionen als Friend-Funktionen ” Die hier vorgestellte L¨osung – Operatoren global zu definieren, um eine automatische Typumwandlung f¨ur den ersten Operator zu erm¨oglichen – wird in kommerziellen Klassen sehr h¨aufig verwendet, da die meisten zweistelligen Operatoren den ersten Operanden nicht ver¨andern. Eine globale Definition kann aber auch f¨ur normale“ Elementfunktionen, die keine Operatoren defi” nieren, sinnvoll sein. Dabei muss allerdings beachtet werden, dass sich dadurch die Aufrufsyntax a¨ ndert. Betrachten wir das am Beispiel einer Funktion, die zu einem Bruch seinen Kehrwert ausgibt. Als Elementfunktion deklariert, sieht ihre Anwendung wie folgt aus:
namespace Bsp { class Bruch { ...
Bruch kehrwert () const;
// Kehrwert liefern
...
}
};
int main() { Bsp::Bruch x, y; ...
y = x.kehrwert();
// Aufruf als Elementfunktion // (keine Typumwandlung m¨oglich)
} Durch eine Deklaration als globale Friend-Funktion wird die Funktion nicht mehr f¨ur ein bestimmtes Objekt, sondern global aufgerufen, und es ist eine automatische Typumwandlung m¨oglich:
namespace Bsp { class Bruch { ...
friend Bruch kehrwert (const Bruch&);
// Kehrwert liefern
...
}
};
int main() { Bsp::Bruch x, y; ...
}
y = kehrwert(x);
// Aufruf als globale Funktion
y = kehrwert(7);
// automatische Typumwandlung u¨ ber // int-Konstruktor m¨oglich (=> 1/7)
Sandini Bib
234
Kapitel 4: Programmieren von Klassen
Sp¨atestens hier sollte aber darauf hingewiesen werden, dass eine automatische Typumwandlung aus Sicht der objektorientierten Programmierung als fragw¨urdig betrachtet werden kann. Objekte erhalten dadurch F¨ahigkeiten, die außerhalb der eigentlichen Klassenstruktur liegen. Stellt man nicht sicher, dass diese Funktionen zumindest logisch zur Klasse geh¨oren und mit ihr zusammen u¨ bersetzt werden, kann dies zu einem nicht mehr nachvollziehbarem Verhalten f¨uhren. Hinzu kommt, dass Friend-Funktionen bei der Vererbung zu einem Problem werden k¨onnen. Auf diese Aspekte wird unter anderem in den Abschnitten 4.6.5 und 4.6.7 noch genauer eingegangen. Es gibt jedenfall immer auch die M¨oglichkeit, auf eine derartige Deklaration einer FriendFunktion zu verzichten. Wie bei den Ein-/Ausgabefunktionen kann man einfach globale Funktionen definieren, die o¨ ffentliche Hilfsfunktionen aufrufen. So k¨onnte man z.B. auch die Multiplikation als globale Operation implementieren, indem man die Multiplikation in einen Aufruf der Operation *= umsetzt:
Bsp::Bruch operator * (const Bsp::Bruch& a, const Bsp::Bruch& b) { Bsp::Bruch ergebnis(a); return a *= b; } Die global deklarierte Funktion verwendet nun keine Interna mehr, sondern ruft nur o¨ ffentliche Elementfunktionen auf (Copy-Konstruktor und Operator *=). Ein eventuell vorhandener Laufzeitnachteil kann durch eine Implementierung als Inline-Funktion weitgehend vermieden werden.
4.6.4
Konvertierungsfunktionen
Konstruktoren, die mit einem Parameter aufgerufen werden, definieren, wie ein Objekt der dazugeh¨origen Klasse aus einem Objekt eines fremden Typs erzeugt werden kann. Es gibt auch die M¨oglichkeit einer umgekehrten Konvertierung, also die Umwandlung des Objekts in einen fremden Datentyp zu definieren. Dies geschieht mit Hilfe so genannter Konvertierungsfunktionen (englisch: conversion functions). Die Deklaration einer Konvertierungsfunktion erfolgt mit dem Schl¨usselwort operator, gefolgt von dem Typ, in den umgewandelt werden soll. Bei der Klasse Bruch ist es z.B. m¨oglich, eine Typumwandlung eines Bruchs in einen Gleitkommawert zu definieren, indem als operator double () eine Funktion definiert wird, die den Wert des Bruchs in den Typ double umwandelt und diesen zur¨uckliefert:7
namespace Bsp { class Bruch { ... // automatische Typumwandlung nach double 7
Man beachte, dass bei der Bildung des Quotienten ohne die expliziten Typumwandlungen von Z¨ahler und Nenner in den Datentyp double die ganzzahlige Division aufgerufen w¨urde. Deshalb ist die explizite Typumwandlung von zaehler und nenner in den Datentyp double notwendig.
Sandini Bib
4.6 Freunde und andere Typen
235
operator double () const; ...
}
};
inline Bruch::operator double () const { // Quotient aus Z¨ahler und Nenner zur¨uckliefern
}
return double(zaehler)/double(nenner);
Eine Konvertierungsfunktion wird wie ein Konstruktor ebenfalls ohne R¨uckgabetyp (auch nicht mit void) deklariert. Die Funktion liefert aber einen R¨uckgabewert, n¨amlich ein Objekt des Typs, in den sie konvertieren soll, zur¨uck. Insofern wird der Typ des R¨uckgabewerts implizit durch den Namen der Konvertierungsfunktion definiert. Der Typ, in den konvertiert wird, kann (analog zu Konstruktoren) auch ein Typ einer anderen Klasse sein. Weder Konstruktoren noch Konvertierungsfunktionen k¨onnen aber FriendFunktionen sein, weshalb es in den Funktionen keinen direkten Zugriff auf private Komponenten der fremden Klasse geben kann (dazu kann es jedoch Hilfsfunktionen geben). Durch die Konvertierungsfunktion der Klasse Bruch kann ein Bruch immer dann verwendet werden, wenn ein Objekt vom Typ double gebraucht wird. Zum Beispiel kann die Wurzelfunktion der mathematischen C-Bibliothek aufgerufen werden:
// klassen/sqrt1.cpp #include #include #include "bruch.hpp" int main() { Bsp::Bruch x(1,4); ...
}
4.6.5
std::cout << std::sqrt(x) << std::endl; // Wurzel aus x als double ausgeben
Probleme bei der automatischen Typumwandlung
Mit der M¨oglichkeit, Integer in Br¨uche und Br¨uche in Gleitkommawerte umwandeln zu k¨onnen, l¨asst sich die Klasse Bruch gleich viel besser verwenden – sollte man meinen. Doch bereits das bisherige Anwendungsbeispiel zeigt die Schattenseiten der automatischen Typumwandlung auf.
Sandini Bib
236
Kapitel 4: Programmieren von Klassen
Das Anwendungsprogramm, das am Anfang dieses Abschnitts vorgestellt wurde, kann damit n¨amlich nicht mehr kompiliert werden:8
// klassen/btest6.cpp // Standard-Headerdateien einbinden
#include #include // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { const Bsp::Bruch a(7,3); Bsp::Bruch x;
// Bruch-Konstante a deklarieren // Bruch-Variable x deklarieren
std::cout << a << std::endl; // Bruch a ausgeben // Bruch x einlesen std::cout << "Bruch eingeben (zaehler/nenner): "; if (! (std::cin >> x)) { // Eingabefehler: Programmabbruch mit Fehlerstatus
std::cerr << "Fehler beim Bruch-Eingeben" << std::endl; return EXIT_FAILURE;
} std::cout << "Eingabe war: " << x << std::endl;
}
// solange x < 1000 // neu: statt while (x < Bsp::Bruch(1000)) while (x < 1000) { // x mit a multiplizieren und ausgeben x = x * a; std::cout << x << std::endl; }
Das Problem ist der am Anfang dieses Abschnitts eingefu¨ hrte Ausdruck
x < 1000 Er ist mit der aktuellen Klassendeklaration nun mehrdeutig geworden: 8
Die Aussage, dass das Programm nicht mehr kompiliert werden kann, bezieht sich auf korrekte Compiler. In der Praxis ist das Programm mitunter trotzdem u¨ bersetzbar. In dem Fall handelt es sich um einen Fehler des Compilers.
Sandini Bib
4.6 Freunde und andere Typen
237
Auf der einen Seite kann er wie besprochen als
x < Bruch(1000)
interpretiert werden. Die Tatsache, dass nun eine Konvertierungsfunktion zum Typ double existiert, erm¨oglicht aber auch eine Interpretation als:
double(x) < double(1000) Beide Interpretationen sind m¨oglich und gleichwertig. Dies liegt daran, dass jede Interpretation u¨ ber selbst definierte Konvertierungen (sei es u¨ ber Konstruktoren oder u¨ ber Konvertierungsfunktionen) unabh¨angig von weiteren Standard-Konvertierungen wie von int nach double die gleiche Priorit¨at besitzt. Wenn es zwei verschiedene gleichwertige Interpretationsm¨oglichkeiten gibt, ist der Ausdruck immer mehrdeutig. Die genauen Regeln zur automatischen Typumwandlung geh¨oren zu den schwierigsten Themen in C++ und werden in Abschnitt 10.3 erl¨autert. Vermeide automatische Typumwandlungen!“ ” Dieses Beispiel lehrt eines: Funktionen zur automatischen Typumwandlung sollten vermieden werden. Insbesondere Zyklen sind bei Funktionen zur automatischen Typumwandlung unbedingt zu vermeiden. Das bedeutet vor allem, dass es zu einem Konstruktor, der mit einem Argument aufgerufen werden kann, keine Konvertierungsfunktion geben sollte, die die entsprechende umgekehrte Umwandlung vornimmt. Genauso muss darauf geachtet werden, dass, wenn eine Klasse einen Konstruktor f¨ur Objekte einer anderen Klasse definiert, diese nicht den umgekehrten Konstruktor definiert. Mit der Vermeidung von Funktionen zur automatischen Typumwandlung wird nicht nur die Gefahr der Mehrdeutigkeit minimiert. Gleichzeitig wird der objektorientierten Idee st¨arker Rechnung getragen. Eigene Datentypen werden ja dazu geschaffen, um eine feste Menge von erlaubten Operationen zu besitzen. Automatische Typumwandlungen weichen dieses Konzept auf. Dies bedeutet nicht, dass grunds¨atzlich keine Typumwandlungen mehr m¨oglich sind. Sie sollten nur nach M¨oglichkeit durch explizite Funktionsaufrufe realisiert werden. G¨angige Praxis dazu ist es, Elementfunktionen wie asTyp()“ oder toTyp()“ zu definieren. ” ” Im Beispiel der Klasse Bruch sollte also statt der Konvertierungsfunktion operator double() besser die Elementfunktion toDouble () definiert werden:
namespace Bsp { class Bruch { ... // explizite Typumwandlung nach double
}
};
double toDouble () const;
Damit wird auch die Anwendung einer solchen Konvertierung deutlicher:
Sandini Bib
238
Kapitel 4: Programmieren von Klassen
// klassen/sqrt2.cpp #include #include #include "bruch.hpp" int main() { Bsp::Bruch x(1,4); ...
}
4.6.6
std::cout << std::sqrt(x.toDouble()) // Wurzel aus x als double ausgeben << std::endl;
Andere Anwendungen des Schlusselworts ¨ friend
Mit dem Schl¨usselwort friend kann im Prinzip jeder Funktion Zugriff auf die Interna einer Klasse gew¨ahrt werden. Eine Ausnahme bilden nur die Operatoren =, (), [ ] und -> (die Ta¨ belle auf Seite 574 liefert einen genauen Uberblick, welche Funktionen Friend-Funktionen sein d¨urfen). Es ist aber auch m¨oglich, eine andere Klasse vollst¨andig zum Freund zu erkl¨aren. Durch die Deklaration:
class X { friend class Y; ...
}; bzw.
class Y; class X { friend Y; ...
}; erhalten alle Funktionen der Klasse Y Zugriff auf die Klasse X. Das u¨ bertr¨agt sich aber nicht auf Friend-Funktionen von Y ( der Freund meines Freundes ist nicht automatisch mein Freund“). ” Eine Friend-Beziehung ist also nicht transitiv. Eine Klasse zum Freund zu erkl¨aren wird mitunter dazu verwendet, um zwei Klassen, die sehr eng zusammengeh¨oren, effektiver implementieren zu k¨onnen (ein typisches Beispiel w¨aren die Klassen Matrix und Vektor).
Sandini Bib
4.6 Freunde und andere Typen
4.6.7
239
friend kontra objektorientierte Programmierung
Einer der Lieblingsstreitpunkte u¨ ber C++ ist die Frage, ob die Verwendung des Schl¨usselworts friend mit der Idee der objektorientierten Programmierung zu vereinbaren ist. Dazu ist Folgendes zu bemerken: C++ ist eine Sprache, die nicht zu objektorientierter Programmierung zwingt, sondern sie nur unterst¨utzt. Diese Unterst¨utzung kann auf vielf¨altige Weise umgangen werden. Die Frage, ob das Schl¨usselwort friend mit objektorientierter Programmierung zu vereinbaren ist, kann somit – wie die Frage, ob C++ objektorientiert ist – weder mit nein noch mit ja beantwortet werden. Es h¨angt davon ab, wie man es verwendet. Zun¨achst kann das Schl¨usselwort friend dazu verwendet werden, durch die globale Deklaration einer Funktion die Aufrufm¨oglichkeiten einer Elementfunktion zu erweitern. Das zeigen die in diesem Abschnitt besprochenen Beispiele der Multiplikation oder der Kehrwert-Funktion. In diesem Fall ist die Verwendung von friend zun¨achst unkritisch, da es sich nur um eine andere Form der Implementierung der Operationen f¨ur eine Klasse handelt. Da diese Funktionen mit der Klasse definiert werden, wird insbesondere der Zugriff auf interne Daten in keiner Weise erweitert. In solch einem Fall ist die Verwendung der Friend-Funktion eine Konzession an die Tatsache, dass einiges in C++ (aus Gr¨unden der Effizienz oder der Kompatibilit¨at zu C) u¨ ber Hintert¨urchen“ zu programmieren ist. Letztlich ist es dann auch Geschmackssache, ob man ” Funktionen mit der globalen Syntax oder der Syntax f¨ur Elementfunktionen aufrufen will. Friend-Funktionen k¨onnen allerdings bei Vererbung und Polymorphie Probleme bereiten. Darauf wird in Abschnitt 6.3.3 noch eingegangen. Insofern stehen Friend-Funktionen den Konzepten der objektorientierten Idee im Wege. Eine andere M¨oglichkeit von Friend-Deklarationen ist es, den Zugriff auf private Daten einer Klasse generell auf eine andere Klasse zu u¨ bertragen. An dieser Stelle wird das Prinzip der strengen Datenkapselung aufgeweicht. Der Zugriff auf die privaten Daten einer Klasse ist damit nicht mehr ausschließlich auf die Funktionen beschr¨ankt, die als Elementfunktion oder als globale Friend-Funktion in der Klassenstruktur deklariert werden. Es hat sich aber gezeigt, dass damit unter Umst¨anden ein erheblicher Laufzeitvorteil zu erreichen ist. Wenn z.B. das Produkt eines Vektors mit einer Matrix gebildet werden soll, muss auf Interna beider Klassen zugegriffen werden. Ohne einen direkten Zugriff m¨usste jeder Zugriff auf ein Element im Vektor bzw. in der Matrix u¨ ber eine Elementfunktion durchgef¨uhrt werden. Eine solche Verwendung der Friend-Funktionen weicht also das Konzept der strengen Datenkapselung zugunsten eines Laufzeitvorteils auf. Da die Laufzeit immer noch ein wichtiges Kriterium von Programmen ist, wird dies in der Praxis oft (und gern) in Kauf genommen. ¨ Jeder sollte sich klar machen, dass die Verwendung einer Friend-Funktion die Uberpr¨ ufung und Wartung einer Klasse erheblich erschweren kann, da der Zugriff auf außenliegende Funktionen u¨ bertragen wird. Am besten verwendet man eine Friend-Deklaration immer mit einem schlechten Gewissen, damit man nicht irgendwann anf¨angt, das Schl¨usselwort friend nur aus Bequemlichkeit einzuf¨uhren. Unstrittig ist auf jeden Fall, dass man mit dem Schl¨usselwort friend jede Menge Unsinn anstellen kann. Aber jeder kennt die Erfahrung, wenn man sich die falschen Personen, pardon Klassen, zum Freund macht. Aus diesem Grund sollte eine Deklaration von Friend-Klassen immer deutlich gekennzeichnet werden.
Sandini Bib
240
Kapitel 4: Programmieren von Klassen
Um Missverst¨andnissen vorzubeugen, sei ausdr¨ucklich darauf hingewiesen, dass es auch mit friend nicht m¨oglich ist, den Zugriff auf die privaten Komponenten einer Klasse nachtr¨aglich zu erweitern. Die Klasse selbst entscheidet in ihrer Deklaration, wen sie zum Freund hat. Es kann sich niemand zum Freund erkl¨aren.
4.6.8
Zusammenfassung
Ein Konstruktor, der mit nur einem Parameter aufgerufen werden kann, definiert damit die M¨oglichkeit einer automatischen oder expliziten Typumwandlung von dem Typ des Parameters in ein Objekt der Klasse. Mit explicit kann die Definition einer automatischen Typumwandlung durch einen Konstruktor verhindert werden. Konvertierungsfunktionen bieten eine M¨oglichkeit, ein Objekt einer Klasse implizit oder explizit in einen anderen Typ umzuwandeln. Sie werden durch operator typ() deklariert und besitzen eine Return-Anweisung, werden aber ohne R¨uckgabetyp deklariert. Bei Funktionen zur automatischen Typumwandlung drohen Mehrdeutigkeiten. Konvertierungsfunktionen sollten deshalb vermieden und Konstruktoren mit nur einem Parameter mit Vorsicht eingesetzt werden. Durch das Schl¨usselwort friend k¨onnen einzelne Operationen oder ganze Klassen Zugriff auf alle privaten Komponenten einer Klasse erhalten. Um eine automatische Typumwandlung f¨ur den ersten Operanden einer als Elementfunktion implementierten Operatorfunktion zu erm¨oglichen, muss diese global deklariert werden. Durch die Deklaration als friend kann deren Implementierung trotzdem Zugriff auf private Komponenten erhalten.
friend ist nicht transitiv ( der Freund meines Freundes ist nicht unbedingt mein Freund“). ”
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
241
4.7 Ausnahmebehandlung fur ¨ Klassen Nachdem in Abschnitt 3.6 bereits das Konzept der Ausnahmebehandlung erl¨autert wurde, wird in diesem Abschnitt gezeigt, wie man in Klassen Ausnahmen ausl¨ost und dazu passende Ausnahmeklassen entwirft. Hinzu kommen weitere Details zum Umgang mit Ausnahmen.
4.7.1
Motivation fur ¨ eine Ausnahmebehandlung in der Klasse Bruch
Eines der typischen Probleme der herk¨ommlichen Fehlerbehandlung wird schon bei der ersten Implementierung der Klasse Bruch (siehe auch Abschnitt 4.1.7) deutlich: Bei der Implementierung der Klasse Bruch kann im Konstruktor zwar der Fehler, dass 0 zum Initialisieren des Nenners u¨ bergeben wird, erkannt werden, eine vern¨unftige Fehlerbehandlung ist aber nicht m¨oglich. Dies liegt daran, dass bei der Implementierung der Klasse u¨ ber die Umst¨ande, unter denen sie angewendet wird, im Allgemeinen keine Annahmen gemacht werden k¨onnen. Entsprechend ist auch nicht bekannt, in welcher Situation 0 zur Initialisierung des Nenners u¨ bergeben wurde. In der Praxis ist es sogar so, dass es verschiedene Ursachen f¨ur einen fehlerhaften Aufruf geben kann und somit gar keine sinnvolle einheitliche Reaktion m¨oglich ist. Aus diesem Grund wurde das Programm bisher einfach mit einer entsprechenden Fehlermeldung beendet:
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt */ if (n == 0) { // Programm mit Fehlermeldung beenden
}
std::cerr << "Fehler: Nenner ist 0" << std::endl; std::exit(EXIT_FAILURE);
...
} Das Problem beschr¨ankt sich aber nicht nur auf Konstruktoren. Ein a¨ hnliches Problem existiert z.B. f¨ur eine String-Klasse oder eine Mengenklasse, wenn mit dem Operator [ ] auf ein Zeichen oder Element zugegriffen wird. In der Funktion kann zwar erkannt werden, wenn ein fehlerhafter Index u¨ bergeben wird, es besteht aber keine M¨oglichkeit, den Fehler sinnvoll zu behandeln, da die Aufrufumst¨ande nicht bekannt sind und keine geeigneten M¨oglichkeiten zur Fehlermeldung bestehen. Wie soll im Ausdruck
s[i] = 'q';
// hoffentlich ist der Index i nicht zu groß
getestet werden, ob der Index i f¨ur s zu groß ist? Das Grundproblem ist in beiden F¨allen gleich: Es treten Fehlersituationen auf, die zwar erkannt, nicht aber sinnvoll behandelt werden k¨onnen, da der Kontext, aus dem heraus der Fehler verursacht wird, nicht bekannt ist. Andererseits kann der Fehler auch nicht an die Aufrufumge-
Sandini Bib
242
Kapitel 4: Programmieren von Klassen
bung zur¨uckgemeldet werden, da R¨uckgabewerte entweder gar nicht definiert sind oder anderen Zwecken dienen. Hier hilft das Konzept der Ausnahmebehandlung: An beliebiger Stelle im Code k¨onnen Fehler erkannt und als Ausnahme an die jeweilige Aufrufumgebung gemeldet werden. In dieser Umgebung kann der Fehler dann abgefangen und entsprechend der aktuellen Situation sinnvoll behandelt werden. Geschieht dies nicht, wird der Fehler nicht einfach ignoriert, sondern f¨uhrt zu einem (definierbaren) geregelten Programmende und nicht zu einem Programmabbruch.
4.7.2
Ausnahmebehandlung am Beispiel der Klasse Bruch
Da es zu jeder Art von Ausnahme oder Fehler eine Klasse geben muss, m¨ussen bei der Deklaration einer Klasse, die die Ausnahmebehandlung verwendet, auch die dazugeh¨origen Ausnahmeklassen deklariert werden. Wenn Fehlerklassen nicht f¨ur mehrere Klassen verwendet werden, geschieht dies sinnvollerweise, indem die Fehlerklassen als eingebettete Klassen innerhalb des G¨ultigkeitsbereichs deklariert werden, zu dem die Fehler geh¨oren (eingebettete Klassen werden in Abschnitt 6.5.4 vorgestellt). Die folgende Version der Klasse Bruch definiert f¨ur die Fehlerbehandlung die Fehlerklasse NennerIstNull:
// klassen/bruch8.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { private: int zaehler; int nenner; public: /* neu: Fehlerklasse */ class NennerIstNull { }; /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen */ Bruch (int = 0, int = 1);
/* Multiplikation * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend Bruch operator * (const Bruch&, const Bruch&); // multiplikative Zuweisung
const Bruch& operator *= (const Bruch&); /* Vergleich * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend bool operator < (const Bruch&, const Bruch&); // Ein- und Ausgabe mit Streams
void printOn (std::ostream&) const; void scanFrom (std::istream&);
};
// Typumwandlung nach double double toDouble () const;
/ * Operator * * - globale Friend-Funktion * - inline definiert */
inline Bruch operator * (const Bruch& a, const Bruch& b) { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
/* Standard-Ausgabeoperator * - global u¨ berladen und inline definiert */
243
Sandini Bib
244
Kapitel 4: Programmieren von Klassen
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern } /* Standard-Eingabeoperator * - global u¨ berladen und inline definiert */
inline std::istream& operator >> (std::istream& strm, Bruch& b) { b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen return strm; // Stream zur Verkettung zur¨uckliefern } } // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Wie man sieht, wird die Fehlerklasse NennerIstNull innerhalb der Klasse Bruch deklariert:
class Bruch { ...
public: class NennerIstNull { }; ...
}; Dabei handelt es sich um die k¨urzeste Deklaration, die f¨ur Klassen u¨ berhaupt m¨oglich ist: Es werden u¨ berhaupt keine Komponenten deklariert. Aufgrund des vordefinierten Default-Konstruktors kann ein Objekt der Klasse aber dennoch erzeugt werden. Der ganze Sinn eines Objekts dieser Klasse besteht nur in seiner Existenz. Bei der Implementierung wird nun, wenn der Nenner 0 ist, ein entsprechendes Fehlerobjekt erzeugt:
// klassen/bruch8.cpp // Headerdatei der Klasse einbinden
#include "bruch.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp {
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
/* Default-Konstruktor, Konstruktor aus ganzer Zahl, * Konstruktor aus Z¨ahler und Nenner * - Default f¨ur z: 0 * - Default f¨ur n: 1 */
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
if (n == 0) {
// neu: Ausnahme: Fehlerobjekt f¨ur 0 als Nenner ausl¨osen
}
throw NennerIstNull(); } if (n < 0) { zaehler = -z; nenner = -n; } else { zaehler = z; nenner = n; }
/* Operator *= */ const Bruch& Bruch::operator *= (const Bruch& b) { // ”x *= y” ==> ”x = x * y” *this = *this * b; // Objekt (erster Operand) wird zur¨uckgeliefert
}
return *this;
/* Operator < * - globale Friend-Funktion */
bool operator < (const Bruch& a, const Bruch& b) {
245
Sandini Bib
246
Kapitel 4: Programmieren von Klassen // da die Nenner nicht negativ sein k¨onnen, reicht:
}
return a.zaehler * b.nenner < b.zaehler * a.nenner;
/* printOn
* - Bruch auf Stream strm ausgeben */
void Bruch::printOn (std::ostream& strm) const { strm << zaehler << '/' << nenner; } /* scanFrom
* - Bruch von Stream strm einlesen */
void Bruch::scanFrom (std::istream& strm) { int z, n; // Z¨ahler einlesen
strm >> z; // optionales Trennzeichen ’/’ und Nenner einlesen
if (strm.peek() == '/') { strm.get(); strm >> n; } else { n = 1; } // Lesefehler?
if (! strm) { return; } // Nenner == 0?
if (n == 0) {
// neu: Ausnahme mit Fehlerobjekt f¨ur 0 als Nenner ausl¨osen
}
throw NennerIstNull();
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
247
/* OK, eingelesene Werte zuweisen * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
}
if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
-z; -n; z; n;
// Typumwandlung nach double double Bruch::toDouble () const { // Quotient aus Z¨ahler und Nenner zur¨uckliefern
}
return double(zaehler)/double(nenner);
} // **** ENDE Namespace Bsp ******************************** Wie man sieht, wurde der Code im Konstruktor nun entsprechend modifiziert. Tritt der Fehler auf, wird das Fehlerobjekt erzeugt und in die Umgebung des Programms geworfen“: ”
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt */ if (n == 0) { // neu: Ausnahme mit Fehlerobjekt f¨ur 0 als Nenner ausl¨osen throw NennerIstNull(); } ...
} Die Anweisung throw entspricht im Programmablauf einer Folge von Return-Anweisungen, die in allen Bl¨ocken, in denen sich das Programm zur Zeit befindet, unmittelbar aufgerufen werden. S¨amtliche G¨ultigkeitsbereiche, in denen sich ein Programm zu dem Zeitpunkt befindet, werden also sofort verlassen, bis der Fehler entweder in irgendeinem Block abgefangen und behandelt oder das Programm beendet wird.
Sandini Bib
248
Kapitel 4: Programmieren von Klassen
Es handelt sich allerdings um keinen Sprung zum n¨achsten u¨ bergeordneten catch, das den Fehler behandelt, sondern um einen geordneten R¨uckzug“. In allen Bl¨ocken werden f¨ur die darin ” angelegten lokalen Objekte die Destruktoren aufgerufen. Objekte, die explizit mit new angelegt wurden, bleiben aber erhalten und m¨ussen im Catch-Bereich gegebenenfalls explizit zerst¨ort werden. Dieser Vorgang wird auch als Stack-Unwinding bezeichnet. Der Programm-Stack wird abgearbeitet, bis eine Fehlerbehandlung definiert ist. Selbst wenn der Fehler nicht behandelt wird und zum Programmende f¨uhrt, wird das Programm also nicht einfach verlassen, sondern geordnet beendet. Im Gegensatz zu exit() werden alle Destruktoren aufgerufen (exit() ruft nur die Destruktoren der statischen Objekte auf). Angesichts der Tatsache, dass ein lokales Objekt eine ge¨offnete Datei oder eine laufende Datenbankanfrage repr¨asentieren kann, ist dies ein wichtiger Unterschied. Fehlerbehandlung Die Behandlung des Fehlers geschieht in der Programmumgebung, die den Fehler direkt oder indirekt verursacht hat. Das folgende Anwendungsprogramm zeigt, wie dies aussehen kann:
// klassen/btest8.cpp // Standard-Headerdateien einbinden
#include #include // Headerdateien f¨ur die verwendeten Klassen einbinden
#include "bruch.hpp" int main() { Bsp::Bruch x;
// Bruch-Variable
/* Versuche, den Bruch x einzulesen, und fange * Ausnahmen vom Typ NennerIstNull ab */ try { int z, n; std::cout << "Zaehler: "; std::cin >> z; std::cout << "Nenner: "; std::cin >> n; x = Bsp::Bruch(z,n); std::cout << "Eingabe war: " << x << std::endl; } catch (const Bsp::Bruch::NennerIstNull&) { /* Programm mit einer entsprechenden * Fehlermeldung beenden
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
}
249
*/ std::cerr << "Eingabefehler: Nenner darf nicht Null sein" << std::endl; return EXIT_FAILURE;
// diese Stelle wird nur erreicht, wenn x erfolgreich eingelesen wurde ...
} F¨ur den durch try eingeschlossenen Bereich wird eine spezielle Fehlerbehandlung installiert, die durch die folgende Catch-Anweisung definiert wird:
try { int z, n; std::cout << "Zaehler: "; std::cin >> z; std::cout << "Nenner: "; std::cin >> n; x = Bsp::Bruch(z,n); std::cout << "Eingabe war: " << x << std::endl; } catch (const Bsp::Bruch::NennerIstNull&) { ...
} Tritt irgendwo im Try-Block eine beliebige Ausnahme auf, wird dieser Block sofort verlassen. Handelt es sich bei dieser Ausnahme um eine Ausnahme vom Typ Bruch::NennerIstNull, werden die Anweisungen im dazugeh¨origen Catch-Block ausgef¨uhrt. Nach der Behandlung im Catch-Block geht es mit der n¨achsten Anweisung hinter dem Catch-Block weiter. Wird im konkreten Beispiel etwa null als Nenner n eingegeben, dann wird durch die Anweisung
x = Bsp::Bruch(z,n); eine entsprechende Ausnahme im Konstruktor ausl¨ost. Damit werden der Konstruktor und der gesamte Try-Block sofort verlassen. Die nachfolgende Ausgabeanweisung
std::cout << "Eingabe war: " << x << std::endl; wird also nicht mehr durchgef¨uhrt. Bei jeder anderen Ausnahme wird mangels Behandlung die gesamte Funktion main() verlassen. Man beachte, dass die Ausnahme im Catch-Block als konstante Referenz u¨ bergeben wird. F¨ur Catch-Bl¨ocke gilt das Gleiche, was f¨ur Funktionen mit Parametern gilt: Sofern nichts anderes angegeben wird, werden Kopien u¨ bergeben. Durch die Verwendung einer Referenz wird das unn¨otige Kopieren des Ausnahmeobjekts vermieden.
Sandini Bib
250
Kapitel 4: Programmieren von Klassen
Ausnahme oder I/O-Fehler? Eine interessante Frage ist, ob man bei der Klasse Bruch u¨ berhaupt eine Ausnahme ausl¨osen sollte, wenn man beim Einlesen eines Bruchs eine Null als Nenner bekommt:
if (n == 0) {
// Ausnahme mit Fehlerobjekt f¨ur 0 als Nenner ausl¨osen
}
throw NennerIstNull();
Anstatt ein entsprechendes I/O-Flag zu setzen, w¨urde in dem Fall also auch die Ausnahmebehandlung verwendet werden. Ein-/Ausgaben sind aber ein gutes Beispiel f¨ur den Unterschied zwischen Ausnahmen und Fehlern. Eingabefehler sind etwas ganz Normales. Es ist somit u¨ blich, bei fehlerhaften Formaten ein entsprechendes Status-Flag im Stream zu setzen (vgl. die Implementierung auf Seite 216ff.). Man k¨onnte auf diese Weise zwar andere Eingabefehler von dem speziellen Fehler der Eingabe einer Null als Nenner unterscheiden, doch verkompliziert das andererseits auch die Schnittstelle. Die Grenzen zwischen Ausnahme und Fehler sind nat¨urlich fließend und insofern ist eine derartige Entscheidung immer auch eine Designfrage.
4.7.3
Fehlerklassen
Fehlerklassen sind Klassen wie alle anderen auch. Das bedeutet, sie k¨onnen Komponenten und Elementfunktionen besitzen. Prinzipiell kann jeder Datentyp (Klassen und fundamentale Datentypen) als Datentyp f¨ur Ausnahmen dienen. Die besondere Bedeutung erhalten diese Klassen erst dadurch, dass sie bei throw oder catch verwendet werden. Man kann z.B. auch Strings als Ausnahmeobjekte verwenden. Die Anweisung
throw "Ausnahme: ..."; l¨osen eine Ausnahme vom Typ const char* aus, die man dann z.B. mit
catch (const char* s) { std::cerr << s << std::endl; } behandeln kann. Wenn Ausnahmeklassen Komponenten besitzen, sind dies die Attribute der Ausnahme. Damit k¨onnen Ausnahmen bzw. Fehler parametrisiert werden. Selbstverst¨andlich werden dann auch Konstruktoren ben¨otigt, um diese Komponenten zu initialisieren. Werden dynamische Komponenten verwendet, kann auch ein Destruktor definiert werden. Dieser wird nach der Fehlerbehandlung bei der Zerst¨orung des Fehlerobjekts aufgerufen. Die Verwendung eines unerlaubten Index in einem Feld (Array) ist ein klassisches Beispiel f¨ur die Verwendung von Parametern in Fehlerobjekten. Wenn auf einen String mit einem unerlaubten Index zugegriffen wird, ist f¨ur die Fehlerbehandlung im Allgemeinen von Interesse, welcher Wert f¨alschlicherweise als Index verwendet wurde. Im Abschnitt 6.2.7 wird ein entsprechendes Beispiel vorgestellt und erl¨autert.
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
4.7.4
251
Weitergabe von Fehlern
Es ist m¨oglich, einen Fehler, der in einem Catch-Bereich behandelt wird, wieder auszul¨osen, um daf¨ur eine u¨ bergeordnete Fehlerbehandlung anzustoßen. Dies ist vor allem dann sinnvoll, wenn auf einen Fehlerfall zwar reagiert werden muss, der Fehler aber nicht behandelt werden kann. Ein typisches Beispiel daf¨ur ist das Schließen von ge¨offneten Dateien oder das Freigeben von angelegtem Speicher, sofern dies nicht von Destruktoren u¨ bernommen wird. Das folgende Beispiel gibt auf diese Weise im Fehlerfall explizit angelegten Speicher frei, ohne den Fehler zu behandeln:
std::string* createNewString () { std::string* newString = NULL;
// Zeiger auf (angelegten) String
try { newString = new std::string("Tasse"); // String explizit anlegen ...
// und modifizieren
} catch (...) { /* bei jeder Ausnahme explizit angelegten String freigeben * und Ausnahme zur eigentlichen Behandlung weiter nach außen reichen */
}
delete newString; throw;
// Zeiger auf angelegten String (normalerweise) zur¨uckliefern
}
return newString;
Die Anweisung throw ohne die Angabe einer Klasse sorgt bei der Fehlerbehandlung daf¨ur, dass der Fehler erneut in das Programm geworfen wird, um von weiter außerhalb behandelt zu werden. Ein derartiges rethrow“ wird auch ben¨otigt, um eine Hilfsfunktion zur Behandlung von ver” schiedenen Ausnahmen zu implementieren. Darauf wird in Abschnitt 3.6.6 eingegangen.
4.7.5
Ausnahmen in Destruktoren
¨ Eine Uberlagerung zweier Ausnahmen ist nicht m¨oglich. Wenn in einem Destruktor, der aufgrund einer Ausnahme aufgerufen wird, wiederum eine Ausnahme ausgel¨ost wird, wird deshalb die normale Ausnahmebehandlung abgebrochen und die Funktion std::terminate() aufgerufen (siehe Abschnitt 3.6.5). Insofern sollte man grunds¨atzlich darauf achten, dass Destruktoren keine Ausnahmen ausl¨osen.
Sandini Bib
252
4.7.6
Kapitel 4: Programmieren von Klassen
Ausnahmen in Schnittstellen-Deklarationen
Die Menge m¨oglicher Ausnahmen geh¨ort zu einer Funktionsbeschreibung wie Parameter und R¨uckgabewerte. Um dies zu unterst¨utzen, kann f¨ur eine Funktion deklariert werden, welche Ausnahmen nach außen gereicht werden k¨onnen. Dazu wird nach der Parameterliste mit throw eine Ausnahmenspezifikation oder Ausnahmenliste (englisch: throw specification) angegeben. Dies kann z.B. wie folgt aussehen:
void f () throw (Bruch::NennerIstNull, String::RangeError); Die Deklaration legt fest, dass von der Funktion nur die angegebenen Ausnahmen ausgeworfen werden k¨onnen. Wird keine Ausnahmenspezifikation deklariert, k¨onnen alle m¨oglichen Ausnahmen auftreten. Eine leere Liste zeigt an, dass keine Ausnahmen ausgel¨ost werden k¨onnen:
void f () throw (); Unerwartete Ausnahmen Die Ausnahmenspezifikation beschreibt nur, welche Ausnahmen nach außen gereicht werden k¨onnen. Intern k¨onnen andere Ausnahmen auftreten und behandelt werden. Tritt innerhalb der Funktion eine Ausnahme auf, die weder behandelt wird noch in der Liste nach außen gereichter Ausnahmen enthalten ist, wird die Funktion std::unexpected() aufgerufen. Diese Funktion ist so vordefiniert, dass sie wiederum std::terminate() (und damit im Allgemeinen std::abort()) aufruft. Auch hier kann mit std::set_unexpected() eine alternative Unexpected-Funktion definiert werden, die weder Parameter noch R¨uckgabewerte besitzen darf. Die bisherige UnexpectedFunktion wird jeweils zur¨uckgeliefert. Klasse std::bad_exception Falls die Klasse std::bad_exception Teil einer Ausnahmenspezifikation ist, l¨ost die Funktion std::unexpected() automatisch eine Ausnahme dieses Typs aus, wenn eine unerwartete Ausnahme auftritt:
void f () throw (Bruch::NennerIstNull, std::bad_exception) { ...
}
throw String::RangeError(); // ruft unexpected() auf, ... // was std::bad_exception ausl¨ost
Wenn eine Ausnahmenspezifikation also die Klasse std::bad_exception umfasst, wird jede Ausnahme, die nicht zu einem der sonst aufgelisteten Typen geh¨ort innerhalb der Funktion durch eine Ausnahme vom Typ std::bad_exception ersetzt. (es sein denn, mit set_unexpected() wurde eine Funktion installiert, die das nicht macht).
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
4.7.7
253
Hierarchien von Fehlerklassen
Fehlerklassen k¨onnen in Hierarchien organisiert werden. Damit k¨onnen allgemeine Fehlerarten spezialisiert bzw. verschiedene Fehlerarten unter einem Oberbegriff zusammengefasst werden. Ein Anwendungsprogramm besitzt dann eine einfache M¨oglichkeit, je nach Situation alle Fehler gemeinsam oder spezielle Fehler einzeln zu behandeln. Zum Bilden von Fehlerklassen-Hierarchien wird das Konzept der Vererbung verwendet, das allerdings erst in Kapitel 5 eingef¨uhrt wird. Insofern ist das Folgende ein Vorgriff auf Themen, die eigentlich erst sp¨ater behandelt werden. Die hier verwendeten Beispiele sollten aber dennoch verst¨andlich sein. Beispiel fur ¨ eine Fehlerklassen-Hierarchie Als Aufh¨anger f¨ur eine Fehlerklassen-Hierarchie soll wieder die Klasse Bruch verwendet werden. Bei der Verwendung der Klasse k¨onnen unterschiedliche Fehler auftreten. Um die Fehler unterschiedlich behandeln zu k¨onnen, wird eine entsprechende Hierarchie definiert (siehe Abbildung 4.5).
B r u c h : : B r u c h f e h l e r
B r u c h : : N e n n e r I s t N u l l
B r u c h : : L e s e f e h l e r
Abbildung 4.5: Einfache Hierarchie von Fehlerklassen
Unter der allgemeinen Fehlerart Bruchfehler gibt es die Spezialf¨alle NennerIstNull und Lesefehler. Entsprechend werden drei Klassen deklariert, wobei die Spezialf¨alle von dem allgemeinen Fall abgeleitet werden. Die Deklaration der Klasse Bruch erh¨alt dazu folgenden Aufbau:
// klassen/bruch10.hpp class Bruch { private: int zaehler; int nenner; public: /* Fehlerklassen: * - neu: allgemeine Basisklasse mit zwei abgeleiteten Klassen */
Sandini Bib
254
Kapitel 4: Programmieren von Klassen
class Bruchfehler { }; class NennerIstNull: public Bruchfehler { }; class Lesefehler : public Bruchfehler { }; /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1);
/* Ein- und Ausgabe mit Streams */ void printOn (std::ostream&) const; void scanFrom (std::istream&); ...
}; In der Quelldatei werden im Fehlerfall die entsprechenden speziellen Fehlerobjekte generiert:
// klassen/bruch10.cpp /* Default-Konstruktor, Konstruktor aus ganzer Zahl, * Konstruktor aus Z¨ahler und Nenner * - Default f¨ur z: 0 * - Default f¨ur n: 1 */
Bruch::Bruch (int z, int n) { /* Z¨ahler und Nenner wie u¨ bergeben initialisieren * - 0 als Nenner ist allerdings nicht erlaubt * - ein negatives Vorzeichen des Nenners kommt in den Z¨ahler */
if (n == 0) {
// Ausnahme: Fehlerobjekt f¨ur 0 als Nenner ausl¨osen
throw NennerIstNull();
} if (n < 0) { zaehler = -z; nenner = -n; } else {
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
}
}
zaehler = z; nenner = n;
/* scanFrom
* - Bruch von Stream strm einlesen */
void Bruch::scanFrom (istream& strm) { int z, n; // Z¨ahler einlesen
strm >> z; // optionales Trennzeichen und Nenner ’/’ einlesen
if (strm.peek() == '/') { strm.get(); strm >> n; } // Lesefehler?
if (! strm) { return; } // Nenner == 0?
if (n == 0) {
// Ausnahme mit Fehlerobjekt f¨ur 0 als Nenner ausl¨osen
}
throw NennerIstNull();
// OK, eingelesene Zahlen zuweisen
}
if (n < 0) { zaehler = nenner = } else { zaehler = nenner = }
-z; -n; z; n;
255
Sandini Bib
256
Kapitel 4: Programmieren von Klassen
Im Anwendungsprogramm kann nun in main() mit einer Catch-Anweisung jede Art von Fehler der Klasse Bruch behandelt werden, indem die allgemeine Basisklasse der Hierarchie verwendet wird:
// klassen/btest10.cpp int main() { try { Bsp::Bruch x; ...
x = liesBruch(); ...
}
} catch (Bsp::Bruch::Bruchfehler) { // main() mit Fehlermeldung und Fehlerstatus beenden std::cerr << "Exception durch Fehler in Klasse Bruch" << std::endl; return EXIT_FAILURE; }
Speziell beim Einlesen kann ein Eingabefehler, der in scanFrom() auftritt, als Anlass zum erneuten Einlesen behandelt werden:
// klassen/btest10b.cpp Bsp::Bruch liesBruch () { Bsp::Bruch x; bool fehler;
// Bruch-Variable // Fehler aufgetreten?
do {
fehler = false;
// zun¨achst mal kein Fehler
/* Versuche, den Bruch x einzulesen, und fange * Fehler vom Typ NennerIstNull ab */ try { std::cout << "Bruch eingeben (zaehler/nenner): "; std::cin >> x; std::cout << "Eingabe war: " << x << std::endl; } catch (Bsp::Bruch::NennerIstNull) { /* Fehlermeldung ausgeben und Schleife nochmal */
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
257
std::cout << "Eingabefehler: Nenner darf nicht Null sein" << std::endl; fehler = true;
} } while (fehler); return x;
}
4.7.8
// eingelesenen Bruch zur¨uckliefern
Design von Fehlerklassen
Nach dem gleichen Muster sollte f¨ur zusammengeh¨orende Fehlerarten immer eine allgemeine Fehlerklasse als Basisklasse deklariert werden, von der alle speziellen Fehlerklassen abgeleitet werden (siehe Abbildung 4.6).
F e h l e r
M a t h e m a t i s c h e r F e h l e r
K e i n e Z a h l
N u l l D i v i s i o n
U e b e r l a u f
I O F e h l e r
L e s e f e h l e r
S c h r e i b f e h l e r
Abbildung 4.6: Beispiel f¨ur eine Fehlerklassen-Hierarchie
Zum einen ergibt sich daraus der Vorteil, dass verschiedene Fehler mit nur einer Catch-Anweisung abgefangen werden k¨onnen. Zum anderen m¨ussen in einer Ausnahmenspezifikation einer Funktion, die mehrere Ausnahmen zur¨uckliefern kann, nicht alle Ausnahmen deklariert werden. Statt
void f () throw (KeineZahl, NullDivision, Ueberlauf); reicht die Angabe der gemeinsamen Basisklasse:
void f () throw (MathematischerFehler); Soll f¨ur mehrere Klassen mit der Ausnahmebehandlung eine gemeinsame Fehlerbehandlung implementiert werden, empfiehlt es sich ebenfalls, daf¨ur eine gemeinsame Basisklasse f¨ur alle Ausnahmen zu definieren.
Sandini Bib
258
Kapitel 4: Programmieren von Klassen
Fehlermeldung als Komponente Eine gemeinsame Basisklasse f¨ur alle Fehlerklassen sollte eine Komponente f¨ur eine Fehlermeldung definieren, die dann zu jedem Fehlerobjekt geh¨ort und ausgegeben werden kann, wenn der Fehler nicht anderweitig behandelt wird:
namespace Bsp { class Fehler { public: std::string message;
// Default-Fehlermeldung
// Konstruktor: Fehlermeldung initialisieren
Fehler (const string& s) message(s) { } ...
}
};
In main() k¨onnte dann die Fehlermeldung einfach ausgegeben werden:
int main() { try { ...
}
} catch (const Bsp::Fehler& fehler) { std::cerr << "FEHLER: " << fehler.message << std::endl; return EXIT_FAILURE; } catch (...) { std::cerr << "FEHLER: unbekannte Exception" << std::endl; return EXIT_FAILURE; }
Bei den Standard-Ausnahmen u¨ bernimmt what() diese Aufgabe (siehe Abschnitt 3.6.4).
4.7.9
Standard-Ausnahmen ausl¨osen
Man kann auch selbst Standard-Ausnahmen ausl¨osen. Dies hat den Vorteil, dass Anwendungsprogrammierer diese Ausnahmen wie andere Standard-Ausnahmen behandeln k¨onnen. Der Nachteil besteht darin, dass man nicht ohne weiteres erkennen kann, ob eine Ausnahme von der Standardbibliothek oder einer anderen Klasse ausgel¨ost wurde. Um eine Standard-Ausnahme auszul¨osen, muss man throw nur ein mit einem string initialisiertes Objekt der Standard-Ausnahmeklasse (siehe Seite 101) u¨ bergeben. Der u¨ bergebene
Sandini Bib
4.7 Ausnahmebehandlung f¨ur Klassen
259
String ist der String, der dann bei Auswertung der Ausnahme von what() geliefert wird. Beispiel:
std::string s; ...
throw std::out_of_range(s); Da es eine implizite Typumwandlung von const char* nach string gibt, kann man auch String-Literale zur Initialisierung verwenden:
throw std::out_of_range("Index ist zu gross"); Die Standard-Ausnahmeklassen, die diese F¨ahigkeit unterst¨utzen, sind die Klassen logic_error und runtime_error sowie die davon abgeleiteten Klassen. Alle anderen Standard-Ausnahmeklassen sind daf¨ur nicht vorgesehen.
4.7.10
Zusammenfassung
Klassen sollten im Fehlerfall Ausnahmen ausl¨osen. Als Datentyp f¨ur Ausnahmen sollten (interne) Hilfsklassen dienen. Destruktoren sollten keine Ausnahmen ausl¨osen. Mit Hilfe von Ausnahmenspezifikationen kann f¨ur Funktionen deklariert werden, welche Ausnahmen sie ausl¨osen k¨onnen. Fehlerklassen f¨ur die Ausnahmebehandlung k¨onnen hierarchisch angeordnet werden, um allgemeine Fehlerarten durch spezielle Fehlerarten zu verfeinern oder verschiedene Fehlerarten zusammenfassen zu k¨onnen. Klassen k¨onnen auch Standard-Ausnahmen ausl¨osen.
Sandini Bib
Sandini Bib
Kapitel 5
Vererbung und Polymorphie Dieses Kapitel stellt nach der Programmierung mit Klassen und der Datenkapselung zwei weitere wesentliche Merkmale der objektorientierten Programmierung vor: die Vererbung und die Polymorphie. Die Vererbung erm¨oglicht es, Eigenschaften einer Klasse von einer anderen Klasse abzuleiten. Das bedeutet, dass f¨ur neue Klassen nur noch Neuerungen implementiert werden m¨ussen. Eigenschaften, die schon einmal in einer anderen Klasse implementiert wurden, k¨onnen u¨ bernommen werden und m¨ussen nicht noch einmal implementiert werden. Dies f¨uhrt neben einer Einsparung an Code zu einem Konsistenzvorteil, da gemeinsame Eigenschaften nicht an mehreren Stellen stehen und somit nur einmal gepr¨uft oder ge¨andert werden m¨ussen. Die Polymorphie erm¨oglicht es, mit Oberbegriffen zu arbeiten (ein Abstraktionsmittel, das im t¨aglichen Leben st¨andig verwendet wird). Verschiedenartige Objekte k¨onnen zeitweise unter einem Oberbegriff zusammengefasst werden, ohne dass ihre unterschiedlichen Eigenschaften verloren gehen. Vererbung ist eine Voraussetzung f¨ur Polymorphie, da f¨ur den jeweiligen Oberbegriff eine Klasse definiert werden muss, von der die Klassen, die sich unter diesem Oberbegriff zusammenfassen lassen, jeweils abgeleitet werden. Wird ein Objekt unter seinem Oberbegriff verwaltet, nimmt es zeitweise den Typ der daf¨ur definierten Klasse an. Die Eigenschaft, dass es in Wirklichkeit aber ein spezielles Objekt ist, geht dabei nicht verloren. Bei einem Funktionsaufruf wird zur Laufzeit automatisch festgestellt, welche Klasse ein Objekt tats¨achlich hat und die jeweils daf¨ur definierte Funktion aufgerufen. Terminologie zur Vererbung Die Begriffswelt f¨ur die Vererbung ist sehr vielf¨altig, was zum Teil daran liegt, dass jede objektorientierte Programmiersprache ihre eigenen Bezeichnungen einf¨uhrt. Ich werde mich nachfolgend auf die Begriffswelt von C++ beschr¨anken, aber auch kurz andere Bezeichnungen vorstellen. Wenn eine Klasse die Eigenschaften einer anderen Klasse u¨ bernimmt, dann spricht man von Vererbung (englisch: inheritance). Eine neue Klasse erbt die Eigenschaften einer bereits vorhandenen Klasse.
261
Sandini Bib
262
Kapitel 5: Vererbung und Polymorphie
Die Klasse, von der geerbt wird, nennt man Basisklasse (andere Bezeichnungen: Oberklasse, Elternklasse). Die Klasse, die erbt, nennt man abgeleitete Klasse (andere Bezeichnungen: Unterklasse, Kind-Klasse). Da eine abgeleitete Klasse selbst eine Basisklasse sein kann, kann eine ganze Klassenhierarchie entstehen, wie das Beispiel in Abbildung 5.1 zeigt.
F a h r z e u g
B o o t
A u t o
S p o r t w a g e n
R u d e r b o o t
M o t o r b o o t
L a s t e r
S e g e l s c h i f f
Abbildung 5.1: Beispiel f¨ur eine Klassenhierarchie
Die Klasse Fahrzeug beschreibt die Eigenschaften von Fahrzeugen. Die Klasse Auto erbt diese Eigenschaften und erweitert sie. Die Klasse Sportwagen erbt die Eigenschaften von Auto (also die erweiterten Eigenschaften von Fahrzeug) und erweitert sie weiter. Entsprechend k¨onnen beliebige andere Zweige angelegt werden. Die Vererbung ist durch die so genannte is-a-Beziehung gekennzeichnet: Ein Sportwagen ist ein Auto. Ein Auto ist ein Fahrzeug. Wie man sieht, wird in der UML-Notation als Symbol f¨ur Vererbung ein gleichseitiges Dreieck mit der Spitze zur Basisklasse verwendet. M¨achtigkeit von Vererbung Es gibt unterschiedliche M¨achtigkeiten der Vererbung:
einfache Vererbung (englisch: single inheritance) Mehrfachvererbung (englisch: multiple inheritance)
Bei der einfachen Vererbung kann eine abgeleitete Klasse nur eine Basisklasse besitzen, bei der Mehrfachvererbung sind mehrere Basisklassen m¨oglich. Daraus folgt, dass bei der einfachen Vererbung nur baumartige Hierarchien auftreten k¨onnen; bei der Mehrfachvererbung sind es gerichtete Grafen. C++ unterst¨utzt die Mehrfachvererbung. Die Mehrfachvererbung ist sehr n¨utzlich, kann aber zu Komplikationen f¨uhren. Aus diesem Grund wird nachfolgend zun¨achst auf die einfache Vererbung eingegangen. Besonderheiten der Mehrfachvererbung werden in Abschnitt 5.4 vorgestellt.
Sandini Bib
5.1 Einfache Vererbung
263
5.1 Einfache Vererbung Als Beispiel f¨ur die einfache Vererbung soll die im vorangegangenen Kapitel eingef¨uhrte Klasse Bruch abgeleitet werden. Dabei wird auf der in Abschnitt 4.7 vorgestellten Version aufgebaut. Die Klasse Bruch soll um eine Eigenschaft erweitert werden, die ein Bruch bisher nicht hatte: Er soll gek¨urzt werden k¨onnen. Man kann diese Eigenschaft nat¨urlich auch direkt in der Klasse Bruch implementieren, aber wir gehen davon aus, dass die Klasse Bruch nicht ver¨andert werden soll, weil es z.B. auch Sinn macht, Br¨uche zu verwalten, die grunds¨atzlich nicht gek¨urzt werden k¨onnen, oder weil die bisherige Version der Klasse Bruch Teil einer geschlossenen Klassenbibliothek ist. In diesem Abschnitt wird zun¨achst das Grundkonzept der Vererbung vorgestellt. Die hier beschriebene erste Version einer abgeleiteten Klasse kann aber bei ihrer Anwendung zu Problemen f¨uhren. Auf diese Probleme und deren Vermeidung wird in Abschnitt 5.2 eingegangen.
5.1.1
Die Klasse Bruch als Basisklasse
Bevor die Basisklasse Bruch abgeleitet werden kann, muss an der bisherigen Implementierung eine kleine Modifikation vorgenommen werden. Die bisherigen Versionen der Klasse Bruch waren f¨ur die Vererbung n¨amlich nicht vorgesehen und w¨urden die hier vorgesehene Ableitung unm¨oglich machen. Dazu sind zwei Anmerkungen erforderlich:
Wenn die bisherigen Versionen der Klasse Bruch Teil einer geschlossenen Klassenbibliothek w¨aren, m¨ussten sie die Modifikationen nat¨urlich schon enthalten. Aufgrund der oben angesprochenen m¨oglichen Anwendungsprobleme, auf die erst im n¨achsten Abschnitt eingegangen wird, m¨ussen eigentlich noch weitere Modifikationen vorgenommen werden. Insofern ist diese Version noch nicht die endg¨ultige f¨ur die Vererbung geeignete Implementierung der Klasse Bruch.
Eine Version der Klasse Bruch, die auch als Basisklasse f¨ur andere Klassen dienen kann, braucht folgende ge¨anderte Klassendeklaration:
// vererb/bruch91.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { protected:
int zaehler; int nenner;
Sandini Bib
264
Kapitel 5: Vererbung und Polymorphie
public: /* Fehlerklasse */ class NennerIstNull { }; /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1); /* Multiplikation
* - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend Bruch operator * (const Bruch&, const Bruch&); // multiplikative Zuweisung
const Bruch& operator *= (const Bruch&); /* Vergleich * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend bool operator < (const Bruch&, const Bruch&); // Ein- und Ausgabe mit Streams
void printOn (std::ostream&) const; void scanFrom (std::istream&);
};
// Typumwandlung nach double double toDouble () const;
/ * Operator * * - globale Friend-Funktion * - inline definiert */
inline Bruch operator * (const Bruch& a, const Bruch& b) { /* Z¨ahler und Nenner einfach multiplizieren
Sandini Bib
5.1 Einfache Vererbung
265
* - das K¨urzen sparen wir uns */
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
}
/* Standard-Ausgabeoperator * - global u¨ berladen und inline definiert */
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern } /* Standard-Eingabeoperator * - global u¨ berladen und inline definiert */
inline std::istream& operator >> (std::istream& strm, Bruch& b) { b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen return strm; // Stream zur Verkettung zur¨uckliefern } } // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Das Zugriffsschlusselwort ¨ protected Der entscheidende Unterschied zur bisherigen Deklaration der Klasse Bruch ist, dass jetzt vor den Komponenten zaehler und nenner statt private das Zugriffsschl¨usselwort protected steht:
namespace Bsp { class Bruch { protected: int zaehler; int nenner; ...
}; ...
}
Sandini Bib
266
Kapitel 5: Vererbung und Polymorphie
Durch protected gesch¨utzte Komponenten sind wie bei private vor Zugriffen durch den Anwender der Klasse gesch¨utzt. Abgeleitete Klassen haben jedoch darauf Zugriff. Bei privaten Komponenten ist selbst das nicht m¨oglich. Da die Vererbung ein wesentliches Konzept von C++ ist, sollte der Klassen-Designer dies im Allgemeinen durch die Verwendung von protected statt private als Zugriffsschutz unterst¨utzen. Da man, um k¨urzen zu k¨onnen, Zugriff auf diese Komponenten braucht, w¨are ein Ableiten in diesem Fall sonst nicht m¨oglich. Man h¨atte nur neue Eigenschaften, f¨ur die kein Zugriff auf die geerbten Komponenten notwendig ist, hinzuf¨ugen k¨onnen. In der Praxis sollte es relativ selten notwendig sein, Komponenten auch vor dem Zugriff durch die Vererbung zu sch¨utzen. In C++ gibt es in der Praxis ohnehin keinen absoluten Schutz vor unvorhergesehenem Zugriff, da o¨ ffentliche Zeiger auf private Komponenten zeigen oder auch Headerdateien manipuliert werden k¨onnen (C++ spezifiziert dazu nur, dass das Verhalten der Sprache nicht definiert ist, wenn f¨ur eine Klasse verschiedene Headerdateien verwendet werden). Insofern sch¨utzt C++ nicht vor unerlaubtem Zugriff durch b¨oswillige Manipulation (daf¨ur gibt es Sprachen wie Ada).
5.1.2
Voruberlegungen ¨ zur abgeleiteten Klasse KBruch
Nachdem die Klasse Bruch nun f¨ur eine Vererbung vorbereitet wurde, kann sie durch die Klasse KBruch abgeleitet werden (siehe Abbildung 5.2). Der Name KBruch“ steht f¨ur k¨urzbarer ” ” Bruch“. Das bedeutet, dass die Objekte dieser Klasse Br¨uche sind, die prinzipiell gek¨urzt werden k¨onnen.
B r u c h
K B r u c h
Abbildung 5.2: Klasse KBruch als abgeleitete Klasse von Bruch
Neu eingefuhrte ¨ Operationen Als neue Operationen kommen drei Funktionen hinzu:
Die Funktion istKuerzbar() liefert zur¨uck, ob der Bruch noch k¨urzbar ist.
Die Funktion kuerzen() k¨urzt den Bruch (sofern er nicht schon gek¨urzt ist).
Die Funktion ggt() liefert den gr¨oßten gemeinsamen Teiler von Z¨ahler und Nenner, mit dem der Bruch gek¨urzt werden k¨onnte, zur¨uck.
Sandini Bib
5.1 Einfache Vererbung
267
Neu implementierte Operationen Neben den neu eingef¨uhrten Operationen m¨ussen f¨ur die Klasse KBruch aus verschiedenen Gr¨unden die Operationen neu definiert werden, die bereits f¨ur die Klasse Bruch implementiert wurden:
Die Konstruktoren m¨ussen erneut definiert werden, da sie grunds¨atzlich nicht geerbt werden. Der Operator *= und die Einlesefunktion scanFrom() m¨ussen erneut definiert werden, da ihre Implementierung aus der Klasse Bruch nicht u¨ bernommen werden kann und u¨ berschrieben werden muss (die Gr¨unde daf¨ur werden noch erl¨autert).
Neue Komponenten Neben den Komponenten zaehler und nenner, die durch die Vererbung von der Basisklasse Bruch u¨ bernommen werden, bietet es sich an, die neue Boolesche Komponente kuerzbar einzuf¨uhren. Sie h¨alt f¨ur ein Objekt jeweils fest, ob der Bruch noch k¨urzbar oder bereits gek¨urzt ist. Man k¨onnte dies auch jedes Mal, wenn diese Eigenschaft von Interesse ist, neu ausrechnen. Dadurch dass die Eigenschaft nur einmal bei einer Wert¨anderung ausgerechnet wird, kann aber Zeit gespart werden. Insgesamt ergibt sich f¨ur Objekte der Klasse KBruch der in Abbildung 5.3 dargestellte Aufbau.
KBruch: Datenelemente: geerbt von Bruch:
zaehler nenner
Elementfunktionen: operator * () operator < () printOn() asDouble()
neu:
kuerzbar
KBruch() istKuerzbar() ggt() operator *= () scanFrom()
Abbildung 5.3: Geerbte und neue Komponenten der Klasse KBruch
5.1.3
Deklaration der abgeleiteten Klasse KBruch
Wie u¨ blich befindet sich die Klassendeklaration in einer eigenen Headerdatei. Sie hat insgesamt folgenden Aufbau, der anschließend im Einzelnen erl¨autert wird:
Sandini Bib
268
Kapitel 5: Vererbung und Polymorphie
// vererb/kbruch1.hpp #ifndef KBRUCH_HPP #define KBRUCH_HPP // Headerdatei der Basisklasse
#include "bruch.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse KBruch * - abgeleitet von Bruch * - der Zugriff auf geerbte Komponenten wird nicht * eingeschr¨ankt (public bleibt public) */
class KBruch : public Bruch { protected: // true: Bruch ist k¨urzbar bool kuerzbar; // Hilfsfunktion: gr¨oßter gemeinsamer Teiler von Z¨ahler und Nenner
unsigned ggt() const; public: /* Default-Konstruktor, Konstruktor aus Z¨ahler * und Konstruktor aus Z¨ahler und Nenner * - Parameter werden an Bruch-Konstruktor durchgereicht */
KBruch (int z = 0, int n = 1) : Bruch(z,n) { kuerzbar = (ggt() > 1); } // multiplikative Zuweisung (neu implementiert)
const KBruch& operator*= (const KBruch&); // Eingabe mit Streams (neu implementiert)
void scanFrom (std::istream&); // Bruch k¨urzen
void kuerzen(); // K¨urzbarkeit testen
Sandini Bib
5.1 Einfache Vererbung
269
bool istKuerzbar() const { return kuerzbar; }
};
} // **** ENDE Namespace Bsp ******************************** #endif
// KBRUCH_HPP
Zun¨achst muss die Headerdatei der Basisklasse eingebunden werden, da die Deklaration der abgeleiteten Klasse darauf aufbaut:
#include "bruch.hpp" Entscheidend f¨ur die Tatsache, dass es sich um eine abgeleitete Klasse handelt, ist dann die eigentliche Klassendeklaration:
class KBruch : public Bruch { ...
}; Eine abgeleitete Klasse wird dadurch deklariert, dass ihre Basisklasse hinter einem Doppelpunkt und einem optionalen Zugriffsschl¨usselwort angegeben wird. Dabei spielt es keine Rolle, ob die Basisklasse selbst eine abgeleitete Klasse ist. Die Syntax zur Deklaration einer abgeleiteten Klasse lautet also:1 class abgeleiteteKlasse : [zugriff ] basisKlasse f deklarationen g; Das optionale Zugriffsschl¨usselwort gibt an, ob und inwiefern der Zugriff auf geerbte Komponenten weiter eingeschr¨ankt wird: ¨ Bei public gibt es keine weiteren Einschr¨ankungen: Offentliche Komponenten der Basisklasse sind auch in der abgeleiteten Klasse o¨ ffentlich, protected bleibt protected, und private bleibt private.
Bei protected gibt es die Einschr¨ankung, dass alle o¨ ffentlichen Komponenten der Basisklasse zu Komponenten werden, auf die der Anwender keinen Zugriff mehr hat;, davon abgeleitete Klassen haben aber weiterhin Zugriff (public wird also protected, protected bleibt protected, und private bleibt private). Bei private werden alle Komponenten der Basisklasse zu privaten Komponenten. Weder der Anwender noch davon wieder abgeleitete Klassen haben auf diese Komponenten Zugriff.
Der Default-Wert f¨ur den Zugriff ist private, sollte aber der besseren Lesbarkeit halber trotzdem immer explizit angegeben werden (manche Compiler geben sogar eine Warnung aus, wenn bei der Basisklasse kein Zugriffsschl¨usselwort angegeben ist). 1
Wie wir in Abschnitt 5.4.1 noch sehen werden, k¨onnen auch mehrere Basisklassen angegeben werden.
Sandini Bib
270
Kapitel 5: Vererbung und Polymorphie
Die Klasse KBruch schr¨ankt den Zugriff auf geerbte Komponenten der Klasse Bruch nicht weiter ein. Das bedeutet, dass die geerbten o¨ ffentlichen Elementfunktionen wie printOn(), die Multiplikation und der Vergleich mit dem Operator < auch bei der Anwendung eines KBruchs aufgerufen werden k¨onnen:
namespace Bsp { class Bruch { ...
public: void printOn (std::ostream&) const; ...
}; class KBruch : public Bruch { ...
}
};
void f() { Bsp::KBruch kb; ...
}
5.1.4
kb.printOn(std::cout);
// OK: printOn() ist auch f¨ur KBruch public
Vererbung und Konstruktoren
Konstruktoren spielen bei der Vererbung eine gesonderte Rolle. Sie werden grunds¨atzlich nicht geerbt. Alle Konstruktoren f¨ur Objekte einer abgeleiteten Klasse m¨ussen neu definiert werden. Werden keine Konstruktoren definiert, existiert auch hier nur der Default-Konstruktor. Obwohl Konstruktoren von Basisklassen nicht geerbt werden, spielen sie bei der Initialisierung eines Objekts doch eine Rolle. Konstruktoren werden n¨amlich verkettet top-down aufgerufen. Beim Anlegen eines Objekts einer abgeleiteten Klasse wird zun¨achst der Konstruktor der Basisklasse aufgerufen, der sozusagen erst den geerbten Objektteil der Basisklasse initialisiert. Erst danach wird der Konstruktor der abgeleiteten Klasse aufgerufen, der zumindest die neu hinzugekommenen Komponenten initialisiert. Er kann aber auch dazu verwendet werden, Initialisierungen des Konstruktors der Basisklasse zu korrigieren, die aus Sicht der abgeleiteten Klasse nicht mehr sinnvoll sind. In unserem Beispiel wird beim Anlegen eines Objekts der Klasse KBruch also zun¨achst ein Bruch-Konstruktor und anschließend erst ein KBruch-Konstruktor aufgerufen. Verwirrend ist dabei vielleicht die Tatsache, dass die Argumente zur Konstruktion des Objekts nicht automatisch an den Konstruktor der Basisklasse u¨ bergeben werden. Sofern nicht anders angegeben, wird vielmehr der Default-Konstruktor der Basisklasse aufgerufen. Dies liegt daran,
Sandini Bib
5.1 Einfache Vererbung
271
dass Parameter f¨ur den Konstruktor der abgeleiteten Klasse eine ganz andere Bedeutung haben k¨onnen, als sie bei der Basisklasse besitzen. Es kann sich auch um eine andere Anzahl oder andere Typen handeln. Auch hier k¨onnen wieder Initialisierungslisten verwendet werden. Sie bieten die M¨oglichkeit, Argumente an den indirekt aufgerufenen Konstruktor der Basisklasse zu u¨ bergeben bzw. durchzureichen. Dies zeigt die Deklaration des ersten Konstruktors der Klasse KBruch:
class KBruch : public Bruch { public: KBruch (int z = 0, int n = 1) : Bruch(z,n) { kuerzbar = (ggt() > 1); } ...
}; Der Konstruktor wird mit den gleichen Default-Argumenten deklariert, wie bei der Klasse Bruch. Es k¨onnen also ein Z¨ahler und ein Nenner als Integer u¨ bergeben werden. Erfolgt dies nicht, werden auch hier als Default-Werte 0 und 1 angenommen. Diese Parameter werden dann aber an den Konstruktor der Basisklasse Bruch durchgereicht, wo sie zur Initialisierung von zaehler und nenner verwendet werden. Erst nach dem Aufruf des Bruch-Konstruktors werden die Anweisungen im KBruch-Konstruktor ausgef¨uhrt. In diesem Fall wird in kuerzbar festgehalten, ob der KBruch k¨urzbar ist. Beispiel fur ¨ die Initialisierung eines Objekts der Klasse KBruch Der genaue Ablauf der Initialisierung soll nachfolgend an einem Beispiel erl¨autert werden. Durch die Deklaration
Bsp::KBruch x(91,39); wird das Objekt x der Klasse KBruch angelegt und mit 91 39 initialisiert. Dies geschieht in folgenden Schritten:
Zun¨achst wird der Speicherplatz f¨ur das Objekt angelegt, dessen Zustand wie immer nicht definiert ist:
x:
zaehler:
?
nenner:
?
kuerzbar:
?
Anhand der Initialisierungsliste des Konstruktors wird festgestellt, dass der int/int-Konstruktor der Klasse Bruch aufgerufen werden muss:
Sandini Bib
272
Kapitel 5: Vererbung und Polymorphie
class KBruch : public Bruch { ...
KBruch (int z = 0, int n = 1) : Bruch(z,n) { kuerzbar = (ggt() > 1); } ...
};
Also wird der int/int-Konstruktor der Klasse Bruch mit den Parametern 91 und 39 aufgerufen, der den von Bruch geerbten Teil des Objekts initialisiert:
x:
zaehler: nenner:
91
kuerzbar:
?
39
Schließlich werden die Anweisungen des KBruch-Konstruktors ausgef¨uhrt, die die Komponente kuerzbar durch Aufruf von ggt() initialisieren:
x:
zaehler: nenner:
91
kuerzbar:
true
39
Die Tatsache, dass die Anweisungen im Basis-Konstruktor vor den Anweisungen im Konstruktor der abgeleiteten Klasse aufgerufen werden, ist wichtig. Nur so kann die abgeleitete Klasse, wie in diesem Beispiel, die initialisierten Komponenten der Basisklasse auswerten. Damit besteht auch die M¨oglichkeit, die Initialisierung zu korrigieren, indem z.B. Sonderf¨alle anders behandelt werden. Dies darf aber nicht dazu f¨uhren, dass die Komponenten eine neue Bedeutung erhalten (in Abschnitt 5.5 werden einige Design-Fallen dazu vorgestellt). Der ganze Mechanismus ist rekursiv. Ist die Basisklasse selbst eine abgeleitete Klasse, wird also zun¨achst der Konstruktor ihrer Basisklasse aufgerufen. Die Initialisierungsliste der Basisklasse gibt dabei an, welche Parameter an den Konstruktor der Basisklasse durchgereicht werden. Eine Initialisierungsliste kann Argumente jeweils nur an ihre direkte Basisklasse u¨ bergeben. Wenn Konstruktoren sowohl f¨ur Basisklassen als auch f¨ur Komponenten aufgerufen werden m¨ussen, werden grunds¨atzlich zuerst die Konstruktoren der Basisklassen und dann die Konstruktoren f¨ur die Komponenten aufgerufen.
Sandini Bib
5.1 Einfache Vererbung
273
Vererbung und Destruktoren Destruktoren werden umgekehrt verkettet, also bottom-up, aufgerufen. Zuerst werden die Anweisungen der abgeleiteten Klasse und dann die der Basisklasse durchgef¨uhrt. Wenn Destruktoren f¨ur Basisklassen und Destruktoren f¨ur Komponenten aufgerufen werden m¨ussen, werden zuerst die Destruktoren f¨ur die Komponenten und danach die Destruktoren der Basisklasse aufgerufen.
5.1.5
Implementierung von abgeleiteten Klassen
Die Implementierung einer abgeleiteten Klasse sieht aus wie bei jeder anderen Klasse auch. Die Quelldatei der Klasse KBruch hat insgesamt folgenden Aufbau:
// vererb/kbruch1.cpp // Headerdatei f¨ur min() und abs()
#include #include
// Headerdatei der eigenen Klasse einbinden
#include "kbruch.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* ggt() * - gr¨oßter gemeinsamer Teiler von Z¨ahler und Nenner */
unsigned KBruch::ggt () const { if (zaehler == 0) { return nenner; }
/* gr¨oßte Zahl ermitteln, die sowohl den Z¨ahler als auch * den Nenner ohne Rest teilt */
}
unsigned teiler = std::min(std::abs(zaehler),nenner); while (zaehler % teiler != 0 || nenner % teiler != 0) { --teiler; } return teiler;
Sandini Bib
274
Kapitel 5: Vererbung und Polymorphie
/* kuerzen() */ void KBruch::kuerzen () { // falls k¨urzbar, Z¨ahler und Nenner durch GGT teilen
if (kuerzbar) { int teiler = ggt(); zaehler /= teiler; nenner /= teiler; }
}
kuerzbar = false;
// damit nicht mehr k¨urzbar
/* Operator *= * - neu implementiert */
const KBruch& KBruch::operator*= (const KBruch& b) { // wie bei der Basisklasse:
zaehler *= b.zaehler; nenner *= b.nenner; // weiterhin gek¨urzt?
if (!kuerzbar) { kuerzbar = (ggt() > 1); } }
return
*this;
/* scanFrom() */ void KBruch::scanFrom (std::istream& strm) { Bruch::scanFrom (strm); // scanFrom() der Basisklasse aufrufen }
kuerzbar = (ggt() > 1);
// K¨urzbarkeit testen
} // **** ENDE Namespace Bsp ********************************
Sandini Bib
5.1 Einfache Vererbung
275
Zun¨achst wird die neu eingef¨uhrte Elementfunktion zur Berechnung des gr¨oßten gemeinsamen Teilers implementiert. Falls der Z¨ahler 0 ist, ist der GGT der Nenner ( 70 hat also den GGT 7). Ansonsten wird in einer Schleife, absteigend vom vorzeichenfreien Minimum von Z¨ahler und Nenner, die erste Zahl gesucht, die sowohl den Z¨ahler als auch den Nenner ohne Rest teilt (der Modulo-Operator % liefert den Rest nach Division). Sp¨atestens bei 1 ist das der Fall. F¨ur den Startwert der Schleife wird die in definierte Standardfunktion std::min(), die das Minimums zweier Werte liefert, und die in definierte Standardfunktion std::abs(), die den Absolutwert (Wert ohne Vorzeichen) eines Integers liefert, verwendet.2 Die Funktion kuerzen() teilt, sofern der Bruch nicht schon gek¨urzt ist, Z¨ahler und Nenner durch den berechneten GGT. Neuimplementierung geerbter Funktionen Als Besonderheit kann es wie hier passieren, dass abgeleitete Funktionen und Operatoren erneut implementiert werden m¨ussen, da ihre Implementierung in der Basisklasse f¨ur Objekte der abgeleiteten Klasse nicht korrekt ist. Dies gilt in diesem Beispiel fu¨ r den Operator *= und die Einlesefunktion scanFrom(). In beiden F¨allen kann es n¨amlich passieren, dass der damit manipulierte KBruch ohne Neuimplementierung in einen inkonsistenten Zustand ger¨at. Dies liegt daran, dass beide Funktionen zaehler und nenner manipulieren. Deren Inhalt bestimmt aber den Wert von kuerzbar. Da die Implementierung der Basisklasse kuerzbar jedoch nicht anpasst (kuerzbar ist dort ja noch nicht bekannt), kann es passieren, dass die Komponente keinen korrekten Wert besitzt. Beim Operator *= kann der KBruch, f¨ur den diese Operation aufgerufen wird, von einem nichtk¨urzbaren in einen k¨urzbaren Zustand wechseln. Dies ist zum Beispiel dann der Fall, wenn das nichtk¨urzbare Objekt 73 mit 37 multipliziert wird. Ohne Neuimplementierung w¨urde kuerzbar auch f¨ur den Wert des KBruchs 21 21 weiterhin auf false stehen. Bei scanFrom() wird der Wert des KBruchs ohne Neuimplementierung ebenfalls ohne Anpassen der Komponente kuerzbar beliebig ge¨andert. Damit kann ebenfalls die Inkonsistenz entstehen, dass die Komponente kuerzbar nicht korrekt gesetzt ist. Die Information, ob der KBruch k¨urzbar ist, muss deshalb jedes Mal, wenn ein neuer Wert eingelesen wird, neu ermittelt werden. Bei der Neuimplementierung gibt es zwei M¨oglichkeiten:
komplette Neuimplementierung Aufruf der Implementierung der Basisklasse mit anschließender Korrektur
Auch hier muss wieder zwischen einem m¨oglichen Laufzeitvorteil und einem damit verbundenen Konsistenznachteil abgewogen werden. Die komplette Neuimplementierung, wie sie beim Ope¨ in der Basisklasse auch ge¨andert werden. rator *= erfolgt, spart Zeit, muss aber bei Anderungen Bei einem Aufruf der Implementierung der Basisklasse, wie sie bei scanFrom() durchgef¨uhrt wird, m¨ussen nur die f¨ur die abgeleitete Klasse zus¨atzlich notwendigen Schritte neu implementiert werden. 2
Es gibt zweifellos bessere Algorithmen zur Berechnung eines GGTs.
Sandini Bib
276
5.1.6
Kapitel 5: Vererbung und Polymorphie
Anwendung von abgeleiteten Klassen
Abgeleitete Klassen werden wie jede andere Klasse angewendet. Objekte werden durch Konstruktoren erzeugt und u¨ ber geerbte oder neue bzw. korrigierte Operationen manipuliert. Das folgende Beispielprogramm soll dies verdeutlichen:
// vererb/kbruchtest1.cpp // Headerdateien
#include #include "kbruch.hpp" int main() { // k¨urzbaren Bruch deklarieren
Bsp::KBruch x(91,39); // x ausgeben
std::cout << x; std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)") << std::endl; // x k¨urzen
x.kuerzen(); // x ausgeben
std::cout << x; std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)") << std::endl; // x mit 3 multiplizieren
x *= 3;
// x ausgeben
}
std::cout << x; std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)") << std::endl;
Es liefert folgende Ausgabe:
91/39 (kuerzbar) 7/3 (unkuerzbar) 21/3 (kuerzbar)
Sandini Bib
5.1 Einfache Vererbung
277
Zun¨achst wird durch die Deklaration
Bsp::KBruch x(91,39); das Objekt x der Klasse KBruch angelegt und mit 91 39 initialisiert. Dies geschieht auf die schon auf Seite 271 erl¨auterte Art und Weise. Anschließend wird x ausgegeben:
std::cout << x; Nach der Ausgabe von x wird jeweils ausgegeben, ob x k¨urzbar ist. Der Ausdruck
x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)" kl¨art dabei, ob (kuerzbar)“ oder (unkuerzbar)“ ausgegeben wird. Da der Operator ?: (er ” ” wird auf Seite 44 erl¨autert) eine geringere Priorit¨at als der Ausgabeoperator << besitzt, muss der Ausdruck geklammert werden. Nach dem Aufruf der Operation, die den KBruch k¨urzt, wird der Zustand des KBruchs erneut ausgegeben. Schließlich wird der KBruch mit 3 multipliziert und erneut ausgegeben. Die Multiplikation mit einer ganzen Zahl erm¨oglicht der KBruch-Konstruktor. Da er auch mit nur einem Integer-Argument aufgerufen werden kann, definiert er eine automatische Typumwandlung von Integer nach KBruch (siehe Abschnitt 4.6.1), wodurch die 3 in den KBruch 31 umgewandelt und dem Operator *= als Parameter u¨ bergeben wird. Da der Ausgabeoperator f¨ur die Klasse KBruch nicht neu implementiert wurde, wird automatisch die Operation verwendet, die in der Klasse Bruch deklariert wurde:
std::ostream& operator << (std::ostream& strm, const Bruch& b); Anwendung der is-a-Beziehung Beim Ausgeben eines KBruchs mit
std::cout << x; ist eine Tatsache bemerkenswert: Obwohl die Operatorfunktion nur f¨ur die Klasse Bruch als zweiten Operanden definiert ist, kann auch ein KBruch u¨ bergeben werden. Man kann also ein Objekt der Klasse KBruch als Bruch verwenden. Dabei handelt es sich um die konsequente Anwendung der is-a-Beziehung. Da die Klasse KBruch von Bruch abgeleitet ist, ist ein KBruch ein Bruch und kann somit jederzeit als solcher verwendet werden. Durch Vererbung wird grunds¨atzlich eine automatische Typumwandlung von einem Objekt einer abgeleiteten Klasse in ein Objekt seiner Basisklasse definiert. Dabei wird das Objekt aber auf die Eigenschaften reduziert, die die Objekte der Basisklasse besitzen. Insbesondere besitzt es nur noch die Datenelemente der Basisklasse. Die bei der Vererbung hinzugekommenen Komponenten fallen einfach weg bzw. werden ignoriert. In diesem Fall ist das kein Problem, denn auch bei der Ausgabe des KBruchs sollen nur Z¨ahler und Nenner ausgegeben werden. Es gibt aber F¨alle, in denen das problematisch sein kann. Im Abschnitt 5.5 werden entsprechende Fallen erl¨autert.
Sandini Bib
278
5.1.7
Kapitel 5: Vererbung und Polymorphie
Konstruktoren fur ¨ Objekte der Basisklasse
Die Multiplikation wurde f¨ur die Klasse KBruch zwar von Bruch geerbt, sie kann aber nicht richtig eingesetzt werden. Dies liegt daran, dass zwei Objekte der Klasse KBruch zwar miteinander multipliziert werden k¨onnen, das Ergebnis aber keinem KBruch zugewiesen werden darf:
Bsp::KBruch kb; ...
std::cout << kb * kb;
// OK: gibt Quadrat von kb aus
...
kb = kb * kb;
// FEHLER: Zuweisung an KBruch nicht erlaubt
Die von der Klasse Bruch geerbte Multiplikation liefert n¨amlich einen Bruch und keinen KBruch zur¨uck. Ein Bruch ist aber kein KBruch (die is-a-Beziehung gilt nur umgekehrt). Somit kann ein Bruch auch nicht einfach einem KBruch zugewiesen werden. Dieses Problem k¨onnte durch Neuimplementierung der Multiplikation behoben werden. Das Problem l¨asst sich aber auch durch die Definition einer Typumwandlung von einem Bruch in einen KBruch l¨osen, die u¨ ber die Implementierung eines weiteren Konstruktors erm¨oglicht wird:
class KBruch : public Bruch { public: /* Konstruktor zur Typumwandlung eines Bruchs in einen KBruch * - Parameter wird an den Copy-Konstruktor von Bruch durchgereicht */ KBruch (const Bruch& b) : Bruch(b) { kuerzbar = (ggt() > 1); } ...
}; Auch hier wird der KBruch dadurch initialisiert, dass zun¨achst ein Konstruktor der Klasse Bruch und dann der Konstruktor der Klasse KBruch aufgerufen wird. Da dem Bruch-Konstruktor der Bruch, aus dem ein KBruch erzeugt werden soll, u¨ bergeben wird, wird der Copy-Konstruktor der Klasse Bruch aufgerufen. Da der Konstruktor nur einen Parameter besitzt, wird dadurch eine automatische Typumwandlung von Bruch nach KBruch definiert. Damit kann man einen Bruch als Ergebnis einer Multiplikation auch einem KBruch zuweisen:
Bsp::KBruch kb; ...
kb = kb * kb;
// OK: Typumwandlung von Bruch nach KBruch definiert
Genauso kann ein Bruch als Parameter bei der multiplikativen Zuweisung verwendet werden:
Bsp::KBruch kb; Bsp::Bruch b; ...
kb *= b;
// OK: Typumwandlung f¨ur b nach KBruch definiert
Sandini Bib
5.1 Einfache Vererbung
279
Ansonsten m¨usste der Parameter des Operators *= als Bruch deklariert werden:
class KBruch : public Bruch { ...
const KBruch& KBruch::operator*= (const Bruch&); ...
}; Einschr¨ankungen bei Konstruktoren fur ¨ Objekte der Basisklasse Es ist eher selten sinnvoll, einen Konstruktor zu haben, der ein Objekt der Basisklasse in ein Objekt der abgeleiteten Klasse umwandeln kann. Er sollte nur dann vorhanden sein, wenn es f¨ur die neu hinzugekommenen Attribute immer eine sinnvolle M¨oglichkeit gibt, diese Attribute zu initialisieren. In diesem Beispiel ist das ausnahmsweise der Fall, da sich die Tatsache, ob der Bruch k¨urzbar ist, aus den bereits in Br¨uchen vorhandenen Komponenten (Z¨ahler und Nenner) ergibt. Typischerweise kommen aber Attribute hinzu, deren Werte unabh¨angig von den bisherigen Attributen ist. H¨atten wir z.B. eine Klasse FarbigerBruch eingef¨uhrt, also einen Bruch, dem als zus¨atzliches Attribut eine bestimmte Farbe f¨ur die Ausgabe zugeordnet ist, so w¨are eine Typumwandlung von Bruch weniger sinnvoll. Die Farbe eines Bruchs ist dann eine Eigenschaft, die in keiner Beziehung zu den bisherigen Eigenschaften steht. Man k¨onnte zwar eine Default-Farbe annehmen, sinnvoller ist es aber meistens, keine Umwandlung eines Bruchs in einen farbigen Bruch zuzulassen, damit in einem Programm, das farbige Br¨uche verwendet, nicht aus Versehen Br¨uche ohne Farbe als farbige Br¨uche verwendet werden. Man mache sich auch hier klar, dass jede automatische Typumwandlung die Gefahr eines ungewollten Verhaltens erh¨oht, da der Compiler dadurch nicht mehr erkennen kann, ob ein Objekt f¨alschlicherweise als Objekt einer anderen Klasse verwendet wird. Auch hier kann eine Funktion zur expliziten Typumwandlung sinnvoll sein. Es ist letztlich eine Design-Entscheidung, bei der zwischen der Gefahr einer ungewollten Typumwandlung und dem Vorteil einer vereinfachten Typumwandlung abgewogen werden muss.
5.1.8
Zusammenfassung
Klassen k¨onnen in einer Vererbungsbeziehung stehen. Eine abgeleitete Klasse u¨ bernimmt (erbt) alle Eigenschaften der Basisklasse und erg¨anzt diese typischerweise um neue Eigenschaften. Objekte abgeleiteter Klassen besitzen die Komponenten der Basisklasse und die neu hinzugekommenen Komponenten. Kennzeichen der Vererbung ist die is-a-Beziehung: Ein Objekt einer abgeleiteten Klasse ist ein Objekt der Basisklasse (mit zus¨atzlichen Eigenschaften). Ein Objekt einer abgeleiteten Klasse darf jederzeit als Objekt der Basisklasse verwendet werden. Es reduziert sich dann auf die Eigenschaften der Basisklasse. Konstruktoren werden nicht geerbt, aber verkettet top-down aufgerufen. Destruktoren werden entsprechend verkettet bottom-up aufgerufen. Mit Initialisierungslisten k¨onnen den Konstruktoren einer Basisklasse Parameter u¨ bergeben werden. Erfolgt dies nicht, wird jeweils der Default-Konstruktor der Basisklasse aufgerufen.
Sandini Bib
280
Kapitel 5: Vererbung und Polymorphie
5.2 Virtuelle Funktionen Die im vorigen Abschnitt eingef¨uhrte Klasse KBruch ( k¨urzbarer Bruch“) besitzt noch einige ” potenzielle Fehlerquellen: Ihre Verwendung kann zu Inkonsistenzen bei Objekten der Klasse f¨uhren. Dies liegt im Wesentlichen daran, dass bei der Implementierung der Klasse Funktionen der Basisklasse u¨ berschrieben wurden (was allerdings auch schon dazu diente, m¨ogliche Inkonsistenzen zu vermeiden). F¨ur Objekte der abgeleiteten Klasse k¨onnen n¨amlich unter bestimmten Umst¨anden trotzdem die u¨ berschriebenen Funktionen der Basisklasse aufgerufen werden. ¨ Welche Probleme beim Uberschreiben von Funktionen einer Basisklasse auftreten k¨onnen und wie diese Probleme behoben werden, wird in diesem Abschnitt aufgezeigt. Dazu wird ein f¨ur die Vererbung und die Polymorphie wesentliches Sprachmittel vorgestellt: virtuelle Funktionen.
¨ Probleme beim Uberschreiben von Funktionen der Basisklasse
5.2.1
Die im vorigen Abschnitt vorgestellte Klasse KBruch u¨ berschreibt zwei Funktionen der Basisklasse Bruch, da deren Implementierung nicht f¨ur KBr¨uche geeignet ist (siehe Abschnitt 5.1.5). Dabei handelt es sich um die Funktionen operator*=() und scanFrom(). In der Basisklasse Bruch lautete deren Deklaration:
class Bruch { ...
public: // multiplikative Zuweisung
const Bruch& operator *= (const Bruch&); // Eingabe mit Streams
void scanFrom (std::istream&); ...
}; In der abgeleiteten Klasse KBruch lautete sie:
class KBruch : public Bruch { ...
public: // multiplikative Zuweisung (neu implementiert)
const KBruch& operator*= (const KBruch&); // Eingabe mit Streams (neu implementiert)
void scanFrom (std::istream&); ...
}; Diese Neuimplementierung kann allerdings Probleme verursachen, die zumindest erkannt und m¨oglichst behoben werden sollten.
Sandini Bib
5.2 Virtuelle Funktionen
281
Das folgende Anwendungsprogramm deckt diese Probleme auf:
// vererb/kbruchtest2.cpp // Headerdateien
#include #include "kbruch.hpp" #include "bruch.hpp" int main() { // KBruch deklarieren
Bsp::KBruch x(7,3); // Bruch mit Kehrwert von x deklarieren
Bsp::Bruch b(3,7);
// Zeiger auf Bruch zeigt darauf
Bsp::Bruch* xp = &x; *xp *= b;
// PROBLEM: ruft Bruch::operator*=() auf
// x ausgeben std::cout << x; std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)") << std::endl;
std::cout << "Bruch eingeben (zaehler nenner): "; std::cin >> x;
}
// PROBLEM: ruft indirekt Bruch::scanFrom() auf
// x ausgeben std::cout << x; std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)") << std::endl;
Zwei Anweisungen des Programms sind problematisch. In beiden F¨allen steckt der Mechanismus dahinter, dass Objekte einer abgeleiteten Klasse auch als Objekte der Basisklasse verwendet werden k¨onnen. Manipulation uber ¨ Zeiger der Basisklasse Im ersten Fall beruht das Problem darauf, dass die Manipulation von KBruch x u¨ ber einen Zeiger durchgef¨uhrt wird, der allerdings als Zeiger auf Objekte der Basisklasse deklariert ist:
Sandini Bib
282
Kapitel 5: Vererbung und Polymorphie
Bsp::KBruch x(7,3); Bsp::Bruch b(3,7); Bsp::Bruch* xp = &x; *xp *= b;
// PROBLEM: ruft Bruch::operator*=() auf
Die Tatsache, dass ein Zeiger vom Typ Bruch* auf einen KBruch zeigt, ist v¨ollig in Ordnung. Es handelt sich dabei um die konsequente Anwendung der is-a-Beziehung, die die Vererbung kennzeichnet:
Ein KBruch ist ein Bruch. Ein KBruch kann deshalb jederzeit als Bruch verwendet werden.
In diesem Fall wird der KBruch x als Bruch verwendet, auf den xp zeigt. Wenn nun aber der KBruch x u¨ ber den Bruch-Zeiger xp manipuliert wird, wird die Elementfunktion der Klasse Bruch und nicht die der Klasse KBruch aufgerufen. Dies liegt daran, dass ¨ der Compiler beim Ubersetzen die Operation *= f¨ur Br¨uche aufruft, da es sich um einen Zeiger auf einen Bruch handelt. Diese Implementierung kennt aber nur die Komponenten zaehler und nenner und passt kuerzbar nicht an. Im vorliegenden Programm entsteht deshalb eine Inkonsistenz. Der Bruch 73 wird zwar mit 37 multipliziert, die Komponente kuerzbar beh¨alt aber ihren alten Wert, auch wenn dies falsch ist. Dies wird mit der folgenden Ausgabeanweisung auch deutlich. Ausgegeben wird:
21/21 (unkuerzbar) Manipulation uber ¨ Referenzen der Basisklasse Ein entsprechendes, wenn auch nicht so leicht erkennbares Problem ist das Einlesen des KBruchs x:
Bsp::KBruch x(7,3); ...
std::cin >> x;
// PROBLEM: ruft indirekt Bruch::scanFrom() auf
Auch hier wird f¨alschlicherweise die Einlesefunktion der Basisklasse verwendet. In diesem Fall liegt das daran, dass der KBruch x von einer Referenz der Klasse Bruch verwendet wird. Aufgerufen wird n¨amlich die bereits in der Basisklasse Bruch definierte Einleseoperation:
inline std::istream& operator >> (std::istream& strm, Bruch& b) { // Elementfunktion zur Eingabe aufrufen b.scanFrom (strm); return strm; // Stream zur Verkettung zur¨uckliefern } Auch hier handelt es sich wieder um die konsequente Anwendung der is-a-Beziehung. Der KBruch x ist ein Bruch und kann somit zur Initialisierung der Bruch-Referenz b verwendet werden.
Sandini Bib
5.2 Virtuelle Funktionen
283
Da b aber ein zweiter Name f¨ur einen Bruch ist, wird zum Einlesen von b die Elementfunktion scanFrom() der Klasse Bruch aufgerufen. Diese liest zwar zaehler und nenner ein, setzt aber nicht die Komponente kuerzbar, da diese f¨ur Br¨uche nicht bekannt ist. Der KBruch beh¨alt in der Komponente kuerzbar deshalb unabh¨angig vom eingelesenen neuen Wert den alten Zustand, auch wenn dies falsch ist. Die neu implementierte Funktion zum Einlesen von Objekten der Klasse KBruch wird also zumindest u¨ ber den Einlese-Operator >> gar nicht erst aufgerufen. Nur ein direkter Aufruf von scanFrom() w¨urde funktionieren.
5.2.2
Statisches und dynamisches Binden von Funktionen
Statisches Binden von Funktionen Hinter beiden Beispielen steckt das gleiche Grundproblem:
Ein Objekt einer abgeleiteten Klasse darf aufgrund der is-a-Beziehung jederzeit als Objekt der Basisklasse verwendet werden. Dies gilt auch dann, wenn es u¨ ber Zeiger oder Referenzen auf Objekte der Basisklasse manipuliert wird. Da der Compiler davon ausgeht, dass es sich um Objekte der Basisklasse handelt, wird die unter Umst¨anden fehlerhafte Elementfunktion der Basisklasse aufgerufen, auch wenn sie f¨ur Objekte der abgeleiteten Klasse neu implementiert wurde.
Der Compiler sorgt daf¨ur, dass die Elementfunktion der Basisklasse aufgerufen wird, da er Funktionsaufrufe normalerweise statisch bindet. Das bedeutet, dass er feststellt, von welchem Typ ein Objekt ist, f¨ur das eine Funktion aufgerufen wird, und dass er Code generiert, der die Funktion aufruft, die zu diesem Typ geh¨ort. Dies gilt im Allgemeinen auch f¨ur Zeiger und Referenzen. Dieses statische Binden f¨uhrt dazu, dass auch dann, wenn die Zeiger auf Objekte einer abgeleiteten Klasse zeigen und deshalb eine andere Elementfunktion aufgerufen werden m¨usste, die falsche Elementfunktion der Basisklasse aufgerufen wird. Entsprechendes gilt f¨ur Referenzen. Die Information, dass eigentlich Objekte einer abgeleiteten Klasse manipuliert werden, geht also bei Funktionsaufrufen durch das statische Binden verloren. Dynamisches Binden durch virtuelle Funktionen Damit die Information, dass Objekte einer abgeleiteten Klasse manipuliert werden, bei Funktionsaufrufen nicht verlorengeht, darf nicht statisch gebunden werden. Stattdessen muss zur Laufzeit, in Abh¨angigkeit vom tats¨achlichen Typ eines Objekts, die richtige Funktion aufgerufen werden. Dieser Sachverhalt wird als dynamisches Binden (englisch: dynamic binding) oder auch sp¨ates Binden (late binding) bezeichnet.3 Dynamisches Binden ist auch in C++ m¨oglich. Dazu muss das Schl¨usselwort virtual verwendet werden. Wenn eine Elementfunktion als virtuelle Funktion deklariert wird, wird bei der Verwendung von Zeigern und Referenzen erst zur Laufzeit entschieden, welche Funktion 3
Der Begriff dynamisches Binden wird auch f¨ur den Mechanismus von Shared Libraries verwendet, hat damit aber nichts zu tun.
Sandini Bib
284
Kapitel 5: Vererbung und Polymorphie
tats¨achlich aufgerufen wird. In Abh¨angigkeit von der Klasse, die das Objekt besitzt, wird die dazugeh¨orige Funktion aufgerufen. Aus diesem Grund m¨ussen zumindest die Funktionen, die in der abgeleiteten Klasse KBruch neu implementiert werden, in der Basisklasse Bruch als virtuelle Funktionen deklariert werden. Sinnvollerweise werden aber alle Funktionen der Basisklasse, die von abgeleiteten Klassen u¨ berschrieben werden k¨onnen, als virtuelle Funktionen deklariert. Nur so ist eine Klasse generell zur Vererbung geeignet. Werden die Funktionen der Basisklasse nicht als virtuelle Funktionen deklariert, k¨onnen die vorher aufgezeigten Probleme auftreten. Aus diesem Grund sollte man sich bei der Vererbung an folgende Regeln halten:
Nichtvirtuelle Funktionen d¨urfen nicht u¨ berschrieben werden. Wenn dies zur Implementierung einer abgeleiteten Klasse notwendig ist, sollte nicht abgeleitet werden.
Die Deklaration der Basisklasse Bruch sollte deshalb besser wie folgt aussehen:
// vererb/bruch92.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { protected: int zaehler; int nenner; public: /* Fehlerklasse */ class NennerIstNull { }; /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1);
Sandini Bib
5.2 Virtuelle Funktionen
/* Multiplikation * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend Bruch operator * (const Bruch&, const Bruch&); /* multiplikative Zuweisung * - neu: virtuell */
virtual const Bruch& operator *= (const Bruch&); /* Vergleich * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend bool operator < (const Bruch&, const Bruch&); /* Ein- und Ausgabe mit Streams * - neu: virtuell */
virtual void printOn (std::ostream&) const; virtual void scanFrom (std::istream&); /* Typumwandlung nach double * - neu: virtuell */
};
virtual double toDouble () const;
/* Operator * * - globale Friend-Funktion * - inline definiert */
inline Bruch operator * (const Bruch& a, const Bruch& b) { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
285
Sandini Bib
286
Kapitel 5: Vererbung und Polymorphie
/* Standard-Ausgabeoperator * - global u¨ berladen und inline definiert */
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern } /* Standard-Eingabeoperator * - global u¨ berladen und inline definiert */
inline std::istream& operator >> (std::istream& strm, Bruch& b) { b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen return strm; // Stream zur Verkettung zur¨uckliefern } } // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Alle Funktionen, die aus welchen Gr¨unden auch immer in abgeleiteten Klassen neu implementiert werden k¨onnten, werden als virtuell deklariert. Ausgenommen davon sind nur Konstruktoren und Friend-Funktionen, die grunds¨atzlich nicht virtuell sein k¨onnen. Einlesen eines KBruchs mit virtuellen Funktionen Beim Einlesen eines k¨urzbaren Bruchs funktioniert damit alles wie erwartet: Es wird nun tats¨achlich die scanFrom()-Implementierung der abgeleiteten Klasse KBruch verwendet, da die Information, dass eigentlich ein KBruch eingelesen wird, in der Funktion operator>>() erhalten bleibt und somit die Funktion scanFrom() der Klasse KBruch aufgerufen wird:
namespace Bsp { class Bruch { ...
public: virtual void scanFrom (std::istream&); ...
}; inline
Sandini Bib
5.2 Virtuelle Funktionen
287
std::istream& operator >> (std::istream& strm, Bruch& b) { // da virtual: wird richtiges scanFrom() aufgerufen b.scanFrom (strm); return strm; // Stream zur Verkettung zur¨uckliefern } class KBruch : public Bruch { ...
public: virtual void scanFrom (std::istream&); ...
}
};
int main() { Bsp::KBruch x (7,3); ...
std::cin >> x;
// OK: ruft indirekt KBruch::scanFrom() auf
...
} Wenn die f¨ur Br¨uche u¨ berladene Operatorfunktion >> f¨ur ihren Parameter b die Elementfunktion scanFrom() aufruft, wird, da b eine Referenz ist und scanFrom() in der Klasse virtuell deklariert wird, erst zur Laufzeit entschieden, welche Klasse b tats¨achlich besitzt. Falls es sich um ein Objekt einer abgeleiteten Klasse handelt, wird automatisch die Implementierung von scanFrom() dieser Klasse aufgerufen, sofern sie eine eigene besitzt. Die Funktion scanFrom() muss in der abgeleiteten Klasse dazu nicht unbedingt virtuell deklariert werden. Da die Funktion in der Basisklasse virtuell ist, ist sie es in der abgeleiteten Klasse automatisch auch. Mit der erneuten Deklaration als virtuell wird aber in der abgeleiteten Klasse deutlich gemacht, dass es sich bei dieser Funktion um eine virtuelle Funktion handelt. Laufzeitnachteile durch virtual Man beachte, dass ein Funktionsaufruf durch die Deklaration als virtuelle Funktion zum Teil deutlich l¨anger dauern kann. Statt eines direkten Funktionsaufrufs muss f¨ur Zeiger und Referenzen Code generiert werden, der zur Laufzeit erst feststellt, welche Funktion aufzurufen ist. Besonders krass ist der Unterschied bei Inline-Funktionen. Da bei Zeigern und Referenzen erst zur Laufzeit entschieden wird, welche Funktion aufzurufen ist, kann der Funktionsaufruf nicht schon beim Kompilieren durch die Anweisungen in der Funktion ersetzt werden. Der Laufzeitvorteil von Inline-Funktionen entf¨allt damit f¨ur Zeiger und Referenzen. Aus diesem Grund kann es in der Praxis sinnvoll sein, Klassen nur mit einigen oder ganz ohne virtuelle Funktionen auszustatten. Die Klassen sind dann zwar nur noch schlecht zur Vererbung geeignet, das Laufzeitverhalten ist daf¨ur aber zum Teil deutlich besser. Letztendlich handelt es sich hierbei wieder um eine Design-Entscheidung.
Sandini Bib
288
Kapitel 5: Vererbung und Polymorphie
¨ ¨ Uberladen kontra Uberschreiben
5.2.3
In der Erwartung, dass mit virtuellen Funktionen alles funktioniert, mag man das Anwendungsprogramm von Seite 281 erneut starten und Erstaunliches feststellen: Das eine Problem ist zwar gel¨ost, das andere aber nicht. Die multiplikative Zuweisung u¨ ber den Zeiger xp funktioniert auch mit virtuellen Funktionen nicht. Dies liegt daran, dass die virtuelle Operatorfunktion der Basisklasse nicht u¨ berschrieben, sondern u¨ berladen wurde. Die Parameter sind n¨amlich nicht gleich:
namespace Bsp { class Bruch { ...
public: virtual const Bruch& operator *= (const Bruch&); ...
}; class KBruch : public Bruch { ...
public: virtual const KBruch& operator*= (const KBruch&); ...
}
};
int main() { Bsp::KBruch x(7,3); Bsp::Bruch b(3,7); Bsp::Bruch* xp = &x; ...
*xp *= b;
// PROBLEM: ruft immer noch Bruch::operator*=() auf
...
} In der Basisklasse besitzt der Parameter von Operator *= die Klasse Bruch, in der abgeleiteten Klasse aber die Klasse KBruch. Damit ist die Implementierung der abgeleiteten Klasse kein echter Ersatz f¨ur die Implementierung der Basisklasse, sondern nur eine Erg¨anzung. F¨ur Objekte der abgeleiteten Klasse wird zwar die neue Implementierung aufgerufen, f¨ur Zeiger und Referenzen der Klasse Bruch existiert aber nach wie vor nur die Implementierung der Basisklasse. Sie wurde in der abgeleiteten Klasse nicht u¨ berschrieben. Es wird zwar erst zur Laufzeit entschieden, welche Funktion aufgerufen werden muss; da es aber keine neue Implementierung der in der Basisklasse definierten Operation gibt, wird auch f¨ur KBr¨uche die Implementierung der Basisklasse Bruch verwendet.
Sandini Bib
5.2 Virtuelle Funktionen
289
¨ Beim Uberschreiben von geerbten Funktionen ist deshalb auch folgende Regel zu beachten:
Eine virtuelle Funktion der Basisklasse wird in einer abgeleiteten Klasse nur dann wirklich u¨ berschrieben, wenn die Anzahl und die Typen der Parameter identisch sind.
Um das Problem zu umgehen, muss der Operator *= in der abgeleiteten Klasse mit den gleichen Parametern und dem gleichen R¨uckgabetyp deklariert werden:
namespace Bsp { class KBruch : public Bruch { ...
public: virtual const Bruch& operator*= (const Bruch&); ...
}
};
¨ Wie grunds¨atzlich beim Uberladen von Funktionen ist auch hier eine Unterscheidung nur durch den R¨uckgabetyp unzul¨assig. Zu dieser Regel gibt es allerdings ein Ausnahme: Liefert eine virtuelle Funktion einer Basisklasse ein Objekt dieser Basisklasse zur¨uck, darf bei einer Neuimplementierung in einer abgeleiteten Klasse statt auch ein Objekt dieser abgeleiteten Klasse zur¨uckgeliefert werden. Ein Zeiger muss aber ein Zeiger und eine Referenz muss eine Referenz bleiben. Damit ist auch die folgende Neuimplementierung m¨oglich:
namespace Bsp { class KBruch : public Bruch { ...
public: virtual const KBruch& operator*= (const Bruch&); ...
}
5.2.4
};
Zugriff auf Parameter der Basisklasse
Mit der hier vorgestellten L¨osung, dass der Parameter ein Objekt der Basisklasse ist, entsteht allerdings sofort ein anderes Problem: Bei der Implementierung des Operators ist es nun nicht mehr m¨oglich, auf die nicht¨offentlichen Komponenten des Parameters zuzugreifen:
const Bruch& KBruch::operator*= (const Bruch& b) { // FEHLER: kein Zugriff auf b.zaehler zaehler *= b.zaehler; // FEHLER: kein Zugriff auf b.nenner nenner *= b.nenner; ...
}
Sandini Bib
290
Kapitel 5: Vererbung und Polymorphie
Der Parameter b ist n¨amlich ein Objekt einer anderen Klasse als der Klasse KBruch, f¨ur die hier eine Elementfunktion implementiert wird. Dabei spielt es keine Rolle, ob die Klasse eine Basisklasse ist und welcher Zugriff auf Komponenten der Basisklasse besteht. Die Klasse KBruch ist in Bezug auf Parameter nur ein Anwender der Klasse Bruch und hat nur auf deren o¨ ffentliche Komponenten Zugriff. Es gilt also noch eine Regel:
F¨ur als Parameter u¨ bergebene Objekte von Basisklassen besteht in abgeleiteten Klassen nur Zugriff auf o¨ ffentliche Komponenten.
Aus diesem Grund kann der Zugriff auf die Komponenten des Parameters nur u¨ ber dessen o¨ ffentliche Schnittstelle durchgef¨uhrt werden. Da keine Elementfunktionen zum Abfragen der Werte von Z¨ahler und Nenner existieren (kommerzielle Klassen h¨atten diese sicherlich), muss in diesem Fall die Implementierung der Basisklasse aufgerufen werden, die den KBruch zun¨achst mit dem u¨ bergebenen Parameter ausmultipliziert. Anschließend k¨onnen eventuell vorhandene Inkonsistenzen beseitigt werden:
const Bruch& KBruch::operator*= (const Bruch& b) { /* neu: Implementierung der Basisklasse aufrufen * - auf nicht¨offentliche Komponenten von b besteht kein Zugriff */ Bruch::operator*= (b); // weiterhin gek¨urzt?
if (!kuerzbar) { kuerzbar = (ggt() > 1); } }
5.2.5
return
*this;
Virtuelle Destruktoren
Es gibt noch ein weiteres Problem, das bei der Vererbung auftreten kann. Es betrifft die explizite Speicherverwaltung. Wenn ein Objekt einer abgeleiteten Klasse mit new angelegt wird, ist klar, welchen Typ es besitzt:
Bsp::KBruch* kbp = new Bsp::KBruch;
// Objekt der Klasse KBruch anlegen
Bei der Freigabe des Objekts mit delete kann es aber vorkommen, dass diese Klarheit nicht besteht. Da es als Objekt einer Basisklasse verwendet werden kann, kann es auch als Objekt einer Basisklasse freigegeben werden:
Sandini Bib
5.2 Virtuelle Funktionen
291
Bsp::Bruch* bp; bp = new Bsp::KBruch;
// KBruch anlegen
...
delete bp;
// KBruch u¨ ber Bruch-Zeiger freigeben
Hier tritt das gleiche Problem auf, das auch sonst bei abgeleiteten Klassen auftreten kann: Der Delete-Operator f¨uhrt zum Aufruf eventuell vorhandener Destruktoren. Sofern aber kein dynamisches Binden stattfindet, geht die Information, dass eigentlich ein Objekt einer abgeleiteten Klasse freigegeben wird, verloren, und es wird nur der Destruktor der Klasse Bruch aufgerufen. Wenn also f¨ur KBr¨uche ein Destruktor implementiert wird, wird dieser dann nicht aufgerufen. Wenn der Destruktor einer abgeleiteten Klasse z.B. f¨ur das Objekt angelegten Speicherplatz freigibt, w¨urde dieser nicht freigegeben. Damit dieser Fall nicht eintritt, muss in der Basisklasse ein virtueller Destruktor deklariert werden (der Default-Destruktor ist nicht virtuell). Dies gilt auch dann, wenn die Basisklasse eigentlich keinen Destruktor braucht. Eine weitere Regel zur Vererbung lautet also:
Eine Klasse ist nur dann generell zur Vererbung geeignet, wenn ein virtueller Destruktor deklariert wird.
In unserem Beispiel bedeutet das, dass die Basisklasse Bruch einen virtuellen Destruktor besitzen muss, der allerdings keine Anweisungen enth¨alt:
namespace Bsp { class Bruch { ...
public: virtual ~Bruch () { } ...
}
5.2.6
};
Vererbung richtig angewendet
Zusammenfassend wird nun noch einmal das Beispiel der Klassen Bruch und KBruch unter Ber¨ucksichtigung aller m¨oglichen Probleme und deren L¨osung vorgestellt. Deklaration der Basisklasse Bruch Die Basisklasse Bruch muss die Bedingungen erf¨ullen, die sie f¨ur eine Vererbung geeignet machen:
Alle Funktionen, die u¨ berschrieben werden k¨onnten, m¨ussen als virtuell deklariert werden. Es muss ein virtueller Destruktor definiert werden.
Es ergibt sich daher folgender Aufbau der Headerdatei fu¨ r die Basisklasse Bruch:
Sandini Bib
292
Kapitel 5: Vererbung und Polymorphie
// vererb/bruch93.hpp #ifndef BRUCH_HPP #define BRUCH_HPP // Standard-Headerdateien einbinden
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Bruch { protected: int zaehler; int nenner; public: /* Fehlerklasse */ class NennerIstNull { }; /* Default-Konstruktor, Konstruktor aus Z¨ahler und * Konstruktor aus Z¨ahler und Nenner */
Bruch (int = 0, int = 1); /* Multiplikation
* - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
friend Bruch operator * (const Bruch&, const Bruch&); /* multiplikative Zuweisung * - neu: virtuell */
virtual const Bruch& operator *= (const Bruch&); /* Vergleich * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist
Sandini Bib
5.2 Virtuelle Funktionen */ friend bool operator < (const Bruch&, const Bruch&);
/* Ein- und Ausgabe mit Streams * - neu: virtuell */
virtual void printOn (std::ostream&) const; virtual void scanFrom (std::istream&); /* Typumwandlung nach double * - neu: virtuell */
virtual double toDouble () const; // neu: virtueller Destruktor (ohne Anweisungen)
};
virtual ~Bruch () { }
/* Operator * * - globale Friend-Funktion * - inline definiert */
inline Bruch operator * (const Bruch& a, const Bruch& b) { /* Z¨ahler und Nenner einfach multiplizieren * - das K¨urzen sparen wir uns */
}
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
/* Standard-Ausgabeoperator * - global u¨ berladen und inline definiert */
inline std::ostream& operator << (std::ostream& strm, const Bruch& b) { // Elementfunktion zur Ausgabe aufrufen b.printOn(strm); return strm; // Stream zur Verkettung zur¨uckliefern }
293
Sandini Bib
294
Kapitel 5: Vererbung und Polymorphie
/* Standard-Eingabeoperator * - global u¨ berladen und inline definiert */
inline std::istream& operator >> (std::istream& strm, Bruch& b) { b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen return strm; // Stream zur Verkettung zur¨uckliefern } } // **** ENDE Namespace Bsp ******************************** #endif // BRUCH_HPP Die dazugeh¨orige Implementierung entspricht der Version auf Seite 244. Deklaration der abgeleiteten Klasse KBruch In der abgeleiteten Klasse muss Folgendes beachtet werden:
Die abgeleitete Klasse darf nur Funktionen u¨ berschreiben, die in der Basisklasse als virtuell deklariert wurden. Dabei m¨ussen die Parameter und der R¨uckgabetyp u¨ bereinstimmen. Wenn die abgeleitete Klasse selbst wieder zur Vererbung geeignet sein soll, muss auch bei ihr ¨ jede neu hinzugekommene und zum Uberschreiben geeignete Funktion als virtuell deklariert werden.
Es ergibt sich daher folgender Aufbau der Headerdatei fu¨ r die abgeleitete Klasse KBruch:
// vererb/kbruch3.hpp #ifndef KBRUCH_HPP #define KBRUCH_HPP // Headerdatei der Basisklasse
#include "bruch.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse KBruch * - abgeleitet von Bruch * - der Zugriff auf geerbte Komponenten wird nicht * eingeschr¨ankt (public bleibt public) * - zur Weiter-Vererbung geeignet */
Sandini Bib
5.2 Virtuelle Funktionen
295
class KBruch : public Bruch { protected: // true: Bruch ist k¨urzbar bool kuerzbar; // Hilfsfunktion: gr¨oßter gemeinsamer Teiler von Z¨ahler und Nenner
unsigned ggt() const; public: /* Default-Konstruktor, Konstruktor aus Z¨ahler * und Konstruktor aus Z¨ahler und Nenner * - Parameter werden an Bruch-Konstruktor durchgereicht */
KBruch (int z = 0, int n = 1) : Bruch(z,n) { kuerzbar = (ggt() > 1); } // multiplikative Zuweisung (neu implementiert)
virtual const KBruch& operator*= (const Bruch&); // Eingabe mit Streams (neu implementiert)
virtual void scanFrom (std::istream&); // Bruch k¨urzen
virtual void kuerzen(); // K¨urzbarkeit testen
};
virtual bool istKuerzbar() const { return kuerzbar; }
} // **** ENDE Namespace Bsp ******************************** #endif
// KBRUCH_HPP
Implementierung der abgeleiteten Klasse In der Quelldatei der abgeleiteten Klasse werden die neuen und u¨ berschriebenen Funktionen implementiert. Dabei muss darauf geachtet werden, dass auch auf Objekte der Basisklasse, die als Parameter u¨ bergeben werden, nur o¨ ffentlicher Zugriff besteht:
Sandini Bib
296
Kapitel 5: Vererbung und Polymorphie
// vererb/kbruch3.cpp // Headerdatei f¨ur min() und abs() #include #include // Headerdatei der eigenen Klasse einbinden
#include "kbruch.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* ggt() * - gr¨oßter gemeinsamer Teiler von Z¨ahler und Nenner */
unsigned KBruch::ggt () const { if (zaehler == 0) { return nenner; }
/* Gr¨oßte Zahl ermitteln, die sowohl Z¨ahler als auch * Nenner ohne Rest teilt */
}
unsigned teiler = std::min(std::abs(zaehler),nenner); while (zaehler % teiler != 0 || nenner % teiler != 0) { teiler--; } return teiler;
/* kuerzen() */ void KBruch::kuerzen () { // falls k¨urzbar, Z¨ahler und Nenner durch GGT teilen
if (kuerzbar) { int teiler = ggt(); zaehler /= teiler; nenner /= teiler;
Sandini Bib
5.2 Virtuelle Funktionen
}
}
297
kuerzbar = false;
// damit nicht mehr k¨urzbar
/* Operator *= ¨ * - zum Uberschreiben mit Typen der Basisklasse neu implementiert */
const KBruch& KBruch::operator*= (const Bruch& b) { /* Implementierung der Basisklasse aufrufen * - auf nicht¨offentliche Komponenten von b besteht kein Zugriff */ Bruch::operator*= (b); // weiterhin gek¨urzt ?
if (!kuerzbar) { kuerzbar = (ggt() > 1); } }
return
*this;
/* scanFrom() */ void KBruch::scanFrom (std::istream& strm) { Bruch::scanFrom (strm); // scanFrom() der Basisklasse aufrufen }
kuerzbar = (ggt() > 1);
// K¨urzbarkeit testen
} // **** ENDE Namespace Bsp ********************************
5.2.7
¨ Weitere Fallen beim Uberschreiben von Funktionen
¨ Es gibt noch einige Fallen, die beim Uberschreiben von Elementfunktionen der Basisklasse beachtet werden m¨ussen. Sie werden im Folgenden vorgestellt. Unterschiedliche Default-Argumente Die Default-Argumente von Funktionen werden beim Kompilieren eingesetzt. Sie sind damit immer statisch.
Sandini Bib
298
Kapitel 5: Vererbung und Polymorphie
Wenn nun virtuelle Funktionen in der Basisklasse einen anderen Default-Wert besitzen als in der u¨ berschriebenen Implementierung der abgeleiteten Klasse, wird zwar die richtige Funktion aufgerufen, aber der falsche Default-Wert u¨ bergeben. Nehmen wir z.B. an, f¨ur Br¨uche sei eine Funktion init() definiert, die als Default-Argument 0 besitzt. Die abgeleitete Klasse KBruch u¨ berschreibt die Funktion, vergibt als Default-Argument aber 1:
class Bruch { ...
};
virtual void init (int = 0);
class KBruch : public Bruch { ...
};
virtual void init (int = 1);
Ein Aufruf der Funktion init() kann dann dazu f¨uhren, dass unterschiedliche Default-Werte verwendet werden, obwohl die Implementierung der abgeleiteten Klasse aufgerufen wird:
Bsp::KBruch x; x.init();
// ruft f¨ur x auf: KBruch::init(1)
Bsp::Bruch* xp = &x; // ruft f¨ur x auf: KBruch::init(0) xp->init(); Um dies zu vermeiden, gilt die Regel: ¨ Funktionen sollten die gleichen Default-Argumente wie ihre Basisklassen Uberschriebene besitzen. ¨ Uberladen heißt uberdecken ¨ ¨ Wir haben schon gesehen, dass beim Uberschreiben einer geerbten Funktion die Parametertypen ¨ ¨ gleich sein sollten, da sonst kein echtes Uberschreiben, sondern ein Uberladen stattfindet. Nun kann es aber auch sein, dass genau das gewollt ist. Eine Funktion soll geerbt werden und zus¨atzlich um eine gleichnamige Funktion mit anderen Parametern erg¨anzt werden. In dem Fall ist zu beachten, dass die Funktion der Basisklasse zwar nicht u¨ berschrieben wird, dass sie trotzdem aber nicht mehr von Objekten der abgeleiteten Klasse aufgerufen werden kann. Betrachten wir dazu folgendes Beispiel: Die Basisklasse Bruch definiert die Elementfunktion init() f¨ur einen int als Parameter. Die abgeleitete Klasse KBruch definiert die gleiche Funktion dagegen mit dem Typ Bruch:
class Bruch { ...
};
void init (int = 0);
Sandini Bib
5.2 Virtuelle Funktionen
299
class KBruch : public Bruch { ...
};
void init (const Bruch&);
F¨ur Objekte der abgeleiteten Klasse kann init() nur noch mit einem Bruch als Parameter aufgerufen werden:
Bsp::KBruch x; Bsp::Bruch a; ...
x.init(a);
// OK
...
x.init(7);
// FEHLER: Bruch::init(int) wurde u¨ berdeckt ¨ Uberladen heißt in abgeleiteten Klassen also immer auch u¨ berdecken. Diese Eigenschaft wurde in C++ zur Sicherheit eingef¨uhrt, damit ein versehentlich falscher Parametertyp besser als Fehler erkannt werden kann. Soll die Funktion der Basisklasse weiterhin verwendbar sein, muss sie erneut definiert werden:
class Bruch { ...
};
void init (int);
class KBruch : public Bruch { ...
};
5.2.8
void init (const Bruch&); void init (int i = 0) { Bruch::init (i); }
Private Vererbung und reine Zugriffsdeklarationen
So wie die Klasse KBruch bisher deklariert wurde, kann noch ein Problem bei der Anwendung der Klasse auftreten: Der Operator *= wurde zwar u¨ berschrieben, um nach einer multiplikativen Zuweisung zu testen, ob das Objekt immer noch k¨urzbar ist, die u¨ berschriebene Implementierung der Basisklasse kann aber weiterhin aufgerufen werden. Durch Verwendung des Bereichsoperators kann n¨amlich explizit verlangt werden, dass die Implementierung der Basisklasse aufgerufen wird:
Sandini Bib
300
Kapitel 5: Vererbung und Polymorphie
// vererb/kbruchtest4.cpp // Headerdateien
#include #include "kbruch.hpp" int main() { // KBruch deklarieren
Bsp::KBruch x(7,3); /* x mit 3 multiplizieren * ABER: Operator der Basisklasse Bruch verwenden */
x.Bsp::Bruch::operator *= (3); // x ausgeben
std::cout << x; std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)") << std::endl;
}
Dies kann dann wie hier im Beispiel dazu f¨uhren, dass Inkonsistenzen entstehen (der Bruch x ist nach der Multiplikation k¨urzbar, tr¨agt aber die Information, er w¨are nicht k¨urzbar). Ein solcher Aufruf ist m¨oglich, da bei der Deklaration der Klasse angegeben wurde, dass o¨ ffentliche Funktionen der Basisklasse weiterhin o¨ ffentlich bleiben. Dies gilt selbst dann, wenn sie u¨ berschrieben werden. Man kann nun streiten, ob dies ein echtes Problem ist. Da explizit angegeben werden muss, dass die Implementierung der Basisklasse verwendet werden soll, wird eine solche Inkonsistenz nicht aus Versehen vorkommen. Wer dies also ausdr¨ucklich w¨unscht, sollte sich auch u¨ ber die Konsequenzen im Klaren sein. Andererseits werden Klassen unter anderem dazu eingef¨uhrt, um durch eine wohldefinierte Schnittstelle zu Objekten keine Inkonsistenzen erzeugen zu k¨onnen. Das Problem kann vermieden werden, indem durch Verwendung der Zugriffsschl¨usselw¨orter private oder protected nicht¨offentlich abgeleitet wird:
class KBruch : protected Bruch { ...
}; Dies hat zur Folge, dass alle Elementfunktionen der Basisklasse nicht¨offentlich werden. Sie k¨onnen vom Anwender der Klasse damit nicht mehr aufgerufen werden oder m¨ussen erneut implementiert werden. Im Beispiel der Klasse KBruch w¨urde das z.B. bedeuten, dass unter anderem die I/O-Operationen noch einmal o¨ ffentlich deklariert und damit auch noch einmal implementiert werden m¨ussten. In diesem Fall w¨urde aber die Vererbung an sich in Frage gestellt. Wenn sowieso jede Funktion neu definiert werden muss, ist es meist einfacher, eine neue Klasse zu implementieren, da die angesprochenen Probleme dann nicht auftauchen k¨onnen.
Sandini Bib
5.2 Virtuelle Funktionen
301
Um dieses Dilemma zu l¨osen, gibt es die M¨oglichkeit, Komponenten mit unterschiedlichen Einschr¨ankungen zu erben. Eine Klasse muss dazu grunds¨atzlich protected oder private geerbt werden, diese Einschr¨ankung kann dann aber f¨ur einzelne Komponenten wieder aufgehoben werden. Dies geschieht durch reine Zugriffsdeklarationen (englisch: access declarations). Damit kann die Deklaration der Klasse KBruch wie folgt aussehen:
// vererb/kbruch5.hpp #ifndef KBRUCH_HPP #define KBRUCH_HPP // Headerdatei der Basisklasse
#include "bruch.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse KBruch * - abgeleitet von Bruch * - neu: der Zugriff auf geerbte Komponenten wird eingeschr¨ankt * (public wird protected) * - damit gilt die is-a-Beziehung nicht mehr */ class KBruch : protected Bruch {
protected: bool kuerzbar;
// true: Bruch ist k¨urzbar
// Hilfsfunktion: gr¨oßter gemeinsamer Teiler von Z¨ahler und Nenner
unsigned ggt() const; public: /* Default-Konstruktor, Konstruktor aus Z¨ahler * und Konstruktor aus Z¨ahler und Nenner * - Parameter werden an Bruch-Konstruktor durchgereicht */
KBruch (int z = 0, int n = 1) : Bruch(z,n) { kuerzbar = (ggt() > 1); }
// neu: reine Zugriffsdeklaration f¨ur Operationen, die public bleiben
Bruch::printOn; Bruch::toDouble;
Sandini Bib
302
Kapitel 5: Vererbung und Polymorphie // multiplikative Zuweisung
virtual const KBruch& operator*= (const Bruch&); // Eingabe mit Streams
virtual void scanFrom (std::istream&); // Bruch k¨urzen
virtual void kuerzen(); // K¨urzbarkeit testen
};
virtual bool istKuerzbar() const { return kuerzbar; }
} // **** ENDE Namespace Bsp ******************************** #endif
// KBRUCH_HPP
Mit dieser Implementierung kann die multiplikative Zuweisung der Basisklasse nicht mehr f¨ur Objekte der abgeleiteten Klasse aufgerufen werden. I/O-Operationen und die Konvertierung in einen Gleitkommawert sind durch die reine Zugriffsdeklaration aber weiterhin ohne Neuimplementierung m¨oglich:
namespace Bsp { class KBruch : protected Bruch { public:
// neu: reine Zugriffsdeklaration f¨ur Operationen, die public bleiben
Bruch::printOn; Bruch::asDouble; ...
}
};
Private Vererbung kontra is-a-Beziehung Private Vererbung reduziert die allgemeinen Konzepte der Vererbung auf reine Code-Wiederverwendung. Insbesondere gilt die is-a-Beziehung nicht mehr. Es ist keine automatische Typumwandlung von einem Objekt der abgeleiteten in ein Objekt der Basisklasse definiert:
Bsp::KBruch x(91,39); ...
Bsp::Bruch* xp = &x; // FEHLER: keine is-a-Beziehung bei privater Vererbung
Sandini Bib
5.2 Virtuelle Funktionen
303
Aus dem gleichen Grund k¨onnen auch weder die Friend-Funktionen noch die Standard-I/OOperatoren der Klasse Bruch verwendet werden. Sie m¨ussen f¨ur die Klasse KBruch alle neu definiert werden. Ohne Neuimplementierung des Ausgabeoperators f¨ur KBr¨uche, kann dieser dann also nicht ausgegeben werden:
Bsp::KBruch x(91,39); ...
std::cout << x;
// FEHLER: kein operator<<(ostream,KBruch) definiert
Semantisch handelt es sich bei dieser Form der Vererbung nicht um eine Vererbung sondern um eine Wiederverwendung. Durch diese Art der Implementierung k¨onnen auf einfache Weise Komponenten und Elementfunktionen u¨ bernommen werden. Eine konzeptionelle Vererbung, die z.B. zur Arbeit mit Oberbegriffen dient und schon beim Programm-Design eine Rolle spielt (siehe Abschnitt 5.3), kann nur durch o¨ ffentliche Vererbung implementiert werden.
5.2.9
Zusammenfassung
Sofern nicht anders angegeben, werden Funktionsaufrufe statisch gebunden. Es wird die Elementfunktion aufgerufen, die f¨ur die Klasse definiert ist, mit der das Objekt deklariert wurde. Mit dem Schl¨usselwort virtual kann das statische Binden f¨ur Zeiger und Referenzen aufgehoben werden. Es wird Code generiert, durch den erst zur Laufzeit in Abh¨angigkeit von der Klasse, die ein Objekt tats¨achlich hat, die richtige Funktion aufgerufen wird. Durch virtuelle Funktionen ist es m¨oglich, Funktionen von Basisklassen zu u¨ berschreiben, ohne dass durch die Verwendung von Zeigern oder Referenzen der Basisklasse Probleme auftreten k¨onnen. Eine Klasse, die uneingeschr¨ankt zur Vererbung geeignet ist, sollte folgende Voraussetzungen erf¨ullen: – Alle Funktionen, die von abgeleiteten Klassen u¨ berschrieben werden k¨onnten, m¨ussen als virtuelle Funktionen deklariert werden.
– Es muss ein virtueller Destruktor definiert werden. Bei der Implementierung einer abgeleiteten Klasse sollte Folgendes beachtet werden: ¨ – Das Uberschreiben von Funktionen, die in der Basisklasse nicht virtuell sind, kann sehr problematisch sein. – Funktionen der Basisklasse gelten nur dann als u¨ berschrieben, wenn die Typen bei der Deklaration gleich sind. ¨ – Uberschriebene Funktionen sollten die gleichen Default-Argumente besitzen. – Mit dem Bereichsoperator k¨onnen u¨ berschriebene virtuelle Funktionen aufgerufen werden.
– Auf als Parameter u¨ bergebene Objekte einer Basisklasse besteht nur o¨ ffentlicher Zugriff. Durch eine private Vererbung kann die is-a-Beziehung bei Vererbung umgangen werden. Die Vererbung dient in diesem Fall nur noch zur Code-Wiederverwendung.
Sandini Bib
304
Kapitel 5: Vererbung und Polymorphie
5.3 Polymorphie In diesem Abschnitt wird das Konzept der Polymorphie und seine Realisierung in C++ vorgestellt.
5.3.1
Was ist Polymorphie?
Polymorphie (oder auch Vielgestaltigkeit) beschreibt die F¨ahigkeit, dass eine Operation f¨ur verschiedenartige Objekte aufgerufen werden kann und dabei zu unterschiedlichen Auswirkungen f¨uhrt. Der Aufruf einer Operation f¨ur ein Objekt wird als Nachricht an dieses Objekt betrachtet, die von ihm selbst interpretiert wird. Da eine Operation f¨ur unterschiedliche Objekte unterschiedlich definiert sein kann, f¨uhrt die gleiche Operation dann zu v¨ollig unterschiedlichen Reaktionen. Auf C++ u¨ bertragen bedeutet das, dass Objekte, die verschiedene Klassen besitzen, auf den gleichen Funktionsaufruf unterschiedlich reagieren k¨onnen. Die Tatsachen, dass der gleiche Funktionsname in unterschiedlichen Klassen verwendet werden kann und dass Funktionen u¨ berladen werden k¨onnen, sind polymorphe Sprachmittel von C++, die wir bereits kennen gelernt haben. Abstraktion durch Oberbegriffe Eine besondere Qualit¨at, um die es vor allem in diesem Abschnitt geht, ist die Tatsache, dass die klassenspezifischen Unterschiede von Objekten zeitweise verloren gehen ko¨ nnen. Dies f¨uhrt zu einem wesentlichen Punkt, in dem objektorientierte Sprachen den herk¨ommlichen Sprachen u¨ berlegen sind: Sie unterst¨utzen das Abstraktionsmittel des Oberbegriffs: Verschiedenartige Objekte k¨onnen zeitweise unter einem gemeinsamen Oberbegriff betrachtet und auch manipuliert werden. Bei der Manipulierung gehen die Unterschiede aber nicht verloren. Anwendungsbeispiele f¨ur die Verwendung von Oberbegriffen gibt es in H¨ulle und F¨ulle:
Verschiedene Autos, Busse, Laster und Fahrr¨ader werden unter dem Oberbegriff Fahrzeug betrachtet. Als solches k¨onnen sie sich z.B. an einem bestimmten Ort befinden und zu einem anderen Ort fahren. Verschiedene Integer, Gleitkommawerte, Br¨uche und komplexe Zahlen werden allgemein als numerische Werte betrachtet, die addiert und multipliziert werden k¨onnen. Verschiedene Kreise, Rechtecke, Linien usw. werden als geometrische Objekte betrachtet, die sich in einer Grafik befinden, eine Farbe besitzen und ausgegeben werden k¨onnen. Verschiedene Arten von Personen (wie Kunde, Angestellter, Chef, Abteilungsleiter) werden als Personen betrachtet, die einen Namen und andere Eigenschaften haben.
Unterst¨utzung von Oberbegriffen bedeutet dabei, dass in Programmen, in denen verschiedenartige Objekte unter einem Oberbegriff behandelt werden, keinerlei R¨ucksicht auf deren tats¨achliche Klasse genommen werden muss.
Eine Funktion f¨ur Fahrzeuge verwendet nur den Typ Fahrzeug. Damit kann sie automatisch Autos, Busse, Laster usw. verwalten. Ein grafischer Editor verwendet nur den Typ GeometrischesObjekt. Damit kann der Editor automatisch Kreise, Rechtecke, Linien usw. verwalten, ausgeben und manipulieren.
Sandini Bib
5.3 Polymorphie
305
Aufgrund der Polymorphie spielen die Unterschiede aber dennoch eine Rolle, da die Operationen von den Objekten, die verwaltet werden, unterschiedlich interpretiert werden:
Wenn ein Fahrzeug f¨ahrt (die Elementfunktion fahren() aufgerufen wird), wird automatisch in Abh¨angigkeit vom tats¨achlichen Typ des Objekts die dazugeh¨orige Operation aufgerufen. Ist das Fahrzeug ein Laster, wird das f¨ur Laster definierte Fahren durchgef¨uhrt (fahren() der Klasse Laster aufgerufen). Ist es ein Auto, wird automatisch das f¨ur Autos definierte Fahren durchgef¨uhrt. Soll ein grafisches Objekt ausgegeben werden, wird automatisch in Abh¨angigkeit von dessen Art die entsprechende Operation aufgerufen. Handelt es sich um einen Kreis, wird die Operation zum Ausgeben eines Kreises aufgerufen, handelt es sich dagegen um ein Rechteck, wird die daf¨ur definierte Ausgabeoperation verwendet.
Bei der Implementierung der Anwendungssoftware f¨ur den Oberbegriff muss noch nicht einmal bekannt sein, welche Typen unter dem Oberbegriff zusammengefasst werden:
Wenn Boote als neue Fahrzeugart eingef¨uhrt werden, kann die Funktion f¨ur Fahrzeuge automatisch auch Boote fahren lassen. Wenn Dreiecke oder Textelemente als neue Art geometrische Objekte eingef¨uhrt werden, kann der grafische Editor automatisch auch diese verwalten.
Die Polymorphie erm¨oglicht es also, dass verschiedenartige Objekte in inhomogenen Mengen zumindest zeitweise unter einem gemeinsamen Oberbegriff verarbeitet werden. Dabei wird in Abh¨angigkeit vom tats¨achlichen Typ des Objekts automatisch die jeweils richtige Funktion aufgerufen.
5.3.2
Polymorphie in C++
Die Polymorphie kann in verschiedenen Sprachen unterschiedlich realisiert sein. In Smalltalk, dem Urbeispiel einer objektorientierten Sprache, wird die Polymorphie als Sprachkonzept durch Typfreiheit realisiert: Variablen haben grunds¨atzlich keinen Typ, sondern k¨onnen beliebige Objekte verwalten. Wenn f¨ur ein Objekt eine Operation aufgerufen wird, wird immer zur Laufzeit in Abh¨angigkeit vom jeweiligen Objekttyp die dazugeh¨orige Funktion aufgerufen. Gibt es keine entsprechende Funktion, wird eine Fehlermeldung ausgegeben.4 Insofern ist Smalltalk eine interpretierende Sprache. Polymorphie mit Typuberpr ¨ ufung ¨ In C++ dagegen gibt es eine Typ¨uberpr¨ufung. Schon der Compiler pr¨uft, ob ein Funktionsaufruf erlaubt ist. Dieses Konzept wird auch bei der Polymorphie aufrechterhalten. F¨ur einen Oberbegriff muss eine Klasse definiert werden, die festlegt, welche Funktionen f¨ur Objekte unter dem Oberbegriff aufgerufen werden k¨onnen. Diese m¨ussen allerdings noch nicht implementiert werden. 4
Korrekterweise muss es in objektorientierter Lesart heißen, dass Objekten in Smalltalk jeweils eine Nach” richt“ geschickt wird, die von einer daf¨ur definierten Methode“ behandelt wird. ”
Sandini Bib
306
Kapitel 5: Vererbung und Polymorphie
Wenn ein Objekt einer Klasse angelegt wird, das unter dem Oberbegriff verwaltet werden kann, wird gepr¨uft, ob auch f¨ur alle unter dem Oberbegriff definierten Operationen eine Implementierung existiert. Damit stellt bereits der Compiler sicher, dass jeder Funktionsaufruf zur Laufzeit m¨oglich ist. Im Gegensatz zu Smalltalk ist C++ also keine interpretierende Sprache. LaufzeitFehlermeldungen u¨ ber nicht vorhandene Funktionen entfallen. Eine derartige Polymorphie kann man in C++ mit zwei verschiedenen Sprachmitteln implementieren: mit Vererbung und mit Templates. In weiteren Verlauf dieses Abschnitts wird zun¨achst die u¨ bliche Form von Polymorphie mit Vererbung vorgestellt. In Abschnitt 7.5.3 wird dann auf die Implementierung von Polymorphie mit Templates eingegangen. Sprachmittel fur ¨ Polymorphie Die Sprachmittel f¨ur die Polymorphie sind an sich schon bekannt:
F¨ur gemeinsame Eigenschaften verschiedener Klassen werden einfach gemeinsame Basisklassen verwendet. In diesen Klassen werden die Attribute und die Operationen definiert, die f¨ur den jeweiligen Oberbegriff gelten. Die verschiedenen Objektarten, die unter dem Oberbegriff betrachtet werden k¨onnen, werden jeweils als Klasse von der Klasse f¨ur den Oberbegriff abgeleitet. Dabei k¨onnen die in der Basisklasse definierten Operationen in den abgeleiteten Klassen unterschiedlich implementiert werden. Aufgrund der is-a-Beziehung k¨onnen die Objekte der verschiedenen abgeleiteten Klassen zeitweise als Objekte der Basisklasse betrachtet und damit unter ihrem Oberbegriff verwendet werden. Damit zur Laufzeit in Abh¨angigkeit von der Klasse, die ein Objekt tats¨achlich besitzt, die richtige Funktion aufgerufen wird, werden die Funktionen als virtuell deklariert.
Hinzu kommt die Besonderheit, dass in der Basisklasse f¨ur einen Oberbegriff Funktionen deklariert werden k¨onnen, ohne dass sie implementiert werden m¨ussen. Es wird nur festgelegt, dass eine bestimmte Operation aufgerufen werden kann. Was beim Aufruf wirklich geschieht, muss in den davon abgeleiteten Klassen jeweils implementiert werden. Abstrakte und konkrete Klassen Basisklassen f¨ur Oberbegriffe sind typischerweise so genannte abstrakte Klassen. Abstrakte Klassen sind Klassen, von denen es keinen Sinn macht, konkrete Objekte (Instanzen) zu erzeugen. C++ unterst¨utzt abstrakte Klassen insofern, als dass sichergestellt werden kann, dass auch wirklich keine Objekte der Klasse erzeugt werden k¨onnen (das leisten nicht alle objektorientierten Sprachen). Das Gegenst¨uck zu einer abstrakten Klasse, also eine Klasse, von der konkrete Objekte erzeugt werden sollen, nennt man konkrete Klasse.
5.3.3
Polymorphie in C++ an einem Beispiel
Als Beispiel f¨ur die Verwendung von Polymorphie soll die Verwaltung verschiedener geometrischer Objekte betrachtet werden (ein klassisches Beispiel).
Sandini Bib
5.3 Polymorphie
307
Es gibt zun¨achst zwei Arten geometrischer Objekte:
Linie Kreis
Diese k¨onnen unter dem Oberbegriff GeoObj als geometrisches Objekt betrachtet, manipuliert und gemeinsam verwendet werden. Ein geometrisches Objekt hat z.B. einen Referenzpunkt und kann mit move() verschoben und mit draw() ausgegeben werden. Ein Kreis besitzt dann zus¨atzlich einen Radius und implementiert die Funktion zur Ausgabe. Die Funktion zum Verschieben wird geerbt. Eine Linie besitzt neben dem Referenzpunkt einen zweiten Punkt und implementiert sowohl die Funktion zum Verschieben als auch die Funktion zur Ausgabe neu. G e o : : G e o O b j r e f p u n k t :
G e o : : K o o r d
K o o r d
x :
i n t
y :
i n t
m o v e ( K o o r d ) d r a w ( )
G e o : : K r e i s r a d i u s : d r a w ( )
u n s i g n e d
G e o : : L i n i e p 2 :
K o o r d
m o v e ( K o o r d ) d r a w ( )
Abbildung 5.4: Klassenhierarchie f¨ur das Polymorphie-Beispiel
Abbildung 5.4 verdeutlicht die daraus resultierende Klassenhierarchie in UML-Notation. Die kursive Darstellung der Basisklasse GeoObj bedeutet, dass es sich um eine abstrakte Klasse handelt. Analog wird in der Basisklasse auch die Operation draw() kursiv dargestellt. Dies bedeutet, dass es sich um eine abstrakte Operation handelt. Sie muss damit von konkreten Klassen auf jeden Fall implementiert werden. Hilfsklasse Koord Als Hilfsklasse wird eine Klasse Koord verwendet. Sie betrachtet einfach nur zwei zusammengeh¨orige X- und Y-Koordinaten als gemeinsames Objekt. Es handelt sich um kein geometrisches Objekt (das entsprechende geometrische Objekt w¨are der Punkt), sondern um ein Wertepaar, das absolut (als Position) oder relativ (als Offset) verwendet werden kann. Der Aufbau und die Operationen sind dermaßen einfach, dass die Klasse komplett in einer Headerdatei implementiert werden kann:
Sandini Bib
308
Kapitel 5: Vererbung und Polymorphie
// vererb/koord1.hpp #ifndef KOORD_HPP #define KOORD_HPP // Headerdatei f¨ur I/O
#include namespace Geo { /* Klasse Koord * - Hilfsklasse f¨ur geometrische Objekte * - nicht zur Vererbung geeignet */
class Koord { private: // X-Koordinate int x; int y; // Y-Koordinate public:
// Default-Konstruktor und Konstruktor aus zwei ints Koord () : x(0), y(0) { // Default-Werte: 0
} Koord (int newx, int newy) : x(newx), y(newy) { }
};
Koord Koord void void
operator + (const Koord&) const; operator - () const; operator += (const Koord&); printOn (ostream& strm) const;
// Addition // Negation // += // Ausgabe
/* Operator + * - X- und Y-Koordinaten addieren */
inline Koord Koord::operator + (const Koord& p) const { return Koord(x+p.x,y+p.y); } /* einstelliger Operator * - X- und Y-Koordinaten negieren
Sandini Bib
5.3 Polymorphie
309
*/ inline Koord Koord::operator - () const { return Koord(-x,-y); }
/* Operator += * - Offset auf X- und Y-Koordinaten aufaddieren */
inline void Koord::operator += (const Koord& p) { x += p.x; y += p.y; } /* printOn() * - Koordinaten als Wertepaar ausgeben */
inline void Koord::printOn (ostream& strm) const { strm << '(' << x << ',' << y << ')'; } /* Operator << * - Umsetzung f¨ur Standard-Ausgabeoperator */
inline ostream& operator<< (ostream& strm, const Koord& p) { p.printOn (strm); return strm; } }
// namespace Geo
#endif // KOORD_HPP Die Klasse sollte eigentlich im Wesentlichen selbsterkl¨arend sein. Bemerkenswert ist vielleicht die Tatsache, dass zum Anlegen nur kein oder zwei Parameter u¨ bergeben werden k¨onnen. Wird kein Parameter u¨ bergeben, wird ein Objekt mit den Werten 0 als X- und Y-Koordinate initialisiert.
Sandini Bib
310
Kapitel 5: Vererbung und Polymorphie
5.3.4
Die abstrakte Basisklasse GeoObj
Die Klasse GeoObj ist die Basisklasse f¨ur den Oberbegriff. Sie legt damit fest, welche Eigenschaften alle geometrischen Objekte besitzen. Dazu geh¨oren gemeinsame Attribute und Operationen, die f¨ur alle geometrischen Objekte aufgerufen werden k¨onnen. Als gemeinsames Datenelement wird definiert:
ein Referenzpunkt
Als Operationen werden definiert:
das Verschieben des Objekts um einen relativen Offset das Ausgeben des Objekts
Auch diese Klasse wird der Einfachheit halber komplett in der Headerdatei definiert. Sie hat folgenden Aufbau:
// vererb/geoobj1.hpp #ifndef GEOOBJ_HPP #define GEOOBJ_HPP // Headerdatei f¨ur Koord
#include "koord.hpp" namespace Geo { /* abstrakte Basisklasse GeoObj * - gemeinsame Basisklasse f¨ur geometrische Objekte * - zur Vererbung vorgesehen */
class GeoObj { protected:
// Jedes GeoObj hat einen Referenzpunkt
Koord refpunkt; /* Konstruktor f¨ur Startwert vom Referenzpunkt * - nicht¨offentlich * - damit ist kein Default-Konstruktor vorhanden */
GeoObj (const Koord& p) : refpunkt(p) { } public: // GeoObj um relativen Offset verschieben
virtual void move (const Koord& offset) {
Sandini Bib
5.3 Polymorphie
311
refpunkt += offset;
}
/* GeoObj ausgeben * - rein virtuelle Funktion */
virtual void draw () const = 0; // virtueller Destruktor
virtual ~GeoObj () { }
}; }
// namespace Geo
#endif
// GEOOBJ_HPP
Zun¨achst werden die Komponente f¨ur den Referenzpunkt und der Konstruktor der Klasse nichto¨ ffentlich definiert:
class GeoObj { protected: Koord refpunkt; GeoObj (const Koord& p) : refpunkt(p) { } ...
}; Die Tatsache, dass der Konstruktor nicht¨offentlich ist, verdeutlicht schon, dass es sich um eine abstrakte Klasse handelt, denn dadurch k¨onnen keine Objekte der Klasse angelegt werden. Da der Konstruktor einen Parameter besitzt, mit dem der Referenzpunkt initialisiert wird, existiert kein Default-Konstruktor. Dies hat zur Folge, dass direkt abgeleitete Klassen in ihren Konstruktoren u¨ ber Initialisierungslisten immer eine Koordinate zur Initialisierung des Referenzpunktes u¨ bergeben m¨ussen. Anschließend werden die o¨ ffentlichen Funktionen deklariert, die f¨ur alle geometrischen Objekte definiert sind. Dazu geh¨ort der virtuelle Destruktor, damit die Klasse zur Vererbung geeignet ist. Außerdem wird das Verschieben eines Objekts deklariert und auch schon implementiert:
class GeoObj { ...
public: virtual void move (const Koord& offset) { refpunkt += offset;
Sandini Bib
312
Kapitel 5: Vererbung und Polymorphie
} ...
}; Die Implementierung verschiebt einfach den Referenzpunkt, indem der u¨ bergebene Offset aufaddiert wird. Bei Objekten, bei denen die absolute Position nur durch den Referenzpunkt definiert wird, reicht das aus. Objekte, deren Position durch andere absolute Koordinaten definiert wird, m¨ussen diese Implementierung u¨ berschreiben. Rein virtuelle Funktionen Schließlich wird auch noch die Funktion zum Ausgeben deklariert:
class GeoObj { ...
public: virtual void draw () const = 0; ...
}; An dieser Stelle geschieht etwas Ungew¨ohnliches: Statt einer Implementierung wird der Funktion der Wert 0 zugewiesen. Dies bedeutet, dass die Funktion f¨ur die Klasse zwar deklariert, aber nicht implementiert wird. Die Implementierung muss damit von davon abgeleiteten Klassen vorgenommen werden. Eine solche Funktion bezeichnet man als rein virtuelle Funktion (englisch: pure virtual function).5 Rein virtuelle Funktionen erf¨ullen einen wichtigen Zweck: Sie legen fest, dass f¨ur eine Klasse, die als Oberbegriff dient, eine bestimmte Funktion aufgerufen werden kann, obwohl sie noch nicht definiert werden kann. In der objektorientierten Modellierung bezeichnet man eine derartige Operation als abstrakte Operation. Solange eine Klasse eine rein virtuelle Funktion besitzt, ist sie unvollst¨andig definiert. Es k¨onnen keine Objekte dieser Klasse angelegt werden. Damit wird die Klasse also automatisch zur abstrakten Klasse. Dies gilt auch f¨ur davon abgeleitete Klassen. Der Compiler achtet darauf, dass in davon abgeleiteten Klassen rein virtuelle Funktionen auch wirklich implementiert werden. Der Test, ob alle Funktionen auch definiert sind, findet aber erst bei dem Versuch statt, ein Objekt der Klasse anzulegen. Eine abgeleitete Klasse muss eine rein virtuelle Funktion n¨amlich nicht unbedingt implementieren, da sie selbst wieder nur eine abstrakte Klasse sein kann. Rein virtuelle Funktionen k¨onnen in Basisklassen dennoch eine (Default-)Implementierung besitzen. Darauf wird auf Seite 330 eingegangen. Auch wenn keine rein virtuellen Funktionen vorhanden sind, k¨onnen abstrakte Klassen definiert werden. Dazu m¨ussen die Konstruktoren einfach, wie hier ebenfalls geschehen, nicht¨offentlich deklariert werden. ¨ Die Ubersetzungen von pure virtual function variieren. Ich habe z.B. auch schon reine virtuelle Funktion oder pure virtuelle Funktion gefunden.
5
Sandini Bib
5.3 Polymorphie
313
Die abgeleitete Klasse Kreis Nun kann die Klasse Kreis als abgeleitete Klasse von GeoObj definiert werden. Von dieser Klasse macht es Sinn, konkrete Objekte (Instanzen) zu erzeugen. Es handelt sich also um eine konkrete Klasse. Ein Kreis besteht aus einem Mittelpunkt, der gleichzeitig Referenzpunkt ist, und einem Radius, der als Komponente neu hinzukommt (siehe Abbildung 5.5).
Kreis: Datenelemente:
Elementfunktionen:
geerbt von GeoObj:
refpunkt
move()
neu:
radius
Kreis() draw()
Abbildung 5.5: Komponenten der Klasse Kreis
Auch die Implementierung der Klasse Kreis befindet sich vollst¨andig in der Headerdatei. Sie hat folgenden Aufbau:
// vererb/kreis1.hpp #ifndef KREIS_HPP #define KREIS_HPP // Headerdatei f¨ur I/O
#include // Headerdatei der Basisklasse
#include "geoobj.hpp" namespace Geo { /* Klasse Kreis
* - abgeleitet von GeoObj * - ein Kreis besteht aus: * - Mittelpunkt (Referenzpunkt, geerbt) * - Radius (neu) */
class Kreis : public GeoObj { protected: // Radius unsigned radius;
Sandini Bib
314
Kapitel 5: Vererbung und Polymorphie
public: // Konstruktor f¨ur Mittelpunkt und Radius
Kreis (const Koord& m, unsigned r) : GeoObj(m), radius(r) { } // Ausgabe (jetzt auch implementiert)
virtual void draw () const; // virtueller Destruktor
};
virtual ~Kreis () { }
/* Ausgabe * - inline definiert */
inline void Kreis::draw () const { std::cout << "Kreis um Mittelpunkt " << refpunkt << " mit Radius " << radius << std::endl; } }
// namespace Geo
#endif // KREIS_HPP Der Konstruktor verlangt einen Mittelpunkt und einen Radius als Parameter zur Initialisierung. Der Mittelpunkt wird u¨ ber die Initialisierungsliste an den Konstruktor der Basisklasse GeoObj u¨ bergeben, wo er zur Initialisierung des Referenzpunktes verwendet wird. Mit dem als zweiten Parameter u¨ bergebenen Radius wird die entsprechende Komponente initialisiert. Die Klasse implementiert die Ausgabefunktion draw(). Dadurch besitzt die Klasse keine rein virtuelle Funktion mehr und kann als konkrete Klasse zum Anlegen von Objekten verwendet werden. Die Funktion selbst simuliert die Ausgabe durch das Schreiben eines entsprechenden Textes. Die Funktion move() wird von der Basisklasse u¨ bernommen, da nur der Mittelpunkt die Position des Kreises definiert und ein Verschieben dieses Punktes ausreicht. Die abgeleitete Klasse Linie Die Klasse Linie besitzt im Prinzip den gleichen Aufbau wie die Klasse Kreis. Eine Linie besteht bei dieser Implementierung allerdings aus zwei absoluten Koordinaten, dem Anfangsund dem Endpunkt. Zus¨atzlich zum Referenzpunkt kommt also noch ein zweiter Punkt hinzu (siehe Abbildung 5.6).
Sandini Bib
5.3 Polymorphie
315
Linie: Datenelemente: geerbt von GeoObj:
refpunkt
neu:
p2
Elementfunktionen:
Linie() move() draw()
Abbildung 5.6: Komponenten der Klasse Linie
Auch die Implementierung der Klasse Linie befindet sich vollst¨andig in der Headerdatei. Sie hat folgenden Aufbau:
// vererb/linie1.hpp #ifndef LINIE_HPP #define LINIE_HPP // Headerdatei f¨ur I/O
#include // Headerdatei der Basisklasse
#include "geoobj.hpp" namespace Geo { /* Klasse Linie
* - abgeleitet von GeoObj * - ein Linie besteht aus: * - einem Anfangspunkt (Referenzpunkt, geerbt) * - einem Endpunkt (neu) */
class Linie : public GeoObj { protected: // zweiter Punkt, Endpunkt Koord p2; public: // Konstruktor f¨ur Anfangs- und Endpunkt
Linie (const Koord& a, const Koord& b) : GeoObj(a), p2(b) { }
Sandini Bib
316
Kapitel 5: Vererbung und Polymorphie // Ausgabe (jetzt auch implementiert)
virtual void draw () const; // Verschieben (neu implementiert)
virtual void move (const Koord&); // virtueller Destruktor
};
virtual ~Linie () { }
/* Ausgabe * - inline definiert */
inline void Linie::draw () const { std::cout << "Linie von " << refpunkt << " bis " << p2 << std::endl; } /* Verschieben * - inline neu implementiert */
inline void Linie::move (const Koord& offset) { // entspricht GeoObj::move(offset); refpunkt += offset; p2 += offset; } }
// namespace Geo
#endif // LINIE_HPP Der Konstruktor verlangt in diesem Fall zwei Koordinaten als Parameter zur Initialisierung. Die erste Koordinate wird u¨ ber die Initialisierungsliste an den Konstruktor der Basisklasse GeoObj u¨ bergeben, wo sie zur Initialisierung des Referenzpunktes verwendet wird. Mit der zweiten Koordinate wird die Komponente f¨ur den neu hinzugekommenen zweiten Punkt initialisiert. Auch diese Klasse implementiert die Ausgabefunktion draw() derart, dass ein entsprechender Text ausgegeben wird. In diesem Fall wird allerdings auch die Funktion move() neu implementiert, da sowohl der Anfangs- als auch der Endpunkt absolute Koordinaten besitzen. Man h¨atte den zweiten Punkt genausogut als Offset auf den ersten Punkt implementieren k¨onnen, wodurch die Funktion move()
Sandini Bib
5.3 Polymorphie
317
von der Basisklasse u¨ bernommen werden k¨onnte. Dann m¨ussten bei anderen Funktionen, wie etwa der Ausgabe, allerdings jeweils Umrechnungen zwischen relativem Offset und absoluter Koordinate stattfinden. Anwendungsbeispiel Ein kleines Beispielprogramm soll nun die Anwendung der Polymorphie verdeutlichen. In dem Beispiel werden Kreise und Linien in einer gemeinsamen Menge als geometrische Objekte betrachtet und manipuliert. Beim Verschieben und Ausgeben werden aber immer die Funktionen aufgerufen, die f¨ur die tats¨achliche Klasse der Objekte definiert sind. Das Programm hat folgenden Aufbau:
// vererb/geotest1.cpp // Headerdateien f¨ur verwendete Klassen
#include "linie.hpp" #include "kreis.hpp" #include "geoobj.hpp" // Vorw¨artsdeklaration
void geoObjAusgeben (const Geo::GeoObj&); int main() { Geo::Linie l1 (Geo::Koord(1,2), Geo::Koord(3,4)); Geo::Linie l2 (Geo::Koord(7,7), Geo::Koord(0,0)); Geo::Kreis k (Geo::Koord(3,3), 11); // inhomogene Menge von geometrischen Objekten:
Geo::GeoObj* menge[10]; menge[0] = &l1; menge[1] = &k; menge[2] = &l2;
// Menge enth¨alt: - Linie l1 // - Kreis k // - Linie l2
/ * Elemente in der Menge ausgeben und verschieben * - es wird automatisch die richtige Funktion aufgerufen */
for (int i=0; i<3; i++) { menge[i]->draw(); menge[i]->move(Geo::Koord(3,-3)); } // Linien einzeln u¨ ber Hilfsfunktion ausgeben
Sandini Bib
318
}
Kapitel 5: Vererbung und Polymorphie
geoObjAusgeben(l1); geoObjAusgeben(k); geoObjAusgeben(l2);
void geoObjAusgeben (const Geo::GeoObj& obj) { /* es wird automatisch die richtige Funktion aufgerufen */ obj.draw(); } Nach dem Erzeugen von zwei Linien und einem Kreis werden diese in einem Feld (Array) von Zeigern auf den Typ GeoObj als geometrische Objekte verwendet. Die Variable menge repr¨asentiert also eine inhomogene Menge von geometrischen Objekten. In einer Schleife wird dann jedes in der Menge befindliche Element ausgegeben und verschoben. Weil virtuelle Funktionen verwendet werden, wird zur Laufzeit jeweils gepr¨uft, welche Klasse das geometrische Objekt tats¨achlich hat, und die entsprechende Funktion aufgerufen. Genauso wird bei Verwendung einer Referenz automatisch die richtige Funktion aufgerufen, was bei der Funktion geoObjAusgeben() ausgenutzt wird. Die Ausgabe des Programms lautet insgesamt:
Linie Kreis Linie Linie Kreis Linie
von (1,2) bis (3,4) um Mittelpunkt (3,3) mit Radius 11 von (7,7) bis (0,0) von (4,-1) bis (6,1) um Mittelpunkt (6,0) mit Radius 11 von (10,4) bis (3,-3)
Polymorphie ist nur m¨oglich, wenn die Identit¨at eines Objekts nicht verloren geht. Das bedeutet, ein Objekt darf nur u¨ ber Zeiger und Referenzen zeitweise als Objekt der Klasse GeoObj betrachtet werden. Eine Variable vom Typ GeoObj ist und bleibt vom Typ GeoObj, auch wenn ein Kreis oder eine Linie zugewiesen wird. In diesem Fall kann keine Variable vom Typ GeoObj deklariert werden, da es sich um eine abstrakte Klasse handelt (sie besitzt eine rein virtuelle Funktion und keinen o¨ ffentlichen Konstruktor). Es sind aber auch Basisklassen denkbar, von denen es durchaus auch Sinn macht, Objekte zu erzeugen. In dem Fall verliert ein Objekt seine Identit¨at, wenn es z.B. durch Zuweisung oder Typumwandlung zum Objekt der Basisklasse wird. Polymorphie kann dann nicht mehr stattfinden. Damit dies nicht aus Versehen geschieht, sollte man darauf achten, dass bei Polymorphie die Basisklassen immer abstrakt sind. Dies ist immer m¨oglich, indem alle Konstruktoren nicht¨offentlich deklariert werden. Wenn es Sinn macht, von einer Basisklasse f¨ur Polymorphie auch konkrete Objekte zu erzeugen, k¨onnen sie durch eine davon abgeleitete Klasse erzeugt werden, die keine neuen Komponenten, sondern nur die entsprechenden Konstruktoren mit o¨ ffentlichem Zugriff definiert.
Sandini Bib
5.3 Polymorphie
5.3.5
319
Anwendung von Polymorphie in Klassen
Das vorherige Beispiel zeigte zwar die Technik der Polymorphie, der wesentliche Vorteil ist aber vielleicht nur zu erahnen. Eine m¨ogliche Anwendung, die die Vorteile von Polymorphie sehr viel besser verdeutlicht, ist die Verwendung von Polymorphie in Klassen. Dies wird im Folgenden am Beispiel der Zusammenfassung verschiedener geometrischer Objekte zu einer Objekt-Gruppe gezeigt. Dazu wird eine Klasse GeoGruppe definiert. Da auch eine Gruppe geometrischer Objekte selbst wieder als geometrisches Objekt betrachtet werden kann, wird die Klasse selbst von GeoObj abgeleitet. Die Klasse GeoGruppe beschreibt also als geometrisches Objekt eine Gruppe geometrischer Objekte. Die Klassenhierarchie erh¨alt dadurch den in Abbildung 5.7 dargestellten Aufbau. G e o : : K o o r d
G e o : : G e o O b j r e f p u n k t :
K o o r d
x :
i n t
y :
i n t
m o v e ( K o o r d ) d r a w ( )
G e o : : K r e i s r a d i u s :
u n s i g n e d
d r a w ( )
G e o : : L i n i e p 2 :
K o o r d
m o v e ( K o o r d ) d r a w ( )
G e o : : G e o G r u p p e e l e m s :
v e c t o r < G e o O b j * >
d r a w ( ) a d d ( G e o O b j ) r e m o v e ( G e o O b j ) :
b o o l
Abbildung 5.7: Polymorphie-Beispiel mit polymorpher Klasse GeoGruppe
Auch die Klasse GeoGruppe implementiert die Funktion draw() und erbt die Funktion move(). Hinzu kommen ein Vektor (siehe Abschnitt 3.5.1) f¨ur die darin verwalteten Elemente und Funktionen zum Einf¨ugen und L¨oschen eines Elements. Nat¨urlich geh¨oren in der Praxis weitere Funktionen dazu, hier geht es nur um das Prinzip. Headerdatei der Klasse GeoGruppe Die Headerdatei der Klasse GeoGruppe enth¨alt wie u¨ blich die Klassendeklaration. Sie hat folgenden Aufbau:
// vererb/gruppe2.hpp #ifndef GEOGRUPPE_HPP #define GEOGRUPPE_HPP
Sandini Bib
320
Kapitel 5: Vererbung und Polymorphie
// Headerdatei der Basisklasse einbinden
#include "geoobj.hpp" // Headerdatei f¨ur die interne Verwaltung der Elemente
#include namespace Geo { /* Klasse GeoGruppe * - abgeleitet von GeoObj * - eine GeoGruppe besteht aus: * - einem Referenzpunkt (geerbt) * - einer Menge von geometrischen Elementen (neu) */
class GeoGruppe : public GeoObj { protected: // Menge von Zeigern auf GeoObjs std::vector elems; public: // Konstruktor mit optionalem Referenzpunkt
GeoGruppe (const Koord& p = Koord(0,0)) : GeoObj(p) { } // Ausgabe (jetzt auch implementiert)
virtual void draw () const; // Element einf¨ugen
virtual void add (GeoObj&); // Element entfernen
virtual bool remove (GeoObj&); // virtueller Destruktor
}; }
virtual ~GeoGruppe () { } // namespace Geo
#endif
// GEOGRUPPE_HPP
Sandini Bib
5.3 Polymorphie
321
Intern wird zur Verwaltung der Elemente ein Vektor verwendet:
class GeoGruppe : public GeoObj { protected: std::vector elems;
// Menge von Zeigern auf GeoObjs
...
}; Im Vektor selbst werden dabei jeweils Zeiger (Verweise) auf die darin befindlichen geometrischen Objekte eingetragen. Dies ist notwendig, um den tats¨achlichen Datentyp der Elemente zu erhalten. Es w¨are auch gar nicht m¨oglich, GeoObj als Elementtyp zu verwenden, da es sich dabei um eine abstrakte Klasse handelt. Die Verwendung von Referenzen ist ebenfalls nicht m¨oglich, da bei ihnen zur Initialisierungszeit festgelegt werden muss, f¨ur welches Objekt sie stehen. In diesem Fall werden aber zur Laufzeit Elemente eingetragen oder auch entfernt. Der Konstruktor enth¨alt einen Parameter, mit dem u¨ ber den Konstruktor der Basisklasse der Referenzpunkt initialisiert wird:
class GeoGruppe : public GeoObj { public: // Konstruktor mit optionalem Referenzpunkt
GeoGruppe (const Koord& p = Koord(0,0)) : GeoObj(p) { } ...
}; Er enth¨alt als Default-Argument den Ursprung (die Koordinate (0,0)). Dieser wird bei der GeoGruppe als Offset auf die Elemente der Gruppe betrachtet. Die Koordinaten der Elemente sind damit relativ und beziehen sich auf den Referenzpunkt der GeoGruppe. Quelldatei der Klasse GeoGruppe In der Quelldatei der Klasse GeoGruppe werden die Funktionen zum Einf¨ugen und Entfernen eines Elements sowie zum Ausgeben aller Elemente definiert:
// vererb/gruppe2.cpp #include "gruppe.hpp" #include namespace Geo { /* add * - Element einf¨ugen */
void GeoGruppe::add (GeoObj& obj) { // Adresse vom geometrischen Objekt eintragen
Sandini Bib
322
}
Kapitel 5: Vererbung und Polymorphie
elems.push_back(&obj);
/* remove * - Element l¨oschen */
bool GeoGruppe::remove (GeoObj& obj) { // erstes Element mit dieser Adresse finden
}
std::vector::iterator pos; pos = std::find(elems.begin(),elems.end(), &obj); if (pos != elems.end()) { elems.erase(pos); return true; } else { return false; }
/* draw * - alle Elemente unter Ber¨ucksichtigung des Referenzpunktes ausgeben */
void GeoGruppe::draw () const { for (unsigned i=0; i<elems.size(); ++i) { elems[i]->move(refpunkt); // Offset f¨ur den Referenzpunkt addieren elems[i]->draw(); // Element ausgeben elems[i]->move(-refpunkt); // Offset wieder entfernen } } }
// namespace Geo
Die Funktion zum Einf¨ugen tr¨agt einfach nur die Adresse des eingef¨ugten Elements in die interne Menge ein:
void GeoGruppe::add (GeoObj& obj) { // Adresse vom geometrischen Objekt eintragen
}
elems.push_back(&obj);
Sandini Bib
5.3 Polymorphie
323
Da f¨ur das u¨ bergebene geometrische Objekt nur Referenzen und Zeiger verwendet werden, bleibt dessen Klassenzugeh¨origkeit erhalten. Dies wird dann beim Ausgeben der Elemente ausgenutzt:
void GeoGruppe::draw () const { for (unsigned i=0; i<elems.size(); ++i) { elems[i]->move(refpunkt); // Offset f¨ur den Referenzpunkt addieren elems[i]->draw(); // Element ausgeben elems[i]->move(-refpunkt); // Offset wieder entfernen } } In einer Schleife werden einfach alle Elemente der GeoGruppe ausgegeben, indem f¨ur diese unter anderem die Funktion draw() aufgerufen wird. Durch die Verwendung der virtuellen Funktionen wird dabei jeweils automatisch die richtige Funktion aufgerufen: Handelt es sich bei dem Element um einen Kreis, wird Kreis::draw() aufgerufen. Handelt es sich bei dem Element um eine Linie, wird Linie::draw() aufgerufen. Und handelt es sich um eine Gruppe geometrischer Objekte (die ja selbst als geometrisches Objekte wiederum in einer Gruppe sein kann), wird GeoGruppe::draw() aufgerufen, das dann f¨ur alle darin befindlichen Elemente wiederum automatisch das richtige draw() aufruft. Vor und nach dem Ausgeben mit draw() wird das Objekt zur Ber¨ucksichtigung des Offsets in der Gruppe noch entsprechend verschoben bzw. zur¨uckgeschoben. In der Praxis w¨urde man stattdessen die Funktion draw() sicherlich mit einem Offset als Parameter versehen. Aber auch hier wird je nach tats¨achlichem Datentyp der Elemente automatisch die passende move()Implementierung aufgerufen. Abgerundet wird die Klasse durch die Funktion zum Entfernen eines Elements:
bool GeoGruppe::remove (GeoObj& obj) { // erstes Element mit dieser Adresse finden
}
std::vector::iterator pos; pos = std::find(elems.begin(),elems.end(), &obj); if (pos != elems.end()) { elems.erase(pos); return true; } else { return false; }
In diesem Fall wird mit Hilfe des Algorithmus find() die Position des u¨ bergebenen geometrischen Objekts gesucht. Man beachte, dass die Adressen verglichen werden, was bedeutet, dass
Sandini Bib
324
Kapitel 5: Vererbung und Polymorphie
es sich um das identische Objekt handeln muss. Wird ein derartiges Objekt gefunden, wird es mit erase aus der Menge entfernt, und es wird true zur¨uckgeliefert. Wird das Objekt nicht gefunden, liefert die Funktion false. Anwendung Das erste Anwendungsprogramm f¨ur geometrische Objekte von Seite 317 kann nun f¨ur die Verwendung einer Gruppe von geometrischen Objekten umgeschrieben werden:
// vererb/geotest2.cpp // Headerdatei f¨ur I/O
#include // Headerdateien f¨ur verwendete Klassen
#include "linie.hpp" #include "kreis.hpp" #include "gruppe.hpp" int main() { Geo::Linie l1 (Geo::Koord(1,2), Geo::Koord(3,4)); Geo::Linie l2 (Geo::Koord(7,7), Geo::Koord(0,0)); Geo::Kreis k (Geo::Koord(3,3), 11); Geo::GeoGruppe g; g.add(l1); g.add(k); g.add(l2);
// GeoGruppe enth¨alt: - Linie l1 // - Kreis k // - Linie l2
// GeoGruppe ausgeben g.draw(); std::cout << std::endl;
g.move(Geo::Koord(3,-3)); // GeoGruppen-Offset verschieben g.draw(); // GeoGruppe nochmal ausgeben std::cout << std::endl;
}
g.remove(l1); g.draw();
// GeoGruppe enth¨alt nur noch k und l2 // GeoGruppe nochmal ausgeben
Zun¨achst werden geometrische Objekte unterschiedlicher Art in die Gruppe eingef¨ugt:
g.add(l1);
// GeoGruppe enth¨alt: - Linie l1
Sandini Bib
5.3 Polymorphie
g.add(k); g.add(l2);
325 // //
- Kreis k - Linie l2
Der Aufruf
g.draw(); ruft jeweils die Funktion draw() der Klasse GeoGruppe auf, wodurch die Schleife aufgerufen wird, die f¨ur die darin enthaltenen Elemente jeweils das richtige draw() aufruft. Das Programm hat also folgende Ausgabe:
Linie von (1,2) bis (3,4) Kreis um Mittelpunkt (3,3) mit Radius 11 Linie von (7,7) bis (0,0) Linie von (4,-1) bis (6,1) Kreis um Mittelpunkt (6,0) mit Radius 11 Linie von (10,4) bis (3,-3) Kreis um Mittelpunkt (6,0) mit Radius 11 Linie von (10,4) bis (3,-3) Da eine GeoGruppe selbst ein geometrisches Objekt ist, k¨onnte sie sich selbst auch wiederum in einer GeoGruppe befinden:
GeoGruppe g2; g2.add(g);
// GeoGruppe g2 enth¨alt GeoGruppe g
Dabei sollte eine GeoGruppe aber nicht direkt oder indirekt sich selbst enthalten, da dies zu Endlosrekursionen f¨uhren kann.
5.3.6
Polymorphie ist keine feste Fallunterscheidung
Auf einen wichtigen Aspekt der Klasse GeoGruppe soll nun noch eingegangen werden: Wie man sieht, operiert die GeoGruppe wirklich als inhomogene Menge f¨ur geometrische Objekte. Die Klasse GeoGruppe enth¨alt dabei aber keinerlei Code, der sich auf die konkrete Datentypen f¨ur die geometrischen Objekte festlegt. Die Klasse verwendet f¨ur die verwalteten geometrischen Objekte nur die Klasse GeoObj, die als Oberbegriff f¨ur alle geometrischen Objekte dient. Damit kann die Klasse auch Objekte beliebiger anderer Klassen verwalten, solange diese von GeoObj abgeleitet sind. Wenn also neue geometrische Objekte wie Dreiecke oder Texte gebraucht werden, m¨ussen nur entsprechende Klassen von GeoObj abgeleitet werden. Die Klasse GeoGruppe bleibt unver¨andert und muss auch nicht neu u¨ bersetzt werden. Dies ist ein ganz wesentlicher Vorteil f¨ur die Praxis. Solange sich die Anforderungen an den Oberbegriff nicht a¨ ndern, ist das System erweiterbar, ohne dass existierende Implementierungen f¨ur den Oberbegriff angetastet werden m¨ussen. Komplexe Vorg¨ange wie das Verschieben, Ausgeben und Zusammenfassen von geometrischen Objekten m¨ussen nur einmal grunds¨atzlich implementiert werden.
Sandini Bib
326
5.3.7
Kapitel 5: Vererbung und Polymorphie
Ruckumwandlung ¨ eines Objekts in seine Klasse
Bei der Anwendung von Polymorphie gibt es allerdings mitunter folgendes Problem: Durch die strenge Typ¨uberpr¨ufung geht die tats¨achliche Klasse eines Objekts, das unter seinem Oberbegriff betrachtet wird, syntaktisch verloren. Wird z.B. eine GeoGruppe als geometrisches Objekt behandelt, kann daf¨ur keine speziell f¨ur GeoGruppen definierte Funktion aufgerufen werden:
Geo::GeoGruppe g;
// GeoObj-Gruppe
g.add(l1);
// OK: Gruppe enth¨alt Linie l1
...
Geo::GeoObj& geoobj = g; // Gruppe wird als geometrische Objekt betrachtet geoobj.add(l1);
// FEHLER: add() f¨ur GeoObj nicht definiert
Wenn verschiedenartige Objekte zeitweise unter der gleichen Oberbegriff-Klasse betrachtet werden, tritt also das Problem auf, dass sie nicht ohne weiteres wieder als das betrachtet werden k¨onnen, was sie eigentlich sind. Diese strenge Typpr¨ufung ist auch gut so, denn sie stellt sicher, dass man zur Laufzeit nicht add() f¨ur Linien oder Kreise aufrufen kann. F¨ur polymorphe Datentypen ist es in C++ allerdings m¨oglich, so genannte Laufzeit-Typinformationen abzurufen. Der dazugeh¨orige Mechanismus wird als Abk¨urzung der englischen Bezeichnung runtime type information auch kurz mit RTTI bezeichnet. Dabei gibt es zwei M¨oglichkeiten:
Man kann versuchen, ein Objekt mit einem Downcast in seinen tats¨achlichen Datentyp umzuwandeln. Man kann die Klasse eines Objekts konkret abfragen.
Beides ist aber nur bei polymorphen Datentypen m¨oglich. Das sind Datentypen, die mindestens eine virtuelle Elementfunktion besitzen. Downcasting mit dem Operator dynamic_cast Mit dem Operator dynamic_cast kann man ein Objekt wieder in seinen tats¨achlichen Datentyp umwandeln. Seine Anwendung sieht wie folgt aus:
void inGruppeEinfuegen (Geo::GeoObj& obj, const Geo::GeoObj& elem) { dynamic_cast(obj).add(elem); // Exception, wenn obj keine GeoGruppe ist } Dem Operator dynamic_cast wird als erstes Argument in spitzen Klammern der Datentyp u¨ bergeben, in den das Objekt umgewandelt werden soll. Dabei muss es sich um einen ReferenzDatentyp handeln. Als zweiter Datentyp wird in runden Klammern das Objekt u¨ bergeben, das in diesen Datentyp umgewandelt werden soll:
dynamic_cast(obj)
Sandini Bib
5.3 Polymorphie
327
Die Tatsache, dass der Datentyp eine Referenz sein muss, soll verdeutlichen, dass hier kein neues Objekt, sondern nur eine tempor¨are zweite Betrachtung des Objekts zur¨uckgeliefert wird. Eine derartige Betrachtung ist nur m¨oglich, wenn es sich bei dem u¨ bergebenen Objekt wirklich um ein Objekt der angegebenen Klasse oder einer davon abgeleiteten Klasse handelt. Ist dies nicht der Fall, wird eine Ausnahme vom Typ std::bad_cast ausgel¨ost. Sie kann wie in Abschnitt 3.6 beschrieben behandelt werden. Man kann mit dynamic_cast auch Zeiger auf polymorphe Objekte umwandeln. In diesem Fall wird im Fehlerfall 0 bzw. NULL zur¨uckgeliefert:
void sonderbehandlung (Geo::GeoObj* objptr) { Geo::GeoGruppe* p = dynamic_cast(objptr); if (p != NULL) { // OK: *objptr ist GeoGruppe p->add(...); } else { // *objptr ist keine GeoGruppe } } Diese Form kann man auch dazu verwenden, den tats¨achlichen Datentyp von Objekten, die keine Zeiger sind, zur Laufzeit auszuwerten:
void sonderbehandlung (const Geo::GeoObj& obj) { Geo::GeoGruppe* p = dynamic_cast(&obj); if (p != NULL) { // OK: obj ist GeoGruppe Geo::GeoGruppe& gruppe = *p; ...
}
} else { // obj ist keine GeoGruppe }
Geht es nur darum herauszufinden, ob ein Objekt von einem bestimmten Typ ist, reicht auch Folgendes:
void typabfrage (const Geo::GeoObj& obj) { if (dynamic_cast(&obj) != NULL) { // obj ist GeoGruppe ...
}
Sandini Bib
328
Kapitel 5: Vererbung und Polymorphie
else { // obj ist keine GeoGruppe ...
}
}
Konkrete Typabfrage mit dem Operator typeid Mit dem Operator typeid kann man bei polymorphen Datentypen zur Laufzeit den Datentyp ermitteln. Das folgende Beispiel zeigt, wie dies aussehen kann:
#include void foo (const Geo::GeoObj& obj) { // Datentyp ausgeben
std::cout << typeid(obj).name() << std::endl; // Datentyp vergleichen
if (typeid(t) == typeid(Geo::GeoGruppe)) { ...
}
}
typeid liefert zu einem Objekt bzw. zu einer Klasse ein Beschreibungsobjekt, das den Datentyp std::type_info besitzt. Dieser Datentyp wird in definiert. F¨ur ein derartiges Objekt kann man folgende Operationen aufrufen:
name() liefert den Namen der Klasse als C-String. Wie dieser String genau aussieht, ist implementierungsspezifisch. == und != liefern, ob zwei Datentypen gleich sind. Da dem Operator typeid als Argument ein Objekt oder eine Klasse u¨ bergeben werden kann, kann man sowohl herausbekommen, ob der Datentyp zweier Objekte gleich ist, als auch ermitteln, ob ein Objekt einen bestimmten Datentyp hat. Mit before() kann ein Datentyp außerdem mit einem anderen zum Zwecke der Sortierung verglichen werden.
Handelt es sich bei einem Argument f¨ur typeid um einen Zeiger und hat dieser Zeiger den Wert 0 bzw. NULL, dann wird eine Ausnahme vom Typ std::bad_typeid ausgel¨ost. Sie kann wie in Abschnitt 3.6 beschrieben behandelt werden. RTTI und Design Die Verwendung von RTTI ist mit Vorsicht zu betrachten. Insbesondere bei der Verwendung von typeid macht man Code von ganz konkreten Datentypen abh¨angig. Eine damit implementierte Fallunterscheidung u¨ ber verschiedene Datentypen zeugt von a¨ ußerst schlechtem Design:
Sandini Bib
5.3 Polymorphie
329
int flaeche (const Geo::GeoObj& obj) // ganz schlechtes Beispiel { if (typeid(obj) == typeid(Geo::Kreis) || typeid(obj) == typeid(Geo::Rechteck)) { // Fl¨ache berechnen ...
}
} else { return 0; }
Durch eine derartige Programmierung wird der ganze Vorteil des Programmierens mit Oberbegriffen zunichte gemacht. Diese Funktion muss mit jeder neuen Art von geometrischen Objekten verifiziert und gegebenenfalls angepasst werden. Etwas besser w¨are ein Design, das unter der Klasse GeoObj eine weitere abstrakte Klasse GeoFlaeche einf¨uhrt, von der dann alle konkreten fl¨achenartigen Objekte abgeleitet sind. In dem Fall muss der Code nicht bei einer neuen Art von Fl¨ache umgeschrieben werden:
int flaeche (const Geo::GeoObj& obj) // etwas besseres Beispiel { GeoFlaeche* fp = dynamic_cast(&obj) if (fp != NULL) { // Fl¨ache berechnen
}
return fp->berechneFlaeche(); } else { return 0; }
Aber auch hier muss darauf geachtet werden, dass nicht pl¨otzlich geometrische Objekte entworfen werden, f¨ur die es zwar Sinn macht, eine Fl¨ache zu berechnen, die aber nicht von GeoFlaeche abgeleitet werden. Am besten ist da sicherlich ein Design, das es erlaubt, von allen Objekten die Fl¨achen zu berechnen:
int flaeche (const Geo::GeoObj& obj) // OK { return obj.berechneFlaeche(); // jedes Objekt einfach nach seiner Fl¨ache fragen } Dazu kann in Geo::GeoObj bereits eine Default-Implementierung vorgesehen werden, die f¨ur alle Objekte, die keine Fl¨ache darstellen, 0 zur¨uckliefert:
Sandini Bib
330
Kapitel 5: Vererbung und Polymorphie
class GeoObj { public: virtual int berechneFlaeche () const { return 0; } ...
}; Um zu verhindern, dass man vergisst, f¨ur abgeleitete Klassen diese Funktion auch zu implementieren, kann man auch eine Default-Implementierung vorsehen und die Funktion dennoch als rein virtuell deklarieren:
class GeoObj { public: virtual int berechneFlaeche () const
= 0;
// rein virtuell
...
}; ...
int GeoObj::berechneFlaeche () const { return 0; }
// aber vorhanden
Abgeleitete Klassen m¨ussen diese Funktion dann implementieren, k¨onnen dazu aber die Implementierung der Basisklasse aufrufen:
class Linie : public GeoObj { public: virtual int berechneFlaeche () const { // Default-Implementierung der Basisklasse u¨ bernehmen
}
return GeoObj::berechneFlaeche();
...
};
5.3.8
Design by Contract
Abstrakte Basisklassen sind das wohl wichtigste Mittel, um in großen Programmen Module und Komponenten voneinander zu entkoppeln. Als Schnittstelle definieren sie einen Vertrag zwischen Objekten, die eine bestimmte Dienstleistung erbringen, und Objekten, die diese Dienst-
Sandini Bib
5.3 Polymorphie
331
leistung abrufen. Ist der Vertrag einmal als abstrakte Basisklasse definiert, k¨onnen beide Seiten unabh¨angig voneinander entwickelt und modifiziert werden.6 Eine derartige Schnittstelle k¨onnte z.B. wie folgt lauten:
class Druckbar { public: virtual void drucken() = 0; }; Man kann nun einerseits beliebige Klassen implementieren, die diese Schnittstelle implementieren:
class XYZ : public Druckbar { ...
public: virtual void drucken() { ...
};
}
Andererseits kann man Funktionen schreiben, die f¨ur alle Objekte, die diesen Vertrag erf¨ullen und also von Druckbar abgeleitet sind, entsprechende Funktionen aufrufen:
void foo (const Druckbar& obj) { ...
obj.drucken(); ...
} Im englischsprachigen Umfeld werden derartige abstrakte Basisklassen auch einfach ABCs genannt (abstract base classes). Es ist mitunter eine gute Richtlinie, Basisklassen grunds¨atzlich nur als derartige abstrakte Basisklassen zu implementieren.
5.3.9
Zusammenfassung
Polymorphie bezeichnet die F¨ahigkeit, dass die gleichen Operationen bei unterschiedlichen Objekten zu unterschiedlichen Reaktionen f¨uhren. C++ unterst¨utzt Polymorphie durch – gleichnamige Komponenten bei verschiedenen Klassen ¨ – das Uberladen von Funktionen – virtuelle Funktionen
6
Java-Programmierer kennen ein derartiges Konzept als spezielles Sprachmittel. Dort wird es mit Hilfe der Schl¨usselw¨orter interface und implements angeboten. In C++ gibt es dazu keine speziellen Schl¨usselw¨orter.
Sandini Bib
332
Kapitel 5: Vererbung und Polymorphie
Mit Polymorphie kann das Abstraktionsmittel des Oberbegriffs implementiert werden. Abstrakte Klassen sind Klassen, bei denen nicht vorgesehen ist, dass von ihnen konkrete Objekte erzeugt werden. Sie dienen dazu, gemeinsame Eigenschaften verschiedener Klassen zusammenzufassen, um mit Oberbegriffen arbeiten zu k¨onnen. Das Gegenst¨uck dazu – also eine Klasse, von der Objekte erzeugt werden sollen – bezeichnet man als konkrete Klasse. Rein virtuelle Funktionen erlauben es, Funktionen zu deklarieren, die in abgeleiteten Klassen implementiert werden m¨ussen. Sie werden durch eine Zuweisung von 0 definiert. F¨ur rein virtuelle Funktionen sind Default-Implementierungen m¨oglich. F¨ur die Typabfrage zur Laufzeit und f¨ur die Typumwandlung vom Oberbegriff in die tats¨achliche Klasse sind die Operatoren typeid und dynamic_cast vorgesehen. Ihre Verwendung sollte in der Regel vermieden werden.
Sandini Bib
5.4 Mehrfachvererbung
333
5.4 Mehrfachvererbung In diesem Abschnitt wird auf die M¨oglichkeit eingegangen, dass eine Klasse zwei oder mehr Basisklassen besitzen kann. Dieser Sachverhalt wird als Mehrfachvererbung (englisch: multiple inheritance) bezeichnet. Wie die einfache Vererbung ist auch die Mehrfachvererbung durch die is-a-Beziehung gekennzeichnet. Nur gilt dies dann mehrfach: Ein Objekt einer doppelt abgeleiteten Klasse ist zum einen dies und zum anderen das. Die Mehrfachvererbung f¨uhrt zu einigen Problemen, die in der Praxis ber¨ucksichtigt werden m¨ussen (z.B. zu Namenskonflikten). In diesem Abschnitt werden die Probleme erl¨autert und L¨osungsm¨oglichkeiten vorgestellt. Generell kann schon hier gesagt werden, dass die Mehrfach¨ vererbung nur mit Vorsicht und nach sorgf¨altigen Design-Uberlegungen eingesetzt werden sollte.
5.4.1
Beispiel fur ¨ Mehrfachvererbung
Ein einleuchtendes einfaches Beispiel f¨ur die Mehrfachvererbung ist eine Klasse f¨ur Amphibienfahrzeuge, die von den Klassen Auto und Boot abgeleitet wird (siehe Abbildung 5.8). Die is-a-Beziehung gilt hier mehrfach:
Ein Amphibienfahrzeug ist ein Auto. Ein Amphibienfahrzeug ist ein Boot.
A u t o
B o o t
A m p h
Abbildung 5.8: Beispiel f¨ur Mehrfachvererbung
Anhand dieses Beispiels werden wir im Folgenden die Probleme betrachten, die speziell bei der Mehrfachvererbung entstehen k¨onnen. Dies sind im Wesentlichen Namenskonflikte, da Komponenten der verschiedenen Basisklassen die gleichen Bezeichnungen besitzen k¨onnen und somit keine eindeutige Zuordnung in der abgeleiteten Klasse mehr m¨oglich ist. Die Basisklassen Auto und Boot Zun¨achst werden die Basisklassen betrachtet. Sie werden in diesem Beispiel bewusst recht einfach gehalten. Ein Auto besteht in diesem Beispiel nur aus einer Komponente f¨ur die bereits gefahrenen Kilometer. Die Anzahl der gefahrenen Kilometer kann beim Anlegen eines Objekts der Klasse Auto angegeben werden oder sie wird als Default-Wert mit 0 initialisiert. Das Auto kann dann eine bestimmte Anzahl von Kilometern fahren und die Anzahl der gefahrenen Kilometer kann ausgegeben werden:
Sandini Bib
334
Kapitel 5: Vererbung und Polymorphie
// vererb/auto1.hpp #ifndef AUTO_HPP #define AUTO_HPP // Headerdatei f¨ur I/O einbinden
#include namespace Bsp { /* Klasse Auto * - zur Vererbung geeignet */
class Auto { protected: int km;
// gefahrene Kilometer
public:
// Default- und int-Konstruktor
Auto (int d = 0) : km(d) { // gefahrene Kilometer initialisieren } // bestimmte Anzahl von Kilometern fahren
virtual void fahre (int d) { // Kilometer aufaddieren km += d; } // Anzahl gefahrener Kilometer ausgeben
virtual void printGefahren () { std::cout << "Das Auto ist " << km << " km gefahren" << std::endl; } // virtueller Destruktor (ohne Anweisungen)
}; }
virtual ~Auto () { } // namespace Bsp
#endif
// AUTO_HPP
F¨ur die Klasse Boot gilt im Prinzip genau das Gleiche, nur dass statt Kilometer Seemeilen verwaltet werden:
Sandini Bib
5.4 Mehrfachvererbung
335
// vererb/boot1.hpp #ifndef BOOT_HPP #define BOOT_HPP // Headerdatei f¨ur I/O einbinden
#include namespace Bsp { /* Klasse Boot * - zur Vererbung geeignet */
class Boot { protected: int sm;
// gefahrene Seemeilen
public:
// Default- und int-Konstruktor
Boot (int d = 0) { // gefahrene Seemeilen initialisieren sm = d; } // bestimmte Anzahl von Seemeilen fahren
virtual void fahre (int d) { // Seemeilen aufaddieren sm += d; } // Anzahl gefahrener Seemeilen ausgeben
virtual void printGefahren () { std::cout << "Das Boot ist " << sm << " sm gefahren" << std::endl; } // virtueller Destruktor (ohne Anweisungen)
}; }
virtual ~Boot () { } // namespace Bsp
#endif
// BOOT_HPP
Sandini Bib
336
Kapitel 5: Vererbung und Polymorphie
Deklaration der abgeleiteten Klasse Amph Die Klasse f¨ur Amphibienfahrzeuge, Amph, wird von den Klassen Auto und Boot wie folgt abgeleitet:
// vererb/amph1.hpp #ifndef AMPH_HPP #define AMPH_HPP // Headerdateien der Basisklassen einbinden
#include "auto.hpp" #include "boot.hpp" namespace Bsp { /* Klasse Amph
* - abgeleitet von Auto und Boot * - zur Weiter-Vererbung geeignet */
class Amph : public Auto, public Boot { public: /* Default-, int und int/int-Konstruktor * - mit erstem Parameter wird Auto-Konstruktor aufgerufen * - mit zweitem Parameter wird Boot-Konstruktor aufgerufen */
Amph (int k = 0, int s = 0) : Auto(k), Boot(s) { // damit ist nichts mehr zu tun
} // Anzahl gefahrener Kilometer und Seemeilen ausgeben
virtual void printGefahren () { std::cout << "Das Amphibienfahrzeug ist " << km << " km und " << sm << " sm gefahren" << std::endl; } // virtueller Destruktor (ohne Anweisungen)
}; }
virtual ~Amph () { } // namespace Bsp
#endif
// AMPH_HPP
Sandini Bib
5.4 Mehrfachvererbung
337
Wie man sieht, werden alle Basisklassen, durch einen Doppelpunkt getrennt, hinter dem Namen der Klasse angegeben:
class Amph : public Auto, public Boot { ...
}; Dabei kann f¨ur jede Klasse einzeln angegeben werden, inwiefern der Zugriff auf die geerbten Komponenten eingeschr¨ankt wird (siehe Abschnitt 5.1.3). Anwendung der abgeleiteten Klasse Amph Da die Klasse Amph die zwei Basisklassen Auto und Boot besitzt, werden sowohl die Eigenschaften der Klasse Auto als auch die Eigenschaften der Klasse Boot geerbt. Ein Amphibienfahrzeug besitzt somit die Komponenten km und sm. Diese k¨onnen z.B. u¨ ber die neu implementierte Elementfunktion printGefahren() ausgegeben werden, wie das folgende Programm zeigt:
// vererb/amphtest1.cpp // Headerdatei f¨ur die Klasse Amph
#include "amph.hpp"
int main () { / * Amphibienfahrzeug anlegen und mit * 7 Kilometer und 42 Seemeilen initialisieren */ Bsp::Amph a(7,42); // gefahrene Kilometer und Seemeilen ausgeben
}
a.printGefahren();
Die Deklaration
Bsp::Amph a(7,42); legt ein Objekt der Klasse Amph an. Die Konstruktoren der Basisklassen werden dabei in der Reihenfolge aufgerufen, in der die Basisklassen deklariert werden. Die Reihenfolge in der Initialisierungsliste ist unerheblich. Diese Tatsache sollte allerdings im Allgemeinen keine Rolle spielen. Eine Reihenfolge wird nur deshalb definiert, um sicherzustellen, dass die Destruktoren in umgekehrter Reihenfolge aufgerufen werden, was z.B. f¨ur die Implementierung einer eigenen Speicherverwaltung wichtig sein kann.
Sandini Bib
338
Kapitel 5: Vererbung und Polymorphie
Konkret passiert Folgendes:
Zun¨achst wird der Speicherplatz f¨ur das Objekt angelegt:
a:
sm:
?
km:
7
sm:
?
Anschließend wird der int-Konstruktor der Klasse Boot aufgerufen, der den von Boot geerbten Teil des Objekts initialisiert:
a:
?
Dann wird der int-Konstruktor der Klasse Auto aufgerufen, der den von Auto geerbten Teil des Objekts initialisiert:
a:
km:
km:
7
sm:
42
Da der Konstruktor der Klasse Amph ohne Anweisungen ist (es sind ja auch keine neuen Komponenten hinzugekommen), ist das Objekt damit vollst¨andig initialisiert.
Wenn sowohl f¨ur Basisklassen als auch f¨ur Komponenten Konstruktoren aufgerufen werden m¨ussen, werden zuerst die Konstruktoren der Basisklassen und dann die Konstruktoren f¨ur die neuen Komponenten aufgerufen. Die Anweisung
a.printGefahren(); ruft die Elementfunktion printGefahren() auf, die in der Klasse Amph neu implementiert wird und gefahrene Kilometer und Seemeilen ausgibt. Zuordnungskonflikte Ein Aufruf von fahre() ist f¨ur Amphibienfahrzeuge dagegen nicht m¨oglich. Diese Elementfunktion wird sowohl von der Klasse Auto als auch von der Klasse Boot geerbt und kann somit nicht eindeutig zugeordnet werden:
Sandini Bib
5.4 Mehrfachvererbung
339
Bsp::Amph a(7,42); ...
a.fahre(77);
// FEHLER: mehrdeutig
Auch hier kann wieder der Bereichsoperator eine eindeutige Zuordnung erm¨oglichen:
Bsp::Amph a(7,42); ...
a.Auto::fahre(77); a.Boot::fahre(23);
// OK: fahre als Auto // OK: fahre als Boot
Um die Verwendung des Bereichsoperators zu vermeiden, k¨onnte die abgeleitete Klasse die Funktionen auch unter einem anderen Namen anbieten:
class Amph : public Auto, public Boot { ...
};
virtual void fahreAlsAuto (int d) { // Umbenennung von Auto::fahre() Auto::fahre(d); } virtual void fahreAlsBoot (int d) { // Umbenennung von Boot::fahre() Boot::fahre(d); }
Nat¨urlich ist es auch m¨oglich, die Funktionen direkt zu implementieren. Dies kann aber bei ¨ Anderungen an der Implementierung der Basisklasse zu Inkonsistenzen fu¨ hren. Hier muss, wie so oft, zwischen einem Konsistenzvorteil und einem Laufzeitnachteil abgewogen werden. Wird das Amphibienfahrzeug als Auto oder Boot betrachtet, dann ist die Zuordnung von fahre() allerdings klar und der Aufruf somit ohne Qualifizierung m¨oglich:
Bsp::Amph a(7,42); ...
Bsp::Boot& b(a); b.fahre(23);
5.4.2
// Amphibienfahrzeug wird als Boot betrachtet // OK: fahre als Boot
Virtuelle Basisklassen
Wenn eine Klasse mehrere Basisklassen besitzt, k¨onnen diese wiederum direkt oder indirekt von der gleichen Basisklasse abgeleitet sein. In diesem Fall stellt sich die Frage, ob die mehrfach geerbten Komponenten der Basisklasse einmal oder mehrfach vorhanden sind. Nehmen wir z.B. an, die Klassen Auto und Boot sind beide von der Klasse Fahrzeug abgeleitet (siehe Abbildung 5.9). Wenn nun die Klasse Fahrzeug z.B. eine Komponente baujahr definiert, dann besitzt sowohl ein Auto als auch ein Boot ein Baujahr. Ein Amphibienfahrzeug sollte nun aber nicht, nur weil es alle Eigenschaften von Auto und alle Eigenschaften von Boot erbt,
Sandini Bib
340
Kapitel 5: Vererbung und Polymorphie
F a h r z e u g
A u t o
B o o t
A m p h
Abbildung 5.9: Ableiten einer Basisklasse u¨ ber mehrere Wege
zwei Baujahre besitzen. Wenn wir allerdings andererseits annehmen, dass die Klasse Fahrzeug eine Komponente maxspeed f¨ur die H¨ochstgeschwindigkeit definiert, dann kann es sinnvoll sein, diese Komponente beim Amphibienfahrzeug zweimal, einmal als Auto und einmal als Boot, zu besitzen. C++ bietet hier beide M¨oglichkeiten: mehrfach geerbte Komponenten k¨onnen gemeinsam verwendet oder getrennt geerbt werden. Nichtvirtuelle Basisklassen Ohne spezielle Angabe sind u¨ ber verschiedene Klassen geerbte Komponenten aus gemeinsamen Basisklassen in C++ mehrfach vorhanden. Bei der folgenden Definition besitzt ein Amphibienfahrzeug deshalb zweimal die Komponente maxspeed:
class Fahrzeug { protected: int maxspeed;
// H¨ochstgeschwindigkeit
...
}; class Auto : public Fahrzeug { ...
}; class Boot : public Fahrzeug { ...
}; class Amph : public Auto, public Boot { ...
};
Sandini Bib
5.4 Mehrfachvererbung
341
Da maxspeed sowohl von Auto als auch von Boot geerbt wird und somit zweimal vorhanden ist, kann darauf f¨ur Amphibienfahrzeuge nur u¨ ber den Bereichsoperator zugegriffen werden:
void Amph::f() { maxspeed = 100; Auto::maxspeed = 100; Boot::maxspeed = 70; Fahrzeug::maxspeed = 100; }
// FEHLER: mehrdeutig // OK: maxspeed von Auto // OK: maxspeed von Boot // FEHLER: mehrdeutig
Virtuelle Basisklassen Um zu erreichen, dass eine Komponente, die u¨ ber verschiedene Klassen von einer Basisklasse geerbt wird, nicht mehrfach vorhanden ist, m¨ussen die direkt abgeleiteten Klassen der gemeinsamen Basisklasse als virtuelle Basisklassen definiert werden. Dies geschieht, indem bei der Angabe der Basisklasse zus¨atzlich das Schl¨usselwort virtual verwendet wird:
class Fahrzeug { protected: int baujahr;
// Baujahr
...
}; class Auto : virtual public Fahrzeug { ...
}; class Boot : virtual public Fahrzeug { ...
}; class Amph : public Auto, public Boot { ...
}; Durch die Angabe von virtual f¨ur die Basisklasse Fahrzeug sind deren Komponenten nur einmal vorhanden, wenn die abgeleiteten Klassen wieder zusammenkommen. Dabei ist die Reihenfolge, ob virtual vor oder hinter dem Zugriffsschl¨usselwort angegeben wird, unerheblich. Da baujahr sowohl von Auto als auch von Boot virtuell geerbt wird, ist die Komponente in Amphibienfahrzeugen nur einmal vorhanden. Ein Zugriff ohne Bereichsoperator ist somit eindeutig und deshalb m¨oglich:
void Amph::f() { baujahr = 1983;
// OK
Sandini Bib
342
}
Kapitel 5: Vererbung und Polymorphie
Auto::baujahr = 1983; Boot::baujahr = 1983; Fahrzeug::baujahr = 1983;
// OK // OK // OK
Entscheidend f¨ur die gemeinsame Verwendung der Komponenten einer Klasse ist, dass die direkt abgeleiteten Klassen jeweils die Basisklasse virtuell deklarieren. Das Schlu¨ sselwort virtual muss also, wie hier geschehen, bei Auto und Boot angegeben werden. Virtuelle und nichtvirtuelle Basisklassen Was aber ist, wenn bei Amphibienfahrzeugen baujahr einmal, maxspeed aber mehrfach vorhanden sein soll? Hier ben¨otigt man eine Hilfsklasse, um die Komponenten, die gemeinsam verwendet werden sollen, von den mehrfach vorhandenen zu trennen. Das k¨onnte in unserem Beispiel wie in Abbildung 5.10 dargestellt aussehen. Da alle von FahrzeugVirtual direkt abgeleiteten Klassen (hier nur die Klasse FahrzeugNonVirtual) virtuell abgeleitet werden, sind dessen Komponenten nur einmal vorhanden. Da die von FahrzeugNonVirtual direkt abgeleiteten Klassen nicht virtuell sind, gibt es deren Komponenten mehrfach. F a h r z e u g V i r t u a l b a u j a h r
{ v i r t u a l }
F a h r z e u g N o n V i r t u a l m a x s p e e d
A u t o
B o o t
A m p h
Abbildung 5.10: Klasse Fahrzeug als virtuelle und nichtvirtuelle Basisklasse
Sandini Bib
5.4 Mehrfachvererbung
5.4.3
343
Das Problem der Identit¨at
Bisher wurde immer davon ausgegangen, dass Objekte identisch sind, wenn ihre Adressen gleich sind. Aufgrund dessen wurde bei der Implementierung eines eigenen Zuweisungsoperators eine Zuweisung an sich selbst durch einen Vergleich der Adressen festgestellt (siehe Abschnitt 6.1.5): // Zuweisung an sich selbst ?
if (this == &obj) { return *this; }
// Objekt unver¨andert zur¨uckliefern
Dies geht bei der Mehrfachvererbung nicht mehr, denn bei der Mehrfachvererbung k¨onnen Objekte identisch sein, obwohl ihre Adressen nicht gleich sind. Dies liegt an der Art und Weise, wie Objekte zur Laufzeit in Programmen typischerweise verwaltet werden. Der Aufbau eines Objekts der Klasse Amph setzt sich im Allgemeinen aus den Komponenten der daran beteiligten Klassen zusammen. Typischerweise werden die Komponenten in der Reihenfolge ihrer Deklaration verwaltet (siehe Abbildung 5.11). Ein Objekt einer abgeleiteten Klasse kann aber grunds¨atzlich als Objekt seiner Basisklasse verwendet werden. In einem solchen Fall wird das Objekt auf die Komponenten reduziert, die in der Basisklasse bereits bekannt sind.
Amph:
Anteil von Fahrzeug Anteil von Auto Anteil von Boot Anteil von Amph
Abbildung 5.11: Typischer Aufbau eines Objekts der Klasse Amph
Bei der einfachen Vererbung werden die Komponenten der abgeleiteten Klasse, die am Ende des Objekts liegen, einfach nicht mehr ber¨ucksichtigt. Die Adresse des Objekts bleibt aber die gleiche. Bei Mehrfachvererbung geschieht dies auch, solange nur hinten liegende Objektteile wegfallen. Dies passiert beim Amphibienfahrzeug z.B. typischerweise dann, wenn es als Auto betrachtet wird (siehe Abbildung 5.12). Wenn das Amphibienfahrzeug aber als Boot betrachtet wird, funktioniert das Weglassen hinterer Teile nicht mehr, da der Objektteil von Auto zwischen Fahrzeug und Boot liegt. Aus diesem Grund wird dann typischerweise ein Schattenobjekt“ erzeugt, das den f¨ur Boote ” richtigen Aufbau hat. Die Objektteile darin sind aber eigentlich nur interne Verweise auf das Originalobjekt (siehe Abbildung 5.13). Entscheidend ist, dass das Objekt damit eine zweite Adresse erh¨alt, unter der es dann angesprochen wird.
Sandini Bib
344
Kapitel 5: Vererbung und Polymorphie
Anteil von Fahrzeug Anteil von Auto Anteil von Boot
Anteil von Fahrzeug
=>
Anteil von Auto
als Auto
Anteil von Amph
Abbildung 5.12: Verwendung von Amph als Auto
Anteil von Fahrzeug Anteil von Auto Anteil von Boot
=>
Anteil von Fahrzeug Anteil von Boot
als Boot
Anteil von Amph
Abbildung 5.13: Verwendung von Amph als Boot
Selbst wenn dann die Betrachtung als Auto und die Betrachtung als Boot jeweils als Fahrzeug verwendet werden, bleiben die Adressen verschieden. Der Objektteil Fahrzeug wird dann einmal vom Original- und einmal vom Schattenobjekt verwendet. Trotz gleichen Typs kann dasselbe Objekt also eine unterschiedliche Adresse besitzen. Zum Testen dieses Sachverhalts kann folgendes Beispiel verwendet werden:
// vererb/ident1.cpp void fFahrzeug (const Bsp::Fahrzeug& a) { std::cout << " als Fahrzeug: " << static_cast(&a) << std::endl; } void fAuto (const Bsp::Auto& a) { std::cout << "&a als Auto: " << static_cast(&a) << std::endl; fFahrzeug(a); }
Sandini Bib
5.4 Mehrfachvererbung
345
void fBoot (const Bsp::Boot& a) { std::cout << "&a als Boot: " << static_cast(&a) << std::endl; fFahrzeug(a); } int main () { Bsp::Amph a;
}
fAuto(a); fBoot(a);
Die mit Hilfe von f1() u¨ ber zwei verschiedene Wege aufgerufene Funktion fFahrzeug() wird f¨ur das Objekt a mit ziemlicher Sicherheit zwei unterschiedliche Adressen ausgeben. Statt mit Referenzen k¨onnen Sie den Test auch mit Zeigern machen:
// vererb/ident2.cpp int main () { using std::cout; using std::endl; Bsp::Amph a; // Adresse von a
cout << "&a: " << (void*)&a << "\n" << endl; // Adresse von a => als Auto, als Boot
cout << "(Bsp::Auto*) &a: " << (void*)(Bsp::Auto*)&a << "\n"; cout << "(Bsp::Boot*) &a: " << (void*)(Bsp::Boot*)&a << "\n\n"; // Adresse von a => als Auto => als Fahrzeug
cout << "(Bsp::Fahrzeug*) (Bsp::Auto*) &a: " << (void*)(Bsp::Fahrzeug*)(Bsp::Auto*)&a << endl; // Adresse von a => als Boot => als Fahrzeug
}
cout << "(Bsp::Fahrzeug*) (Bsp::Boot*) &a: " << (void*)(Bsp::Fahrzeug*)(Bsp::Boot*)&a << '\n' << endl;
Sandini Bib
346
Kapitel 5: Vererbung und Polymorphie
Es ist noch nicht einmal sichergestellt, dass nur zwei verschiedene Adressen ausgegeben werden. Die Tatsache, dass ein Objekt immer die gleiche Adresse besitzt, ist keine Sprachspezifikation von C++, sondern beruht darauf, dass Compiler Objekte im Allgemeinen soweit es geht an der gleichen Adresse verwalten. Um identische Objekte mit Sicherheit zu erkennen, gibt es nur eine M¨oglichkeit: Man muss entsprechende Mechanismen selbst implementieren. Jedes Objekt muss als Komponente eine eindeutige ID und eine Elementfunktion zum Vergleichen dieser IDs besitzen. Abschnitt 6.5.1 zeigt, wie das aussehen kann.
5.4.4
Dieselbe Basisklasse mehrfach ableiten
Es ist nicht m¨oglich, ein und dieselbe Klasse mehrfach direkt abzuleiten (siehe Abbildung 5.14). Ansonsten w¨urden unl¨osbare Namenskonflikte entstehen. Bei Initialisierungslisten kann dann z.B. keine eindeutige Zuordnung mehr getroffen werden, welche Basisklasse gemeint ist.
A
B
Abbildung 5.14: Die gleiche Basisklasse direkt mehrfach ableiten
Dieser Sachverhalt kann aber unter Umst¨anden sinnvoll eingesetzt werden. Ich habe allerdings noch kein schl¨ussiges Beispiel daf¨ur gesehen. Es w¨urde bedeuten, dass ein Objekt vom Typ B einerseits ein Objekt vom Typ A, andererseits aber ein anderes Objekt vom Typ A ist. Wer in der Praxis vor einem solchen Vererbungsproblem steht, sollte deshalb zun¨achst einmal pr¨ufen, ob sein Design in Ordnung ist und er nicht meint, dass ein Objekt vom Type B aus zwei Objekten vom Typ A besteht und also zwei solche Objekte als Komponenten besitzt. Falls es aber doch sinnvoll ist, ein und dieselbe Basisklasse mehrfach direkt abzuleiten, sollten Sie zwei Dinge tun:
Mir schreiben, damit ich Ihr Beispiel in der n¨achsten Auflage als sinnvolles Beispiel verwenden kann,7 und Dummy-Klassen einf¨uhren, da bei gleichnamigen Basisklassen keine eindeutige Zuordnung durch den Klassennamen m¨oglich ist (siehe Abbildung 5.15).
Das einzige halbwegs sinnvolle Beispiel, das mir einf¨allt, ist eine von der Klasse Person zweimal abgeleitete Klasse GespaltenePersoenlichkeit.
7
Sandini Bib
5.4 Mehrfachvererbung
347
A
A 1
A 2
B
Abbildung 5.15: Die gleiche Basisklasse indirekt mehrfach ableiten
5.4.5
Zusammenfassung
C++ erlaubt die Mehrfachvererbung. Abgeleitete Klassen k¨onnen mehrere Basisklassen besitzen. Entstehen dadurch Namenskonflikte f¨ur den Zugriff auf Komponenten, kann nur mit dem Bereichsoperator darauf zugegriffen werden. Basisklassen k¨onnen u¨ ber Mehrfachvererbung mehrfach verwendet werden. Durch virtuelle Basisklassen sind die darin definierten Komponenten in den Objekten dann trotzdem nur einmal vorhanden. Insbesondere bei der Mehrfachvererbung kann ein und dasselbe Objekt verschiedene Adressen besitzen. Dies ist sogar m¨oglich, wenn es unter dem gleichen Typ betrachtet wird. Um die Identit¨at eines Objekts immer zweifelsfrei sicherstellen zu k¨onnen, m¨ussen deshalb entsprechende Komponenten eingef¨uhrt werden.
Sandini Bib
348
Kapitel 5: Vererbung und Polymorphie
5.5 Design-Fallen bei der Vererbung Der Aufbau einer Klassenhierarchie ist eine wesentliche Design-Entscheidung bei der Softwareentwicklung, die sp¨ater nur schwer wieder korrigiert werden kann. Umso wichtiger ist es, dass dabei keine Fehler gemacht werden. Dieser Abschnitt stellt verschiedene Fehlerquellen vor, die beim konzeptionellen Einsatz von Vererbung existieren, so dass sie erkannt und korrigiert werden k¨onnen.
5.5.1
Vererbung kontra Verwendung
In objektorientierten Sprachen gibt es zwei M¨oglichkeiten zu abstrahieren: Vererbung und Verwendung. F¨ur die dahinter steckende Beziehung gibt es verschiedene Bezeichnungen: Beziehung
Sprachmittel
Verwendung has_a besteht_aus part_of containment Aggregation Komponente, Verweis
Vererbung is_a ist_ein kind_of inheritance Generalisierung abgeleitete Klasse
Betrachten wir das am Beispiel eines Autos:
Ein Auto ist ein Fahrzeug Ein Auto besteht aus Motor, Karosserie, der M¨oglichkeit zu fahren, ...
Beim Auto ist das sicher unstrittig, doch das ist nicht immer der Fall. Im Folgenden werden verschiedene Beispiele vorgestellt, bei denen nicht immer klar ist, ob Vererbung oder Verwendung angemessen ist.
5.5.2
Design-Fehler: Einschr¨ankende Vererbung
Wie lautet Ihre Antwort, wenn ich Sie frage: In welcher Beziehung stehen die Klassen Rechteck und Quadrat zueinander? Falls Sie annehmen, es handelt sich um eine Vererbungsbeziehung, liegen Sie falsch. Die Vererbung ist zwar durch die is-a-Beziehung gekennzeichnet, die deutsche Sprache verwendet diese Beziehung aber auch in einem anderen Sinne. Was mit der is-a-Beziehung semantisch n¨amlich ausgedr¨uckt wird, ist die Tatsache, dass alles, was mit einem Objekt einer Basisklasse gemacht werden kann, auch f¨ur ein Objekt der abgeleiteten Klasse m¨oglich und sinnvoll ist. Wenn ich nun im objektorientierten Sinne behaupte, ein ” Quadrat ist ein Rechteck“, sage ich damit, dass ich mit einem Quadrat alles machen kann, was ich auch mit einem Rechteck machen kann. Doch das stimmt nicht! Ich kann n¨amlich die Breite und H¨ohe eines Rechtecks unterschiedlich ver¨andern. Bei einem Quadrat ist das aber nicht m¨oglich. Das Quadrat w¨are danach kein Quadrat mehr.
Sandini Bib
5.5 Design-Fallen bei der Vererbung
349
Der Design-Fehler ist der, dass an die geerbten Komponenten eine spezialisierende Bedingung gekn¨upft wird, n¨amlich, dass Breite und H¨ohe, durch die ein Rechteck gekennzeichnet ist, immer gleich sein m¨ussen. Eine Regel zur Vererbung lautet also:
Alle Zust¨ande, die ein Objekt einer Basisklasse annehmen kann, muss auch ein Objekt der abgeleiteten Klasse annehmen k¨onnen.
Dummerweise wird die Vererbungsbeziehung h¨aufig auch als Spezialisierung bezeichnet. Dies ist aber nicht im einschr¨ankenden Sinne, sondern im konkretisierenden Sinne gemeint. Ich bezeichne die Vererbungsbeziehung deshalb lieber als Konkretisierung. Praktischer Umgang mit einschr¨ankender Vererbung F¨ur den praktischen Umgang mit einschr¨ankender Vererbung gibt es drei M¨oglichkeiten:
M¨ogliche Auswirkungen ignorieren Obwohl es als problematisch bekannt ist, kann man eine Klasse Quadrat von einem Rechteck ableiten und nach dem Motto Wer versucht, die Breite und H¨ohe von Quadraten unterschied” lich zu ver¨andern, ist selbst schuld.“ dem Anwendungsprogrammierer den schwarzen Peter u¨ berlassen. Diese Art von Design ist sicherlich ganz schlecht, da der Fehler auftreten kann und dann noch nicht einmal festgestellt wird, sondern zun¨achst nur zu einer Inkonsistenz f¨uhrt. M¨ogliche Auswirkungen im Einzelfall beheben Eine andere M¨oglichkeit besteht darin, ebenfalls Quadrat von Rechteck abzuleiten, die Funktionen, die zu Inkonsistenzen f¨uhren k¨onnen, in der Klasse Quadrat aber neu zu implementieren. Diese neuen Implementierungen k¨onnten – eine Fehlermeldung ausgeben, – den Aufruf ignorieren (gar nichts tun), – versuchen, den Aufruf auf den speziellen Fall hin zu interpretieren (z.B. das Quadrat mit dem Mittelwert aus dem Faktor f¨ur die Breite und H¨ohe skalieren).
Diese M¨oglichkeit ist zwar nicht ganz so schlecht wie die erste, da der Fehler wenigstens nicht zu Inkonsistenzen f¨uhrt. Sie ist aber nichtsdestotrotz immer noch schlecht, da der Fehler immer noch auftreten kann. An dieser Stelle muss immer die Polymorphie beachtet werden. Man kann dann ein Quadrat zeitweise als Rechteck verwenden oder sogar in einem Feld (Array) von verschiedenen Rechtecken verwalten. Wenn es f¨ur Rechtecke die M¨oglichkeit gibt, die Breite und H¨ohe unterschiedlich zu skalieren, dann sollte das f¨ur jedes Objekt gelten, das als Rechteck verwendet wird. Ansonsten m¨usste der Anwendungsprogrammierer sich Gedanken dar¨uber machen, welche Spezialf¨alle durch ungeschickte Vererbung auftreten k¨onnen. Man beachte schließlich, dass Klassen auch abgeleitet werden k¨onnen, wenn sie bereits an anderer Stelle Verwendung finden. Potentielle Fehlerquelle durch besseres Design beseitigen Der richtige Umgang mit spezialisierender Vererbung besteht darin, ein m¨ogliches Auftreten des Fehlers erst gar nicht zuzulassen, indem der Vererbungsfehler erst gar nicht gemacht wird.
Sandini Bib
350
Kapitel 5: Vererbung und Polymorphie
Da ein Quadrat also kein Rechteck und ein Rechteck ohnehin kein Quadrat ist, gibt es u¨ berhaupt keine Vererbungsbeziehung. Wer die zweifellos vorhandenen gemeinsamen Eigenschaften aber nur einmal implementieren will, kann eine gemeinsame abstrakte Basisklasse wie Viereck implementieren.
5.5.3
Design-Fehler: Wertver¨andernde Vererbung
Wie lautet Ihre Antwort, wenn ich Sie frage: In welcher Beziehung stehen die Klassen Bruch und BruchMitGanzerZahl (wie 3 41 ) zueinander? Sie sind nun vorsichtig geworden und antworten sicherheitshalber, dass Sie es nicht wissen, was keine schlechte Antwort ist, da die Antwort ohnehin nicht ganz eindeutig ist. Die Implementierung k¨onnte im Prinzip so aussehen:
class Bruch { private: int zaehler; int nenner; ...
}; class BruchMitGanzerZahl : public Bruch { private: int zahl; ...
}; Der entscheidende Punkt ist, dass eine neue Komponente hinzukommt, die den bisherigen Komponenten eine neue Bedeutung gibt. Wenn das Objekt mit 13 4 initialisiert wird, sind die Werte von zaehler und nenner bei Bruch und BruchMitGanzerZahl verschieden. Der Bruch 13 4 w¨are als BruchMitGanzerZahl 3 41 (siehe Abbildung 5.16).
Bruch: zaehler: 13 nenner: 4
BruchMitGanzerZahl: zaehler: nenner:
1 4
zahl:
3
Abbildung 5.16: Bruch versus BruchMitGanzerZahl
Nun muss beachtet werden, dass die Vererbungsbeziehung auch bedeutet, dass ein abgeleitetes Objekt jederzeit als Objekt der Basisklasse verwendet werden kann. In diesem Fall reduziert sich
Sandini Bib
5.5 Design-Fallen bei der Vererbung
351
das Objekt auf die Komponenten, die in der Basisklasse schon existieren. Also w¨urde 3 41 als 14 verwendet, was zu entsprechend unsinnigen Ergebnissen f¨uhren kann. So w¨urde letztlich jede 1 Zuweisung an einen Bruch aus dem initialisierten Wert 13 4 den Wert 4 machen. Der Design-Fehler besteht darin, dass die geerbten Komponenten um neue Komponenten erg¨anzt werden, durch die sie f¨ur den gleichen Informationsgehalt neue Werte erhalten. Eine weitere Regel zur Vererbung lautet also:
Der Zustand der Komponenten, die ein Objekt einer Basisklasse f¨ur einen bestimmten Wert annimmt, darf durch neue Komponenten einer abgeleiteten Klasse nicht ver¨andert werden.
Auch in diesem Fall sollte nicht abgeleitet werden, um ein m¨ogliches Auftreten des Fehlers gar nicht erst zu erm¨oglichen. Es handelt sich hier n¨amlich eindeutig um eine has-a-Beziehung. Schon der Name der Klasse, BruchMitGanzerZahl, dr¨uckt ja bereits aus, dass zwei Objekte, eine Bruch und eine ganze Zahl, als gemeinsames Objekt betrachtet werden. Die Deklaration der Klasse s¨ahe also besser so aus:
class BruchMitGanzerZahl { private: Bruch bruch; int zahl; ...
}; In diesem Beispiel gibt es allerdings noch eine andere M¨oglichkeit der Implementierung. Es kann n¨amlich geerbt werden, ohne dass die Werte der geerbten Komponenten f¨ur den gleichen Informationsgehalt ge¨andert werden. Dazu m¨usste die Implementierung dahingehend ge¨andert werden, dass der Wert 13 4 auch bei der Klasse BruchMitGanzerZahl dazu f¨uhrt, dass die Komponente zaehler mit 13 und die Komponente nenner mit 4 initialisiert wird. Der einzige Unterschied w¨urde darin bestehen, dass ein Bruch als Bruch mit ganzer Zahl ausgegeben wird. Es w¨urde also nur die Ausgabeoperation umgeschrieben werden.
5.5.4
Design-Fehler: Wertinterpretierende Vererbung
Nehmen wir an, die Klasse Bruch w¨are nur f¨ur positive Br¨uche definiert:
class Bruch { private: unsigned int zaehler; unsigned int nenner; ...
}; In diesem Fall w¨are eine Ableitung durch die Klasse BruchMitVorzeichen falsch:
class BruchMitVorzeichen : public Bruch { private: bool istNegativ; ...
};
Sandini Bib
352
Kapitel 5: Vererbung und Polymorphie
Der Informationsgehalt eines Bruchs wird hier n¨amlich um eine Information erg¨anzt, die die Bedeutung der Wertbelegung a¨ ndern kann, was ohne diese Komponente nicht m¨oglich w¨are. Dies liegt daran, dass der potenzielle Wertebereich damit verdoppelt wird (alle Bruch-Werte positiv und negativ). otzlich Wenn nun aber ein negativer Bruch einem Bruch zugewiesen wird, wird aus 13 4 pl¨ 13 , was zumindest sicherlich problematisch ist. Ich h¨ o r schon die Leser, die sagen, dass dies doch 4 ein nettes Feature“ w¨are. Die Frage ist nur, ob ein derartiges Verhalten so intuitiv ist, dass es ” ohne explizite Veranlassung stattfinden darf. Es kann schon kritisch werden, wenn Sie in Ihrem Programm aus Versehen Absicht aus 12 Millionen Euro Schulden ein Guthaben von 12 Millionen Euro machen. Der Design-Fehler besteht darin, dass die geerbten Komponenten um neue Komponenten erg¨anzt werden, durch die ihre Werte unterschiedliche Bedeutungen erhalten. Eine weitere Regel zur Vererbung lautet also:
Die Wertebelegung, die ein Objekt einer Basisklasse f¨ur einen bestimmten Informationsgehalt annehmen kann, darf durch neue Komponenten einer abgeleiteten Klasse keine neue Bedeutung erhalten.
Dieses Design-Problem ist auch daran zu erkennen, dass nicht f¨ur jeden Wert eines Objekts einer abgeleiteten Klasse eine Zuweisung an ein Objekt einer Basisklasse sinnvoll ist, womit wie diese Art der Vererbung auch wieder als einschr¨ankende Vererbung entlarvt haben. Auch hier ist die einzig sinnvolle L¨osung die Implementierung als eigenst¨andige Klasse mit Bruch und Vorzeichen als Komponenten:
class BruchMitVorzeichen { private: Bruch bruch; bool istNegativ; ...
}; Das Problem kann auch schon durch eine bessere Namensgebung erkannt werden: Wenn die Klasse Bruch in diesem Fall BruchOhneVorzeichen genannt wird, ist bereits offensichtlich, dass ein BruchMitVorzeichen kein BruchOhneVorzeichen ist.
5.5.5
Vermeide Vererbung!“ ”
Insgesamt zeigt sich bei Einsteigern in die objektorientierte Programmierung ein Hang, das neue Sprachmittel Vererbung zu oft einzusetzen. Dies ist immer problematisch, denn eine Vererbung stellt eine engere Bindung dar als eine Verwendung. Werden Klassen nur verwendet, passiert nichts solange sich die o¨ ffentliche Schnittstelle nicht a¨ ndert. Da abgeleitete Klassen auch Zugriff ¨ in Basisklassen kritischer und k¨onnen auf protected Komponenten haben, sind Anderungen leichter zu Inkonsistenzen f¨uhren. Insbesondere ein Ableiten von Klassen, die nicht unter der eigenen Kontrolle liegen, ist immer kritisch.
Sandini Bib
5.5 Design-Fallen bei der Vererbung
353
Ich neige deshalb zu der Empfehlung: Vermeide Vererbung!“. Das bedeutet, dass man Verer” bung nur dann einsetzt, wenn man einen Sachverhalt nicht anders sinnvoll implementieren kann. Zuerst sollte man sich immer Fragen, ob nicht eigentlich eine Verwendungsbeziehung vorliegt. Wenn man dann ableitet, sollte man die Semantik von Operationen und Komponenten nie einschr¨anken oder ver¨andern. Objekte abgeleiteter Klassen sollten ohne Einschr¨ankung jederzeit als Objekte der Basisklasse verwendet werden k¨onnen. Dieses Prinzip ist auch als Liskov Substitution Principle bekannt.8 Manchmal mag es als Implementierungsdetail sinnvoll sein, eine Verwendungsbeziehung durch private Vererbung (siehe Abschnitt 5.2.8) zu implementieren. Dies Art der Vererbung wird mitunter auch Implementierungsvererbung genannt und verdeutlicht den Sachverhalt: Es geht nicht um eine konzeptionelle Vererbung im Sinne der is-a-Beziehung, sondern um eine einfachere Implementierung eines Sachverhalts durch implizite Wiederverwendung von Code. Auch dies sollte man vermeiden. Auf lange Sicht lohnt sich robustes Design mit m¨oglichst wenig Abh¨angigkeiten immer.
5.5.6
Zusammenfassung
Nicht jede im Sprachgebrauch vorhandene is-a-Beziehung ist ein Kennzeichen f¨ur die Vererbung. Insbesondere ist (¨offentliche) Vererbung nicht korrekt, – wenn nicht mehr alle Operationen m¨oglich sind, – wenn Operationen nicht mehr die gleiche semantische Bedeutung haben, – wenn die Bedeutung von Komponenten der Basisklasse durch die Vererbung ver¨andert wird oder
8
– wenn die Eigenschaften von Komponenten der Basisklasse eingeschr¨ankt werden. F¨ur alle m¨oglichen Objekte einer abgeleiteten Klasse muss es sinnvoll sein, sie einem Objekt einer Basisklasse zuzuweisen, wobei die neuen Komponenten einfach wegfallen bzw. ignoriert werden. Vermeide Vererbung.
Nach Barbara Liskov, die dieses Prinzip zum ersten Mal 1988 formal vorstellte.
Sandini Bib
Sandini Bib
Kapitel 6
Dynamische und statische Komponenten In den bisherigen Kapiteln wurden Klassen vorgestellt, die einfache“ Komponenten besaßen. ” Damit ist gemeint, dass es sich um Komponenten wie int, string oder vector handelt, die man problemlos kopieren und zuweisen kann. Dies ist jedoch nicht bei allen Datentypen der Fall. Besonders, wenn man Zeiger als Komponenten verwendet, kann man diese Komponenten nicht einfach kopieren oder einander zuweisen. Aus diesem Grund muss man sich in die als Default vorhandenen Operationen, wie das Kopieren, Zuweisen und auch in das Aufr¨aumen einmischen. Dieses Kapitel behandelt dieses Thema unter dem Stichwort dynamische Komponenten“. ” Außerdem werden in diesem Kapitel statische Komponenten erl¨autert. Dabei handelt es sich um Komponenten, die nur einmal im Programm enthalten sind und von allen Objekten/Instanzen gemeinsam verwendet werden. Als Beispielklassen f¨ur dieses Kapitel dienen eine eigene Implementierung einer StringKlasse und eine einfache Klasse zur Personenverwaltung.
355
Sandini Bib
356
Kapitel 6: Dynamische und statische Komponenten
6.1 Dynamische Komponenten Dieser Abschnitt stellt die Verwaltung und Anwendung von Klassen mit dynamischen Komponenten vor. Anhand einer eigenen einfachen Implementierung einer Klasse f¨ur Strings werden die Besonderheiten vorgestellt, die bei der Verwendung dynamischer Komponenten beachtet werden m¨ussen.
6.1.1
Implementierung der Klasse String
Im Folgenden wird eine Implementierung der Klasse Bsp::String vorgestellt, mit der einfache Operationen analog zum Standarddatentyp std::string m¨oglich sind. Somit k¨onnte in einfachen Anwendungsf¨allen mit Hilfe einer einfachen Typdefinition von der Standardklasse auf diese eigene Klasse gewechselt werden. Der Implementierungsansatz setzt alle Operationen dieser String-Klasse um auf von C u¨ bernommene Funktionen f¨ur Strings und Felder (Arrays) zur¨uck (diese werden zum Teil in Tabelle 3.12 auf Seite 118 vorgestellt). So wird z.B. die Zuweisung auf die Funktion memcpy() zur¨uckgef¨uhrt. F¨ur die Speicherverwaltung werden die in C++ vorhandenen Operatoren new und delete (siehe Seite 120) verwendet. Headerdatei der Klasse Bsp::String Die Headerdatei der Klasse Bsp::String hat insgesamt folgenden Aufbau, auf den nachfolgend im Einzelnen eingegangen wird:
// dyna/string1.hpp #ifndef STRING_HPP #define STRING_HPP // Headerdatei f¨ur I/O
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class String { private: char* buffer; unsigned len; unsigned size;
// Zeichenfolge als dynamisches Array // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
public:
// Default- und char*-Konstruktor
String (const char* = "");
Sandini Bib
6.1 Dynamische Komponenten
357
// Aufgrund dynamischer Komponenten:
String (const String&); // Copy-Konstruktor String& operator= (const String&); // Zuweisung ~String(); // Destruktor // Vergleichen von Strings
friend bool operator== (const String&, const String&); friend bool operator!= (const String&, const String&); // Hintereinanderh¨angen von Strings
friend String operator+ (const String&, const String&); // Ausgabe mit Streams
void printOn (std::ostream&) const; // Eingabe mit Streams
void scanFrom (std::istream&); // Anzahl der Zeichen
unsigned length () const { return len; }
};
private: /* Konstruktor aus L¨ange und Buffer * - intern f¨ur Operator + */ String (unsigned, char*);
// Standard-Ausgabeoperator
inline std::ostream& operator << (std::ostream& strm, const String& s) { // String auf Stream ausgeben s.printOn(strm); return strm; // Stream zur¨uckliefern } // Standard-Eingabeoperator
inline std::istream& operator >> (std::istream& strm, String& s) { s.scanFrom(strm); // String von Stream einlesen
Sandini Bib
358
Kapitel 6: Dynamische und statische Komponenten
}
return strm;
// Stream zur¨uckliefern
/* Operator !=
* - als Umsetzung auf Operator == inline implementiert */
inline bool operator!= (const String& s1, const String& s2) { return !(s1==s2); } } // **** ENDE Namespace Bsp ******************************** #endif // STRING_HPP Betrachten wir zun¨achst die Komponenten, die den Aufbau eines Strings beschreiben:
class String private: char* unsigned unsigned
{ buffer; len; size;
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
...
} Ein String-Objekt besteht aus einem dynamischen Zeiger buffer, der die eigentlichen Zeichen des Strings als Feld von chars verwaltet, sowie aus zwei Komponenten, die jeweils die aktuelle Anzahl der Zeichen in dem String und die Gr¨oße des dazugeh¨origen Speicherplatzes verwalten. Die Zeichenfolge selbst geh¨ort nicht direkt zum Objekt, sondern wird als dynamischer Speicherplatz vom Objekt verwaltet. Abbildung 6.1 verdeutlicht diesen Sachverhalt: Der String s enth¨alt dort im aktuellen Zustand die Zeichenfolge hallo mit f¨unf Zeichen. Diese Zeichen befinden sich in einem separaten Speicherplatz f¨ur acht Zeichen, auf den buffer verweist. s :
' h '
b u f f e r : l e n : s i z e :
' a '
' l '
' l '
' o '
?
?
?
5 8
Abbildung 6.1: Interner Aufbau von Bsp::Strings
Man k¨onnte alternativ ein Feld mit einer festen Gr¨oße deklarieren. Dies hat jedoch den Nachteil, dass die Anzahl der Zeichen im String entweder merklich eingeschr¨ankt ist und/oder unverantwortlich viel Speicherplatz f¨ur kleine Strings verschwendet wird.
Sandini Bib
6.1 Dynamische Komponenten
359
Der u¨ ber buffer verwaltete dynamische Speicherplatz geh¨ort nicht automatisch zum Objekt, sondern muss explizit beim Erzeugen des Objekts angelegt werden. Entsprechend muss der Spei¨ cherplatz bei Anderungen gegebenenfalls angepasst und beim Zerst¨oren des Objekts wieder freigegeben werden. Dies ist m¨oglich, da die Implementierung s¨amtlicher Operationen in der Hand des Programmierers liegt. Jeder Konstruktor muss also explizit Speicherplatz anlegen, um diesen f¨ur die Zeichenfolge verwenden zu k¨onnen. Dazu geh¨ort auch der Copy-Konstruktor (Copy-Konstruktoren wurden in Abschnitt 4.3.7 eingef¨uhrt), der aus diesem Grund selbst implementiert werden muss. Der Default-Copy-Konstruktor w¨urde, da er komponentenweise kopiert, n¨amlich nur den Zeiger kopieren, anstatt ein neues Feld anzulegen und die Zeichenfolge der Vorlage wirklich zu kopieren. Aus dem gleichen Grund muss nun auch der Zuweisungsoperator implementiert werden. Der Default-Zuweisungsoperator, der komponentenweise zuweist, w¨urde nur die Zeiger und nicht die Zeichenfolgen, auf die diese zeigen, zuweisen. Schließlich muss der Speicherplatz, der explizit zu jedem Objekt angelegt wird, auch wieder freigegeben werden, wenn das Objekt zerst¨ort wird. Aus diesem Grund muss das Gegenst¨uck zu den Konstruktoren, der Destruktor, definiert werden. Ein Destruktor wird aufgerufen, wenn ein Objekt zerst¨ort wird, und bietet die M¨oglichkeit, vorher noch aufzur¨aumen“. Man erkennt ihn ” daran, dass er als Funktionsnamen den Klassennamen mit dem vorangestellten Zeichen ~ tr¨agt:
class String { public: ...
};
~String();
// Destruktor
Quelldatei der Klasse Bsp::String Die Klasse Bsp::String wird mit Hilfe der in in Abschnitt 3.8 eingef¨uhrten Operatoren zur dynamischen Speicherverwaltung, new[ ] und delete[ ], implementiert. Die Quelldatei hat bis auf die Einlesefunktion (sie wird erst in Abschnitt 6.1.7 erl¨autert) folgenden Aufbau:
// dyna/string1a.cpp // Headerdatei der eigenen Klasse
#include "string.hpp" // C-Headerdateien f¨ur String-Funktionen
#include #include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Konstruktor aus C-String (const char*) * - Default f¨ur s: "" */
Sandini Bib
360
Kapitel 6: Dynamische und statische Komponenten
String::String (const char* s) { len = std::strlen(s); size = len; buffer = new char[size]; std::memcpy(buffer,s,len); }
// Anzahl an Zeichen // Zeichenanzahl bestimmt Speicherplatzgr¨oße // Speicherplatz anlegen // Zeichen in Speicherplatz kopieren
/* Copy-Konstruktor */ String::String (const String& s) { // Anzahl an Zeichen u¨ bernehmen len = s.len; size = len; // Zeichenanzahl bestimmt Speicherplatzgr¨oße buffer = new char[size]; // Speicherplatz anlegen std::memcpy(buffer,s.buffer,len); // Zeichen kopieren } /* Destruktor */ String::~String () { // mit new[] angelegten Speicherplatz wieder freigeben delete [] buffer; } /* Operator = * - Zuweisung */
String& String::operator= (const String& s) { // Zuweisung eines Strings an sich selbst hat keinen Effekt
if (this == &s) { return *this; }
// String zur¨uckliefern
len = s.len;
// Anzahl an Zeichen u¨ bernehmen
// falls Platz nicht reicht, vergr¨oßern
if (size < len) { delete [] buffer;
// alten Speicherplatz freigeben
Sandini Bib
6.1 Dynamische Komponenten
}
361
size = len; // Zeichenanzahl bestimmt neue Gr¨oße buffer = new char[size]; // Speicherplatz anlegen
std::memcpy(buffer,s.buffer,len); // Zeichen kopieren }
return *this;
// ge¨anderten String zur¨uckliefern
/* Operator == * - vergleicht zwei Strings * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
bool operator== (const String& s1, const String& s2) { return s1.len == s2.len && std::memcmp(s1.buffer,s2.buffer,s1.len) == 0; } /* Operator + * - h¨angt zwei Strings hintereinander * - globale Friend-Funktion, damit eine automatische * Typumwandlung des ersten Operanden m¨oglich ist */
String operator+ (const String& s1, const String& s2) { // Puffer f¨ur den Summenstring erzeugen
char* buffer = new char[s1.len+s2.len]; // Summenzeichenfolge darin initialisieren
std::memcpy (buffer, s1.buffer, s1.len); std::memcpy (buffer+s1.len, s2.buffer, s2.len); // daraus Summenstring erzeugen und zur¨uckliefern
}
return String (s1.len+s2.len, buffer);
/* Konstruktor f¨ur uninitialisierten String bestimmter La¨ nge * - intern f¨ur Operator + */
Sandini Bib
362
Kapitel 6: Dynamische und statische Komponenten
String::String (unsigned l, char* buf) { // Anzahl an Zeichen u¨ bernehmen len = l; size = len; // ist gleichzeitig auch Speicherplatzgr¨oße buffer = buf; // Speicherplatz u¨ bernehmen } /* Ausgabe auf Stream */ void String::printOn (std::ostream& strm) const { // Zeichenfolge einfach ausgeben
}
strm.write(buffer,len);
} // **** ENDE Namespace Bsp ******************************** Wie bereits erw¨ahnt, wird intern eine dynamische Speicherverwaltung implementiert, die daf¨ur sorgt, dass die Strings intern immer ausreichend viel Speicherplatz f¨ur die jeweilige Zeichenfolge besitzen. Aus diesem Grund muss bei den Funktionen, die den Speicherplatz anlegen oder manipulieren, besonders viel Sorgfalt verwendet werden.
6.1.2
Konstruktoren bei dynamischen Komponenten
Sehen wir uns dazu zun¨achst den Konstruktor f¨ur einen C-String (Typ const char*) als Argument an:
String::String (const char* s) { len = std::strlen(s); size = len; buffer = new char[size]; std::memcpy(buffer,s,len); }
// Anzahl an Zeichen // Zeichenanzahl bestimmt Speicherplatzgr¨oße // Speicherplatz anlegen // Zeichen in Speicherplatz kopieren
Dieser Konstruktor wird aufgerufen, wenn das neue String-Objekt angelegt wurde. Dies geschieht z.B. durch eine entsprechende Deklaration:
Bsp::String s = "hallo"; Angelegt wird nur Speicherplatz f¨ur die ganzzahligen Werte len und size sowie f¨ur den Zeiger buffer. Die Werte sind, wie immer bei fundamentalen Datentypen, zun¨achst nicht definiert:
Sandini Bib
6.1 Dynamische Komponenten s :
b u f f e r :
?
l e n : s i z e :
?
363
?
Zun¨achst werden len und size initialisiert. Dabei wird mit strlen() die L¨ange des u¨ bergebenen C-Strings ermittelt:
len = std::strlen(s); size = len; Danach wird durch die Anweisung
buffer = new char[size]; Speicherplatz angelegt, der groß genug f¨ur die Zeichenfolge ist und dessen Adresse dem StringObjekt in buffer zugewiesen wird. Auch der Inhalt dieses Speicherplatzes ist zun¨achst nicht definiert: s :
?
b u f f e r : l e n : s i z e :
?
?
?
?
5 5
Mit der Anweisung
std::memcpy(buffer,s,len); werden die einzelnen Zeichen der Zeichenfolge schließlich kopiert. Die von C u¨ bernommene Standardfunktion memcpy() kopiert jeweils die u¨ bergebene Anzahl an Zeichen eines Feldes aus chars (bzw. Bytes). Damit werden alle len Zeichen von s in den Speicherbereich kopiert, auf den buffer zeigt: s :
' h '
b u f f e r : l e n : s i z e :
' a '
' l '
' l '
' o '
5 5
Das String-Objekt ist nun als eigenst¨andiges Objekt initialisiert. Verwendung als Default-Konstruktor Als Default-Konstruktor wird der C-String-Konstruktor mit dem Default-Argument "" aufgerufen. Das bedeutet, dass auf entsprechende Weise ein Objekt angelegt wird, in dem buffer auf eine Zeichenfolge ohne Elemente zeigt:
Sandini Bib
364
Kapitel 6: Dynamische und statische Komponenten s :
b u f f e r : l e n : s i z e :
0 0
Hier nutzen wir die Tatsache aus, dass man mit new auch ein Feld mit null Elementen anlegen kann. In der Praxis w¨are allerdings wohl eine Sonderbehandlung sinnvoll, die buffer in diesem Fall intern NULL zuweist. Allerdings muss dies dann auch vor jedem Zugriff auf die Zeichen der Zeichenfolge abgefragt werden. Man beachte, dass als Default-Argument f¨ur den String-Konstruktor nicht NULL verwendet werden kann, da die intern verwendeten String-Funktionen nicht mit NULL als Parameter aufgerufen werden d¨urfen (strlen(NULL) w¨urde auf vielen Systemen zum Absturz f¨uhren). Bei der Verwendung von NULL w¨are eine Sonderbehandlung notwendig. Da die Standard-String-Klasse eine derartige Sonderbehandlung allerdings nicht durchf¨uhrt, wird auch hier darauf verzichtet.
6.1.3
Implementierung eines Copy-Konstruktors
Der Copy-Konstruktor arbeitet im Prinzip wie der einfache Konstruktor. Der Unterschied ist nur, dass die Zeichenfolge, mit der das erzeugte String-Objekt initialisiert werden soll, nicht der Parameter selbst ist, sondern im Parameter u¨ bergeben wird:
String::String (const String& s) { // Anzahl an Zeichen u¨ bernehmen len = s.len; size = len; // Zeichenanzahl bestimmt Speicherplatzgr¨oße buffer = new char[size]; // Speicherplatz anlegen std::memcpy(buffer,s.buffer,len); // Zeichen kopieren } Auch hier ist der Hinweis angebracht, dass der Default-Copy-Konstruktor nicht verwendet werden kann, da dieser nur komponentenweise kopiert. Der Zeiger buffer w¨urde dann nicht auf eigenst¨andigen Speicherplatz, sondern auf den gleichen Speicherplatz zeigen, auf den buffer in dem String zeigt, der kopiert wurde.
6.1.4
Destruktoren
Der Destruktor dient bei der String-Klasse dazu, den explizit angelegten Speicherplatz mit der Zerst¨orung des Strings freizugeben. Da es sich um ein Feld von Zeichen handelt, muss die FeldSyntax des Delete-Operators verwendet werden:
String::~String () { // mit new[] angelegten Speicherplatz wieder freigeben delete [] buffer; }
Sandini Bib
6.1 Dynamische Komponenten
365
Aufgerufen wird der Destruktor, unmittelbar bevor der Speicherplatz f¨ur das eigentliche Objekt freigegeben wird. Dies ist z.B. der Fall, wenn der G¨ultigkeitsbereich eines lokal angelegten Objekts verlassen wird:
void f() { Bsp::String s = "hallo";
// Konstruktor f¨ur s
... } // Am Blockende: Destruktor f¨ur s
Der Destruktor wird f¨ur jedes angelegte Objekt aufgerufen. Das gilt auch f¨ur statische, tempor¨are und explizit angelegte Objekte:
static Bsp::String s; // Konstruktor zum Programmstart // Destruktor zum Programmende
void f () { Bsp::String namen[10]; // 10 Aufrufe des Default-Konstruktors namen[0] = neuerString("hallo");
// Zuweisung des tempor¨aren R¨uckgabewerts an namen[0] // Destruktor f¨ur tempor¨aren R¨uckgabewert
} // Am Blockende: 10 Aufrufe des Destruktors Bsp::String neuerString (const char* s) { // char*-Konstruktor Bsp::String neu = s; return neu; // Copy-Konstruktor f¨ur tempor¨aren R¨uckgabewert
} // Am Blockende: Destruktor f¨ur neu
6.1.5
Implementierung des Zuweisungsoperators
Der Zuweisungsoperator ist im Prinzip eine Kombination aus Destruktor und Copy-Konstruktor. Ein existierender String wird durch eine Kopie eines anderen ersetzt. Dabei kann allerdings oft optimiert werden, indem z.B. nur dann der bisherige Speicherplatz freigegeben und neuer reserviert wird, wenn der bisherige Speicherplatz nicht mehr ausreicht:
String& String::operator= (const String& s) { // Zuweisung eines Strings an sich selbst hat keinen Effekt
if (this == &s) {
Sandini Bib
366
Kapitel 6: Dynamische und statische Komponenten
}
return *this;
// String zur¨uckliefern
len = s.len;
// Anzahl an Zeichen u¨ bernehmen
// falls Platz nicht reicht, vergr¨oßern
if (size < delete size = buffer }
len) { // alten Speicherplatz freigeben [] buffer; len; // Zeichenanzahl bestimmt neue Gr¨oße = new char[size]; // Speicherplatz anlegen
std::memcpy(buffer,s.buffer,len); // Zeichen kopieren return *this;
}
// ge¨anderten String zur¨uckliefern
Bei der Implementierung eines eigenen Zuweisungsoperators sollte Folgendes beachtet werden:
Ein Zuweisungsoperator sollte immer das Objekt, f¨ur das die Operation aufgerufen wurde, als Referenz zur¨uckliefern. Dies erm¨oglicht verkettete Zuweisungen oder Tests nach Zuweisungen, wie sie schon in C u¨ blich sind:
Bsp::String a, b, c; ...
a = b = c; ...
if ((a = b) != Bsp::String("")) { ...
}
Allgemein lautet der typische Funktionskopf eines Zuweisungsoperators also: klasse& klasse::operator = (const klasse& obj) Da es in der Regel keinen Sinn macht, am R¨uckgabewert im gleichen Ausdruck weitere Manipulationen durchzuf¨uhren, kann man den R¨uckgabewert auch als Konstante deklarieren. Der Funktionskopf eines Zuweisungsoperators kann deshalb auch wie folgt aussehen: const klasse& klasse::operator = (const klasse& obj) Diese M¨oglichkeit wird in der Praxis allerdings nicht oft verwendet. Im Zuweisungsoperator sollte als Erstes getestet werden, ob ein Objekt sich selbst zugewiesen wird. Eine Zuweisung eines Objekts an sich selbst kann in einem Anwendungsprogramm u¨ ber Zeiger immer passieren:
Bsp::String s; Bsp::String* sp; ...
// String // Zeiger auf String
Sandini Bib
6.1 Dynamische Komponenten
sp = &s;
367 // sp zeigt auf s
...
*sp = s;
// s wird u¨ ber sp an sich selbst zugewiesen
Wird der Test nicht durchgef¨uhrt, kann es nicht nur passieren, dass unn¨otigerweise Rechenzeit verbraucht wird, es ist auch m¨oglich, dass das Programm fehlerhaft weiterl¨auft. Dies geschieht z.B., wenn beim Zuweisungsoperator Speicherplatz freigegeben wird. Damit w¨urde n¨amlich gleichzeitig der Speicherplatz des Objekts freigegeben, dessen Daten anschließend zugewiesen werden. Alle Implementierungen von Zuweisungsoperatoren sollten deshalb mit den folgenden Zeilen beginnen: // Zuweisung an sich selbst?
if (this == &obj) { return *this; }
// Objekt unver¨andert zur¨uckliefern
Man beachte, dass hier nicht verglichen wird, ob zwei Objekte gleich, sondern ob sie identisch sind. Es werden n¨amlich die Adressen verglichen. Bei einem derartigen Vergleich gibt es allerdings ein Problem: Bei Mehrfachvererbung k¨onnen Objekte identisch sein, obwohl ihre Adressen verschieden sind. In Abschnitt 5.4.3 wird darauf genauer eingegangen.
6.1.6
Weitere Operatoren
Die anderen Operatoren und Funktionen der String-Klasse sind einfach nur Umsetzungen in die entsprechenden C-Funktionen. Test auf Gleichheit Zwei Strings sind dann gleich, wenn die Zeichenfolgen gleich sind. Zum Vergleich kann die Funktion memcmp() verwendet werden (sie liefert bei Gleichheit 0 zur¨uck). Zuvor wird jedoch erst einmal gepr¨uft, ob u¨ berhaupt die Anzahl der Zeichen gleich ist:
bool operator== (const String& s1, const String& s2) { return s1.len == s2.len && std::memcmp(s1.buffer,s2.buffer,s1.len) == 0; } Der UND-Operator && bricht beim ersten falschen Teilausdruck ab. Ist die Anzahl der Zeichen also verschieden, werden die Zeichen selbst gar nicht mehr verglichen. Die Funktion ist als globale Friend-Funktion deklariert, um eine automatische Typumwandlung f¨ur den ersten Operanden zu erm¨oglichen (siehe auch Seite 227).
Bsp::String s; ...
if ("hallo" == s)
// =>
if (Bsp::String("hallo") == s)
Sandini Bib
368
Kapitel 6: Dynamische und statische Komponenten
Der Test auf Ungleichheit wird als Inline-Funktion einfach auf den Test auf Gleichheit zur¨uckgef¨uhrt:
class String { public: ...
};
friend bool operator== (const String&, const String&); friend bool operator!= (const String&, const String&);
...
inline bool operator!= (const String& s1, const String& s2) { return !(s1==s2); } Hintereinanderh¨angen durch den Operator + Der Operator + wird zum Hintereinanderh¨angen von zwei Strings verwendet. Auch er wird als globale Friend-Funktion definiert, um eine automatische Typumwandlung auch f¨ur den ersten Operanden zu erm¨oglichen:
class String { public: ...
};
friend String operator+ (const String&, const String&);
Bei der Implementierung muss ein neues Objekt f¨ur den Summenstring erzeugt werden. Eine naive Implementierung k¨onnte dies wie folgt tun:
String operator+ (const String& s1, const String& s2) { // Summenstring anlegen
String sum; // ausreichend Speicherplatz anfordern
delete [] sum.buffer; sum.buffer = new char[s1.len+s2.len]; // Zeichen kopieren
std::memcpy(sum.buffer,s1.buffer,s1.len); std::memcpy(sum.buffer+s1.len,s2.buffer,s2.len); // Summenstring zur¨uckliefern
}
return sum;
Sandini Bib
6.1 Dynamische Komponenten
369
Diese Implementierung enth¨alt aber gleich mehrere Laufzeitnachteile: Zun¨achst wird ein Summenstring mit initialem Speicherplatz angelegt, der aber sofort wieder freigegeben wird, um ihn zu vergr¨oßern (etwas, was auch leicht vergessen wird und dann ein Speicherplatz-Leck (MemoryLeak) darstellt). Schließlich wird mit der Return-Anweisung der eigentliche R¨uckgabewert als Kopie von sum angelegt und sum wieder zerst¨ort. Damit wird insgesamt dreimal new und zweimal delete aufgerufen. Es w¨are nat¨urlich besser, wenn nur einmal new aufgerufen w¨urde. Dies gilt umso mehr, als dass new und delete zu den teureren“ Operationen geh¨oren. Zu diesem Zweck wird ein spe” zieller Konstruktor definiert, der einen String anlegt, dem sowohl die Startgr¨oße als auch der vollst¨andig initialisierte Puffer mit der Zeichenfolge des Summenstrings u¨ bergeben wird:
String::String (unsigned l, char* buf) { // Anzahl an Zeichen u¨ bernehmen len = l; size = len; // ist gleichzeitig auch Speicherplatzgr¨oße buffer = buf; // Speicherplatz u¨ bernehmen } Dieser Konstruktor ist eine spezielle Optimierung, die als o¨ ffentliche Schnittstelle gef¨ahrlich w¨are. Man k¨onnte nicht sicherstellen, dass als zweiter Parameter immer mit new[] angelegter und initialisierter Speicherplatz der L¨ange len u¨ bergeben wird. Aus diesem Grund kann der Konstruktor von der Außenwelt nicht aufgerufen werden:
class String { ...
};
private: String (unsigned, char*);
Der Operator + kann damit so implementiert werden, dass gleich der Puffer des Summenstrings mit der richtigen Gr¨oße erzeugt und initialisiert wird:
String operator+ (const String& s1, const String& s2) { // Puffer f¨ur den Summenstring erzeugen
char* buffer = new char[s1.len+s2.len]; // Summenzeichenfolge darin initialisieren
std::memcpy (buffer, s1.buffer, s1.len); std::memcpy (buffer+s1.len, s2.buffer, s2.len); // daraus Summenstring erzeugen und zur¨uckliefern
}
return String (s1.len+s2.len, buffer);
Da der spezielle Konstruktor, der aus der L¨ange und dem Puffer erst einen wirklichen String erzeugt, Teil der Return-Anweisung ist, wird diese Operation typischerweise so optimiert, dass
Sandini Bib
370
Kapitel 6: Dynamische und statische Komponenten
dieser Summenstring gleich als R¨uckgabewert erzeugt wird. Damit wird nur das eine new aufgerufen. Dieses Beispiel zeigt, welches Optimierungspotenzial man hat, wenn man Zugriff auf alle Operationen eines Datentyps hat. Man beachte, dass diese Optimierung keinen Einfluss auf die Schnittstelle des Anwenders hat. Der Anwender kann so oder so einfach Strings hintereinander h¨angen. Bei dieser Implementierung geht es allerdings merklich schneller.
6.1.7
Einlesen eines Strings
Das Einlesen eines Objekts mit dynamischen Komponenten ist in der Regel nicht ganz einfach. Ein Problem ist, dass beim Beginn des Einlesens noch nicht klar ist, wie groß der Speicherplatz wird, den das Objekt braucht. Auch muss definiert werden, wann das Einlesen eines Strings beginnt und wodurch es beendet wird. Der nachfolgend vorgestellten Implementierung der Ein¨ lesefunktion liegen also einige Uberlegungen zugrunde, auf die anschließend eingegangen wird. Die Einlesefunktion der Klasse Bsp::String hat folgenden Aufbau:
// dyna/string1b.cpp // **** BEGINN Namespace Bsp ********************************
namespace Bsp { void String::scanFrom (std::istream& strm) { char c; len = 0;
// zun¨achst ist der gelesene String leer
strm >> std::ws;
// f¨uhrende Trennzeichen u¨ berlesen
/* solange der Input-Stream strm nach dem Einlesen * eines Zeichens in c in Ordnung ist */ // >> w¨urde Trennzeichen u¨ berlesen while (strm.get(c)) { /* ein Trennzeichen schließt die Stringeingabe ab * => RETURN */ if (std::isspace(c)) { return; } /* falls der Platz nicht mehr ausreicht, * muss mehr geschaffen werden */
Sandini Bib
6.1 Dynamische Komponenten
if (len >= size) { char* tmp = buffer; size = size*2 + 32; buffer = new char[size]; std::memcpy(buffer,tmp,len); delete [] tmp; }
371
// Zeiger auf alten Speicherplatz // Speicherplatz mehr als verdoppeln // neuen Speicherplatz anlegen // Zeichen kopieren // alten Speicherplatz freigeben
// neues Zeichen eintragen
}
buffer[len] = c; ++len;
// Einlese-Ende durch Fehler oder EOF
} } // **** ENDE Namespace Bsp ******************************** Grunds¨atzlich ist die Einlesefunktion so implementiert, dass sie einen String als Wort einliest, wobei f¨uhrende Trennzeichen u¨ berlesen werden. Abgeschlossen wird die Eingabe also durch ein Trennzeichen (Whitespace) oder das Ende der Eingabe (EOF, End-Of-File). Nachdem die L¨ange des einzulesenden Strings auf 0 gesetzt wurde, werden deshalb zun¨achst f¨uhrende Trennzeichen u¨ berlesen:
std::strm >> std::ws; Der Manipulator std::ws u¨ bernimmt diese Arbeit. Der Name steht f¨ur Whitespace“, womit die ” typischen Trennzeichen Newline, Tabulator und Leerzeichen gemeint sind. Anschließend wird eine Schleife durchlaufen, die jeweils ein Zeichen liest und verarbeitet:
while (strm.get(c)) { // c verarbeiten ...
} Die Elementfunktion get() dient dabei dazu, das jeweils n¨achste Zeichen einzulesen. Der Operator >> kann hier nicht verwendet werden, da er, selbst wenn er nur ein Zeichen einliest, f¨uhrende Trennzeichen u¨ berliest. Wir m¨ussen aber wissen, ob ein Trennzeichen gelesen wurde, da dies das Ende der Stringeingabe w¨are. Die Funktion get() liefert jeweils den Stream zur¨uck, von dem gelesen wurde. Dieser wird dann als Bedingung verwendet, ob die Schleife weiterlaufen soll. Wie in Abschnitt 4.5.2 bereits erl¨autert wurde, ist die Bedingung dann erf¨ullt, wenn sich der Stream in einem korrekten Zustand befindet (weder End-Of-File noch Fehler). Die Schleife l¨auft also zun¨achst so lange, wie ein einzelnes Zeichen erfolgreich eingelesen werden kann.
Sandini Bib
372
Kapitel 6: Dynamische und statische Komponenten
In der Schleife wird zun¨achst getestet, ob ein Trennzeichen eingegeben wurde. Wenn dies der Fall ist, wird es als Eingabeende betrachtet, und die wird Funktion beendet:1
if (std::isspace(c)) { return; } Wurde kein Trennzeichen eingegeben, muss das neue Zeichen eingetragen werden. Hierbei muss aber sichergestellt werden, dass noch genug Platz f¨ur das neue Zeichen vorhanden ist. Schon in C ist es ein verbreiteter Fehler anzunehmen, eine Eingabe k¨onne nur 80 Zeichen lang sein. Tatsache ist, dass eine Eingabe beliebig lang werden kann (z.B. weil die Eingabe von einem Datenstrom gelesen wird, der erst nach 10.000 Zeichen ein Trennzeichen hat). Wenn der Platz nicht mehr ausreicht, wird deshalb ein neuer, mehr als doppelt so großer Speicherplatz angefordert, und die bisherigen Zeichen werden dorthin kopiert:
if (len >= size) { char* tmp = buffer; size = size*2i + 32; buffer = new char[size]; std::memcpy(buffer,tmp,len); delete [] tmp; }
// Zeiger auf alten Speicherplatz // Speicherplatz mehr als verdoppeln // neuen Speicherplatz anlegen // Zeichen kopieren // alten Speicherplatz freigeben
Nun kann das neue Zeichen eingetragen und die Anzahl der Zeichen entsprechend erh¨oht werden:
buffer[len] = c; ++len;
6.1.8
Kommerzielle Implementierung von String-Klassen
Die vorliegende Implementierung von Bsp::String stellt nur einen rudiment¨aren Auszug aus einer Implementierung einer standardkonformen String-Klasse dar. So fehlt z.B. eine der wichtigsten Operationen, n¨amlich die M¨oglichkeit, mit dem Indexoperator auf ein Zeichen zuzugreifen. Darauf wird in Abschnitt 6.2.1 eingegangen. Auch kann man die Klasse sicherlich noch weiter optimieren. Wir haben zwar schon ein wenig optimiert; da die Klasse aber von fundamentaler Bedeutung ist, lohnt sich sicherlich jede Optimierung, die zu einem besseren Laufzeitverhalten f¨uhrt. Eine Konsequenz der Optimierung ist, dass die meisten Funktionen Inline-Funktionen sind. Da Headerdateien lesbar sind (sie m¨ussen ja eingebunden werden k¨onnen), empfehle ich, einmal einen Blick in eine derartige Headerdatei zu werfen. Hinweis: Auf manchen Systemen wird isspace() auch in f¨alschlicherweise global, also ohne std:: definiert, was hier zu einer Fehlermeldung f¨uhrt. 1
Sandini Bib
6.1 Dynamische Komponenten
373
Reference-Counting Eine Optimierungsm¨oglichkeit, die auch verdeutlicht, welche m¨achtige M¨oglichkeiten sich durch die Programmierung von Klassen in C++ er¨offnen, ist die Verwendung einer Technik namens ¨ Reference-Counting. Diese Technik basiert auf der Uberlegung, dass das Kopieren und Zuweisen von Strings teuer ist, sehr h¨aufig vorkommt und dass Strings relativ selten manipuliert werden (also einzelne Zeichen darin nur selten ver¨andert werden). Der Trick besteht darin, dass ein String-Objekt selbst nur ein einfaches Handle ist, das auf das eigentliche String-Objekt (den so genannten Body) verweist. Die eigentlichen Daten eines Strings mit der eigentlichen Zeichenfolge werden also in eine Hilfsklasse ausgelagert:
class StringBody { private: char* buffer; unsigned len; unsigned size; unsigned refs;
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer // Anzahl Strings, die diesen Body verwenden
...
}; Die Objekte der Hilfsklasse StringBody verwalten die eigentlichen Zeichenfolgen und k¨onnen von mehreren Strings, die alle den gleichen Wert haben, gemeinsam verwendet werden. Die Komponente references h¨alt jeweils fest, wie viele Strings ein solches Objekt verwenden. Mit jeder Initialisierung wird ein Body-Objekt mit den eigentlichen Daten und ein Handle erzeugt. Im Body wird eingetragen, dass es genau einen Besitzer gibt (refs wird auf 1 gesetzt). Das Handle selbst ist ein ganz einfaches Objekt, das einfach nur auf den Body verweist:
class String { private: StringBody* body; // Zeiger auf den eigentlichen String ...
}; Wenn ein String nun kopiert wird, wird nur das Handle kopiert, und im Body wird eingetragen, dass nun ein Handle mehr auf ihn verweist. Zwei Strings verwenden dann also intern dieselbe Zeichenfolge. Wenn ein String zerst¨ort wird, wird in seinem Body die Anzahl der Strings, die auf ihn verweisen, entsprechend dekrementiert. Ist die Anzahl damit 0, wird auch das Body-Objekt zerst¨ort. Jeder lesende Zugriff auf einen String (etwa die Abfrage seiner L¨ange) wird einfach an den Body weitergereicht. Nur wenn ein String ver¨andert wird, dessen Body mehrfach verwendet wird, wird eine echte Kopie des Bodys angelegt und dem ge¨anderten String zugeordnet. Da das Durchreichen von Strings (Kopieren, Zuweisen) typischerweise weitaus h¨aufiger ge¨ schieht als das Andern einzelner Zeichen, werden mit diesem Mechanismus Speicherplatz-Funktionen vermieden, die vergleichsweise viel Zeit kosten. Andere typische Optimierungen sind spezielle Funktionalit¨aten f¨ur Teilstrings, die ebenfalls u¨ ber interne Hilfsklassen verwaltet werden.
Sandini Bib
374
Kapitel 6: Dynamische und statische Komponenten
Reference-Counting kann man auch mit Hilfe von speziellen Smart-Pointern implementieren. Darauf wird in Abschnitt 9.2.1 eingegangen. Doch kein Reference-Counting Nach der Erl¨auterung der Optimierungsm¨oglichkeit mit Reference-Counting mag man erwarten, dass nun jede Implementierung der Klasse std::string auf diese Weise optimiert ist. Doch weit gefehlt. Es hat sich in j¨ungster Zeit gezeigt, dass derartige Optimierungen bei String-Klassen in Multithreading-Programmen kontraproduktiv sein k¨onnen. Der Preis der erh¨ohten Komplexit¨at und der Locking-Verwaltung ist oft h¨oher als der Gewinn durch das Vermeiden des Kopierens. Aus diesem Grund haben nach und nach alle String-Implementierungen eine derartige Optimierung wieder entfernt. Sie w¨urde nur dann Sinn machen, wenn sichergestellt ist, dass Strings zu ihren Lebzeiten nicht ver¨andert werden.2 Stattdessen gibt es heutzutage andersartige Optimierungen, die vor allem auf die Tatsache abzielen, dass die meisten Strings nur aus sehr wenigen Zeichen bestehen. Das Gute an C++ ist, dass man von derartigen Erkenntnissen und Verbesserungen bei der Verwendung der StringKlassen automatisch profitiert. Das Interface a¨ ndert sich dadurch nicht.
6.1.9
Weitere Anwendungsm¨oglichkeiten dynamischer Komponenten
Der Begriff dynamische Komponente kann weit mehr bedeuten als nur eine Komponente, die mit new und delete verwaltet wird. Eine der faszinierendsten Eigenschaften von C++ ist, dass beliebig komplizierte Anweisungen aufgerufen werden k¨onnen, um ein Objekt in seinen Startzustand zu bringen. So kann z.B. eine Dateiverwaltung komplett in einem abstrakten Datentyp versteckt werden. Der Konstruktor o¨ ffnet die als Argument u¨ bergebene Datei, und der Destruktor schließt sie wieder:
class Datei { private: FILE* fp; public: Datei (char*); ~Datei ();
// Zeiger auf ge¨offnete Datei
// char*-Konstruktor f¨ur den Dateinamen // Destruktor
...
};
2 Hier zeigt sich der Vorteil des String-Ansatzes von Java, bei dem zwischen einer Klasse f¨ur Strings, bei ¨ denen keine einzelnen Zeichen ge¨andert werden k¨onnen, und Strings, bei denen jede Anderung m¨oglich ist, unterschieden wird. Daf¨ur kann man dort aber Strings nicht einfach mit dem Operator == vergleichen. (Ich glaube, ich muss doch noch meine eigene String-Klasse oder Programmiersprache schreiben.)
Sandini Bib
6.1 Dynamische Komponenten
375
Datei::Datei (char* dateiname) { // Datei o¨ ffnen
fp = fopen(dateiname,...); ...
} Datei::~Datei () { // Datei schließen
}
fclose(fp);
Im Anwendungsprogramm wird die Datei dann automatisch mit der Deklaration eines Objekts ge¨offnet und beim Verlassen des Blocks automatisch wieder geschlossen:
void f () { Datei d("testprog.cc"); // Konstruktor o¨ ffnet die Datei ...
} // Destruktor am Blockende schließt die Datei automatisch Die in der Standard-I/O-Bibliothek definierten Stream-Klassen zum Zugriff auf Dateien arbeiten ¨ nach diesem Prinzip (sie rufen dazu allerdings Low-level-Funktionen zum Offnen und Schließen von Dateien auf). Diese Klassen werden in Abschnitt 8.2 vorgestellt. Auf entsprechende Art und Weise k¨onnen Datenbankzugriffe, Pipes, Prozesse und andere beliebig komplexe Objekte oder Vorg¨ange so abstrahiert werden, dass ihre Anwendung erheblich vereinfacht wird. Im Gegensatz zu Java gibt es hier auch den Vorteil, dass die Destruktoren nicht irgendwann, sondern beim Verlassen des G¨ultigkeitsbereichs aufgerufen werden. Dadurch k¨onnen auch abzuschließende Vorg¨ange als Objekte programmiert werden. Ein typisches Beispiel w¨are eine Klasse die laufende Transaktionen repr¨asentiert. In C++ k¨onnte man sie wie folgt anwenden:
void foo() { Transaktion t; ...
}
// Ausnahme l¨ost t.cancel() aus
t.commit();
Der Clou an dieser Verwendung besteht darin, dass man im Falle eines Fehlers und vorzeitigen Abbruchs (etwa durch eine Ausnahme) die Transaktion nicht explizit mit cancel() abbrechen muss. Dies macht automatisch der Destruktor von t. Bei der Implementierung solcher Klassen muss man allerdings mit gewisser Vorsicht vorgehen. Speziell die Implementierung von Copy-Konstruktor und Zuweisungsoperator muss sehr
Sandini Bib
376
Kapitel 6: Dynamische und statische Komponenten
gut u¨ berlegt werden. (Was soll beim Kopieren einer Datei oder einer Transaktion passieren?) Auf jeden Fall darf man sich hier nicht auf die Default-Implementierung verlassen. Diese arbeitet in der Regel nicht korrekt. Als Richtlinie gilt:
Eine Klasse braucht einen Copy-Konstruktor, einen Zuweisungsoperator und einen Destruktor oder nichts davon.
Das heißt: Braucht man f¨ur eine Klasse eine eigene Implementierung von einem Copy-Konstruktor, einem Zuweisungsoperator oder einem Destruktor, dann braucht man in der Regel eine eigene Implementierung f¨ur alle diese Operationen. Ist nicht klar, ob man einen Copy-Konstruktor oder Zuweisungsoperator braucht, kann man das Kopieren und Zuweisen auch einfach verbieten. Dazu m¨ussen der Copy-Konstruktor bzw. der Zuweisungsoperator einfach als private deklariert werden. Darauf wird in Abschnitt 6.2.5 eingegangen.
6.1.10
Zusammenfassung
Klassen k¨onnen dynamische Komponenten besitzen. Das sind Komponenten, die nicht einfach durch Zuweisung kopiert werden k¨onnen (typischerweise Zeiger). Klassen mit dynamischen Komponenten brauchen eine eigene Implementierung des CopyKonstruktors, des Zuweisungsoperators und eines Destruktors. Ein Destruktor ist eine Funktion, die bei der Zerst¨orung des Objekts aufgerufen wird. Ein Zuweisungsoperator sollte mit einem Test beginnen, der feststellt, ob ein Objekt sich selbst zugewiesen wird. Ein Zuweisungsoperator sollte das Objekt zur¨uckliefern, dem etwas zugewiesen wurde (also *this). Durch dynamische Komponenten k¨onnen beliebig komplexe Vorg¨ange so abstrahiert werden, dass sie u¨ ber ein Objekt einfach zu handhaben sind. Das Kopieren und Zuweisungen k¨onnen verboten werden. Eine Klasse braucht in der Regel einen Copy-Konstruktor, einen Zuweisungsoperator und einen Destruktor oder nichts davon.
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
377
6.2 Weitere Aspekte dynamischer Komponenten Klassen mit dynamischen Komponenten zeigen eine wesentliche St¨arke des Konzepts der objektorientierten Programmierung auf: Komplizierte dynamische Vorg¨ange k¨onnen in das Verhalten einer Klasse verlagert werden, so dass das Anwendungsprogramm sich nur noch auf das Wesentliche konzentrieren kann. Beim Implementieren derartiger Klassen lauern allerdings auch Gefahren. So kann man z.B. ungewollt die Datenkapselung einer Klasse aufheben. Derartige Aspekte der Implementierung eigener Klassen werden in diesem Abschnitt angesprochen.
6.2.1
Dynamische Komponenten bei konstanten Objekten
Die im vorigen Abschnitt vorgestellte Klasse Bsp::String wird in diesem Abschnitt um einige sinnvolle Operationen erweitert. Dabei wird ein Problem angesprochen, das f¨ur die Implementierung von Klassen mit dynamischen Komponenten von großer Wichtigkeit ist: der Umgang mit konstanten Objekten, die dynamische Komponenten besitzen. Es ist besondere Sorgfalt notwendig, um die Konstantheit der Objekte nicht zu unterlaufen. Strings mit dem Operator [ ] Da Strings als Feld (Array) von Zeichen betrachtet werden, ist es u¨ blich, auch f¨ur String-Klassen mit Hilfe des Indexoperators den Zugriff auf einzelne Zeichen zu erm¨oglichen. Eine Tatsache ist dabei allerdings bemerkenswert: Bei nichtkonstanten Strings bietet der Operator Zugriff auf ein Zeichen im Objekt, wodurch es sogar ver¨andert werden kann. Nach den Anweisungen
Bsp::String s = "Tasse"; s[0] = 'K'; sollte in s der String "Kasse" stehen. Dazu muss der im Ausdruck s[0] aufgerufene Operator [ ] einen internen Teil des Strings zur¨uckliefern, so dass dieser manipuliert werden kann. Dies ist nur m¨oglich, wenn der Operator [ ] nicht eine Kopie, sondern Zugriff auf das Originalzeichen des Strings liefert, damit es manipuliert werden kann. Das bedeutet, dass die Operatorfunktion eine Referenz zur¨uckliefern muss. Der Operator muss deshalb wie folgt deklariert werden:
namespace Bsp { class String { private: char* buffer; unsigned len; unsigned size;
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
...
public: char& operator [] (unsigned); // Zugriff auf ein Zeichen ...
}
};
Sandini Bib
378
Kapitel 6: Dynamische und statische Komponenten
Die Implementierung k¨onnte dann wie folgt aussehen:
// dyna/stridxvar.cpp /* Operator [] f¨ur Variablen */ char& String::operator [] (unsigned idx) { // Index nicht im erlaubten Bereich ?
if (idx >= len) { throw std::out_of_range("string index out of range"); } }
return buffer[idx];
Nach dem Test, ob der u¨ bergebene Index u¨ berhaupt im erlaubten Bereich liegt, wird das Zeichen mit dem Index idx der internen Komponente buffer als Referenz zur¨uckgeliefert. Das zur¨uckgelieferte Zeichen der Funktion ist also keine Kopie, sondern das Originalzeichen des Strings und kann damit auch manipuliert werden. Der Operator [ ] fur ¨ konstante Strings Die soeben vorgestellte Implementierung des Operators [ ] f¨ur Strings ist allerdings f¨ur konstante Strings nicht geeignet, da der Operator damit eine Manipulation am String erm¨oglichen w¨urde. Es w¨are aber dennoch m¨oglich, sie als Konstanten-Elementfunktion zu deklarieren und damit auch f¨ur Konstanten den Aufruf zu erm¨oglichen:
namespace Bsp { class String { ...
public: char& operator [] (unsigned) const; ...
}
};
Dies f¨uhrt aber dazu, dass eine Konstante manipuliert werden kann:
const String s = "Tasse"; s[0] = 'K';
// wird nicht als Fehler erkannt
Nun k¨onnte der Einwand kommen, dass doch eigentlich schon der Compiler erkennen kann, dass auf ein konstantes Objekt zugegriffen wird und somit eine Manipulation u¨ ber den Operator [ ] sowieso nicht m¨oglich ist. Dies ist aber falsch! Das Problem besteht darin, dass das eigentliche Objekt gar nicht ver¨andert wird. Seine Komponente buffer ist ja nur der Zeiger auf die Zei-
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
379
chenkette, und der bleibt konstant. Das, worauf der Zeiger zeigt, ist aber nicht konstant. Wenn der Operator [ ] in seiner bisherigen Implementierung auch f¨ur konstante Objekte erlaubt w¨urde, k¨onnten diese deshalb den Inhalt des Strings ver¨andern. Der Operator sollte aber f¨ur konstante Objekte definiert sein. Es w¨are recht uneinsichtig, wenn es nur bei variablen Strings m¨oglich w¨are, ein Zeichen u¨ ber den Operator [ ] abzufragen. Das Folgende sollte also m¨oglich sein:
const Bsp::String s = "Tasse"; ...
char c = s[0]; Das bedeutet, dass eigentlich zwei Implementierungen des Operators [ ] ben¨otigt werden: eine f¨ur konstante und eine f¨ur variable Strings. Eine solche unterschiedliche Implementierung ist m¨oglich. Eine Funktion kann sowohl f¨ur variable als auch f¨ur konstante Objekte u¨ berladen werden (nur wenn es keine eigene Funktion f¨ur variable Objekte gibt, wird die Funktion f¨ur konstante Objekte verwendet). Aus diesem Grund sollte die Klasse Bsp::String eine zweite Deklaration f¨ur den Operator [ ] erhalten:
namespace Bsp { class String { private: char* buffer; unsigned len; unsigned size;
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
...
public:
// Operator [] f¨ur Variablen
char& operator [] (unsigned); // Operator [] f¨ur Konstanten
char operator [] (unsigned) const; ...
}
};
Die Anweisungen im Funktionsk¨orper f¨ur konstante Strings unterscheiden sich nicht von der Version f¨ur variable Strings:
// dyna/stridxconst.cpp /* Operator [] f¨ur Konstanten */ char String::operator [] (unsigned idx) const { // Index nicht im erlaubten Bereich ?
Sandini Bib
380
Kapitel 6: Dynamische und statische Komponenten
if (idx >= len) { throw std::out_of_range("string index out of range"); } }
return buffer[idx];
Entscheidend ist nur, dass das Zeichen nicht mehr als Referenz, sondern als Kopie zur¨uckgeliefert wird. Handelt es sich bei dem R¨uckgabewert um ein gr¨oßeres Objekt, w¨are aus Zeitgr¨unden eine Referenz vorzuziehen, die dann aber nat¨urlich als konstant deklariert werden sollte:
namespace Bsp { class String { ...
public:
const char& operator [] (unsigned) const; ...
}
6.2.2
};
Konvertierungsfunktionen fur ¨ dynamische Komponenten
Eine a¨ hnliche Sorgfalt verlangt die Implementierung von Konvertierungsfunktionen. Dabei spielt es keine Rolle, ob es sich um Funktionen zur automatischen oder zur expliziten Typumwandlung handelt. Die Klasse String liefert wieder ein sch¨ones Beispiel. Es ist sicherlich sinnvoll, wenn man aus einem Objekt der Klasse String wieder einen C-String machen kann, da zahlreiche Funktionen einen C-String als Parameter verlangen. ¨ Aufgrund der Uberlegungen aus Abschnitt 4.6.5, dass Funktionen zur automatischen Typumwandlung vermieden werden sollten, wird das Problem anhand einer Funktion zur expliziten Typumwandlung betrachtet. Auch hier muss darauf geachtet werden, dass u¨ ber die Konvertierungsfunktion kein Zugriff auf ein internes Objekt entsteht. Dies sollte sowohl f¨ur konstante als auch f¨ur variable Objekte gelten, da sonst beliebige Manipulationen am String-Objekt m¨oglich werden. Die Schnittstelle der Funktionen, die den Zugriff auf das Objekt kontrollieren und verifizieren, w¨urde aufgehoben, und es k¨onnten Inkonsistenzen entstehen. Der Zugriff auf die internen Elemente des Strings kann auf zweierlei Arten vermieden werden:
Es kann eine Kopie verwendet werden. Es kann eine Konstante verwendet werden.
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
381
Kopien als Ruckgabewerte ¨ Wenn eine Kopie zur¨uckgeliefert wird, sehen die Deklaration und die Implementierung z.B. wie folgt aus:
class String private: char* unsigned unsigned
{ buffer; len; size;
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
...
};
public: char* toCharPtr() const;
char* String::toCharPtr() const { // Speicherplatz f¨ur Kopie anlegen
char* p = new char[len+1]; // Zeichen kopieren
std::memcpy (p, buffer, len); // Stringendekennzeichen anh¨angen
p[len] = '\0'; // und zur¨uckliefern
}
return p;
Diese L¨osung hat allerdings mehrere Nachteile:
Zum einen kostet das explizite Anlegen von Speicherplatz Zeit. Zum anderen muss, da extra Speicherplatz angelegt wird, das Anwendungsprogramm darauf achten, diesen auch wieder freizugeben. Eine Forderung, die fr¨uher oder sp¨ater sicherlich zu einem Speicherplatzproblem f¨uhren wird. Falls eine solche Implementierung zur Konvertierung verwendet wird, sollte diese deshalb zumindest im Namen den Hinweis enthalten, dass Speicherplatz angelegt wird, der freizugeben ist (etwa durch den Namen asNewCharPtr()).
Konstanten als Ruckgabewerte ¨ Besser ist es daher meistens, die interne Zeichenfolge zur¨uckzuliefern und den R¨uckgabewert so zu deklarieren, dass die Daten nicht ver¨andert werden k¨onnen:
Sandini Bib
382
Kapitel 6: Dynamische und statische Komponenten
class String private: char* unsigned unsigned
{ buffer; len; size;
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
...
};
public: const char* toCharPtr() const;
const char* String::toCharPtr() const { // Zeichenfolge zur¨uckliefern
}
return buffer;
In diesem Fall wird einfach die Zeichenfolge des Strings als konstante Zeichenfolge zur¨uckgeliefert. Auch hier kann es allerdings Probleme geben:
Zum einen wird eine Adresse zur¨uckgeliefert, die ung¨ultig werden kann. Da das StringObjekt bei einer Wert¨anderung eventuell einen neuen Speicherplatz f¨ur die Zeichenfolge erh¨alt, besteht die Gefahr, dass die hier zur¨uckgelieferten Zeichen dann nicht mehr definiert sind (ein Problem, das beim Anlegen einer Kopie nicht entsteht). Man kann nicht einfach ein Stringendekennzeichen anh¨angen.
Die Standard-String-Klasse std::string bietet zwei derartige Funktionen:
c_str() liefert die eigentliche Zeichenfolge als C-String (also mit angeh¨angtem Stringendekennzeichen '\0', siehe Seite 72). data() liefert die interne Zeichenfolge so, wie sie ist.
Dabei verwenden Implementierungen der Klasse typischerweise folgenden Trick: Intern wird einfach immer automatisch ein Stringendekennzeichen angeh¨angt. Dazu wird immer f¨ur mindestens ein Zeichen mehr Speicherplatz reserviert. Sowohl c_str() als auch data() liefern dann einfach diesen internen Puffer.3 Dieser R¨uckgabewert ist dann nat¨urlich nur bis zur n¨achsten Operation definiert, die den String manipulieren oder ung¨ultig machen k¨onnte. Man muss sich deshalb immer sofort eine Kopie anlegen.
6.2.3
Konvertierungsfunktionen fur ¨ Bedingungen
Bei Klassen mit dynamischen Komponenten verwendet man oft die automatische Typumwandlung zum Testen von Bedingungen. In Anlehnung an traditionellen C-Code wie Vorsicht, das bedeutet nicht, dass man davon ausgehen kann, dass auch data() mit dem Stringendekennzeichen endet. Es ist nur bei den meisten Implementierungen so. Wer ein Stringendekennzeichen braucht, sollte also immer c_str() aufrufen.
3
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
FILE* fp;
383
// Zeiger auf die ge¨offnete Datei
fp = fopen ("hallo", "r"); if (fp) { // Daten lesen ...
} else {
// FEHLER: fp ist NULL ...
} wird z.B. bei einer Klasse f¨ur ge¨offnete Dateien Entsprechendes erm¨oglicht:
Bsp::Datei f("hallo");
// Konstruktor o¨ ffnet die Datei (hoffentlich)
if (f) { // Daten lesen ...
} else {
// Fehler: f ist nicht in Ordnung ...
} Diese Technik basiert in C auf dem Sachverhalt, dass NULL bei Zeigern oft einen Fehlerstatus anzeigt. NULL ist aber nichts anderes als der Wert 0, welcher wiederum f¨ur false steht. Der Test
if (fp) ist also eigentlich eine Abk¨urzung f¨ur:
if (fp != NULL) Man kann hierbei streiten, ob dadurch die Lesbarkeit der Programme erh¨oht wird, da diese Schreibweise dazu f¨uhrt, dass in Bedingungen nicht mehr nur Boolesche Ausdr¨ucke oder Werte stehen k¨onnen. ¨ Die Ubertragung auf C++ kann man nun unter dem gleichen Blickwinkel betrachten. Extra daf¨ur wurde definiert, dass Bedingungen in Kontrollstrukturen auch dann m¨oglich sind, wenn f¨ur ein Objekt einer Klasse eine automatische Typumwandlung in einen ganzzahligen Typ oder einen Zeigertyp definiert ist. Besitzt der ganzzahlige Typ oder der Zeigertyp den Wert 0, ist die Bedingung nicht erf¨ullt. Um also die obige If-Abfrage zu erm¨oglichen, muss z.B. eine Typumwandlung in einen Zeigertyp implementiert werden. Dies k¨onnte bei einer Klasse f¨ur ge¨offnete Dateien z.B. wie folgt aussehen:
Sandini Bib
384
Kapitel 6: Dynamische und statische Komponenten
namespace Bsp { class Datei { private: FILE* fp;
// Zeiger auf die ge¨offnete Datei
public: ...
}
};
operator FILE* () { // automatische Umwandlung in File* return fp; }
Nach der Deklaration
Bsp::Datei f("hallo"); entspricht der Aufruf
if (f) damit:
if ((f.operator FILE*()) != NULL) Diese Umsetzung birgt aber die Gefahr, die ganzen Vorteile einer sicheren Schnittstelle zu verlieren. Mit einer so definierten Typkonvertierung besteht die M¨oglichkeit, Zugriff auf eine private Komponente zu erhalten und diese zu modifizieren. Ohne Fehlermeldung w¨are z.B. Folgendes m¨oglich:
Bsp::Datei f; FILE* fp; ...
fp = f; Eine erste Verbesserung w¨are deshalb die Umwandlung des Zeigers in den Datentyp void*:
namespace Bsp { class Datei { private: FILE* fp;
// Zeiger auf die ge¨offnete Datei
public: ...
}
};
operator void* () { return (void*)fp; }
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
385
Doch auch damit wird eine interne Komponente nach außen gegeben. Noch besser ist es daher sicherzustellen, dass keine interne Adresse nach außen gegeben wird:
namespace Bsp { class Datei { private: FILE* fp;
// Zeiger auf die ge¨offnete Datei
public: ...
}
};
operator void* () { return fp != NULL ? reinterpret_cast(32) : static_cast(0); }
In diesem Fall werden die Werte 32 bzw. 0 mit Hilfe von Operatoren zur Typumwandlung in Adressen umgewandelt. W¨ahrend die Verwendung von 0 als Adresse m¨oglich ist und somit f¨ur die Umwandlung die Verwendung von static_cast ausreicht, muss 1 mit dem heftigsten“ al” ler Typumwandlungsoperatoren, dem Operator reinterpret_cast, umgewandelt werden. Dies ist zwar kein sehr guter Programmierstil, aber er funktioniert. Das Beste ist, man vermeidet Funktionen zur automatischen Typumwandlung. Eine Elementfunktion zur expliziten Typumwandlung wie istOK() funktioniert ebenfalls und f¨uhrt zudem zu lesbarerem Anwendungscode:
if (f.istOK()) Die Standardklassen f¨ur I/O verwenden allerdings die gerade vorgestellte Technik mit der automatischen Typumwandlung. Dabei wird auch der Operator ! u¨ berladen, um so etwas wie
if (! f) zu erm¨oglichen. In Abschnitt 4.5.2 wurde bereits genauer darauf eingegangen.
6.2.4
Konstanten werden zu Variablen
Bei komplexen Klassen lohnt es sich mitunter, nicht jede Komponente, die von Interesse sein k¨onnte, bei der Initialisierung des Objekts zu berechnen. Erst wenn die Information auch wirklich zum ersten Mal gebraucht wird, kann eine entsprechende Berechnung durchgef¨uhrt werden. Eine derartige Programmiertechnik bezeichnet man als sp¨ate Auswertung (lazy evaluation). Ein Beispiel liefert wieder die Klasse f¨ur ge¨offnete Dateien: Um festzustellen, wie viele Zeilen eine Datei besitzt, muss die ganze Datei durchlaufen und die Anzahl der Zeilentrenner aufsummiert werden. Dies kostet bei großen Dateien nicht unerheblich Zeit. Aus diesem Grund bietet es sich an, die Anzahl der Zeilen zwar in einer internen Komponente festzuhalten, sie aber erst dann zu ermitteln, wenn sie zum ersten Mal gebraucht wird:
Sandini Bib
386
Kapitel 6: Dynamische und statische Komponenten
namespace Bsp { class Datei { private: FILE* fp; int zeilen;
// Zeiger auf die ge¨offnete Datei // Zeilenanzahl (-1: noch nicht bekannt)
public: // Konstruktor Datei (...) : zeilen(-1) { ...
// Zeilenanzahl zun¨achst nicht bekannt
} int zeilenanzahl ();
// liefert die Zeilenanzahl
...
}
};
...
int Datei::zeilenanzahl () { // bei der ersten Nachfrage die Zeilenanzahl ermitteln
}
if (zeilen == -1) { zeilen = zeilenanzahlErmitteln(); } return zeilen;
Hier entsteht allerdings ein Problem: Die Zeilenanzahl kann nicht f¨ur konstante Objekte der Klasse ermittelt werden:4
const Bsp::Datei f("prog.dat"); std::cout << f.zeilenanzahl();
// FEHLER: keine Konstanten-Elementfunktion
Das Problem ist, dass zeilenanzahl() keine Konstanten-Elementfunktion sein kann, da das Objekt intern durch den Aufruf manipuliert wird. Eigentlich handelt es sich aber um eine Funktion f¨ur ein logisch konstantes Objekt, denn das, was es repr¨asentiert, wird durch den Funktionsaufruf ja nicht ver¨andert. F¨ur solche F¨alle wurde das Schl¨usselwort mutable eingef¨uhrt. Immer wenn man eine Komponente hat, die f¨ur die logische Konstantheit eines Objekts keine Rolle spielt, kann man mit mutable daf¨ur sorgen, dass diese auch von Konstanten-Elementfunktionen ver¨andert werden darf. 4
Dieses Beispiel geht davon aus, dass die Konstantheit einer Datei bestimmt, ob schreibend zugegriffen werden darf. Die Standarddatentypen f¨ur den Dateizugriff bieten verschiedene Datentypen f¨ur lesenden und schreibenden Zugriff.
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
387
Damit k¨onnte die Klasse Datei wie folgt deklariert werden:
namespace Bsp { class Datei { private: // Zeiger auf die ge¨offnete Datei FILE* fp; mutable int zeilen; // Zeilenanzahl (-1: noch nicht bekannt) // - neu: auch f¨ur Konstanten intern a¨ nderbar
public: // Konstruktor Datei (...) : zeilen (-1) { ...
// Zeilenanzahl zun¨achst nicht bekannt
} // Elementfunktion f¨ur Konstanten int zeilenanzahl () const; ...
}
};
...
int Datei::zeilenanzahl () const { // bei der ersten Nachfrage Zeilenanzahl ermitteln
}
if (zeilen == -1) { // OK: zeilen auch f¨ur konstante Objekte a¨ nderbar zeilen = zeilenanzahlErmitteln(); } return zeilen;
Nun ist die Ermittlung der Zeilenanzahl auch bei als konstant deklarierten Dateien m¨oglich:
const Bsp::Datei f("prog.dat"); std::cout << f.zeilenanzahl();
// OK
Es g¨abe auch noch andere M¨oglichkeiten, die Konstantheit von Variablen zu entfernen. Doch handelt es sich dabei um eine explizite Umwandlung des Datentyps (siehe Seite 72). Insofern ist diese L¨osung sauberer. mutable braucht man nicht nur f¨ur eine sp¨ate Auswertung. Wann immer trotz Manipulation eine logische Konstantheit besteht, ist mutable angemessen. Ein anderes Beispiel w¨are eine Funktion, die w¨ahrend der Durchf¨uhrung lokal in einem Objekt festh¨alt, dass sich das Objekt gerade in einer Operation befindet, die das Objekt allerdings nicht ver¨andert. Auf diese Weise kann man rekursive Aufrufe bei lesenden Zugriffen erkennen.
Sandini Bib
388
Kapitel 6: Dynamische und statische Komponenten
6.2.5
Vordefinierte Funktionen verbieten
Die Klasse f¨ur ge¨offnete Dateien ist ein gutes Beispiel f¨ur einen weiteren wichtigen Punkt, der bei Klassen mit dynamischen Komponenten immer beachtet werden sollte. Wie im vorherigen Abschnitt erl¨autert wurde, sind die Operatoren, die als Default f¨ur jede Klasse vorhanden sind, f¨ur Klassen mit dynamischen Komponenten ungeeignet. So m¨ussen in der Regel ein eigener Copy-Konstruktor und ein eigener Zuweisungsoperator implementiert werden. Wenn nun das Anlegen einer Kopie oder eine Zuweisung nicht sinnvoll ist, sollte nicht einfach auf die Implementierung der Operatoren verzichtet werden, da dann die Gefahr besteht, dass sie trotzdem verwendet werden. Besser ist es, die Operationen zu verbieten. Dies geschieht recht einfach, indem sie als private deklariert werden. F¨ur die Klasse von ge¨offneten Dateien k¨onnte das Kopieren und Zuweisen auf folgende Weise unm¨oglich gemacht werden:
namespace Bsp { class Datei { private: FILE* fp;
// Zeiger auf die ge¨offnete Datei
...
public: ...
}
};
private: Datei (const Datei&); Datei& operator= (const Datei&);
Es reicht wirklich die Deklaration dieser Standardoperationen als private; eine Implementierung ist nicht notwendig. Eine Parameter¨ubergabe ist dann nat¨urlich nur noch mit Hilfe von Referenzen m¨oglich:
void mitKopie (Bsp::Datei); void ohneKopie (const Bsp::Datei&); ...
void f() { Bsp::Datei f("prog.dat"); Bsp::Datei g("prog.old");
}
ohneKopie(f); mitKopie(f); g = f;
// OK // FEHLER: Kopie anlegen nicht erlaubt // FEHLER: Zuweisungen nicht erlaubt
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
6.2.6
389
Proxy-Klassen
Die meisten Probleme, die in diesem Abschnitt erl¨autert wurden, haben damit zu tun, dass man die Kontrolle u¨ ber die Operationen verliert, die f¨ur ein Objekt aufgerufen werden. Liefert man einen Zeiger auf interne Daten, kann dieser zweckentfremdet werden. Liefert man u¨ ber den Indexoperator eine Referenz auf ein Zeichen, hat man keine Kontrolle mehr, was mit dem Zeichen im String passiert. So kann man Zeichen in einem String z.B. auch wie folgt manipulieren:
Bsp::String s("hallo"); ++s[2];
// drittes Zeichen inkrementieren
Mit Hilfe einer Proxy-Klasse kann man in all diesen F¨allen die Kontrolle behalten. Als Proxy wird eine Kapsel bezeichnet, die die Kontrolle u¨ ber etwas erm¨oglicht, u¨ ber das man normalerweise keine Kontrolle hat, und dabei die Schnittstelle nicht ver¨andert. In der String-Klasse kann man den Indexoperator z.B. auch so implementieren, dass er einen eigenen Datentyp zur¨uckliefert, der sich soweit sinnvoll wie ein char verh¨alt, dennoch aber nicht alle Operationen zul¨asst. Dies sieht wie folgt aus:
class String { public: /* Proxy-Klasse f¨ur Zugriff auf einzelne Zeichen */ class reference { // String hat Zugriff auf private Komponenten friend class String; private: // interner Verweis auf ein Zeichen im String char& ch; // Konstruktor (nur f¨ur Klasse String aufrufbar) reference(char& c) : ch(c) { // Verweis anlegen }
reference(const reference&); public:
// Kopieren verboten
// Zuweisungen von char und von anderer Referenz sind OK
reference& operator= (char c) { ch = c; return *this; } reference& operator= (const reference& r) { ch = r.ch; return *this; }
Sandini Bib
390
Kapitel 6: Dynamische und statische Komponenten // Verwendung als char legt Kopie an
};
operator char() { return ch; }
public: // Zugriff auf ein Zeichen im String
reference operator [] (unsigned); char operator [] (unsigned) const; ...
}; Innerhalb der Klasse String wird die eingebettete Klasse reference definiert. Diese repr¨asentiert Referenzen auf ein Zeichen in einem String. Ein derartiges Objekt wird von dem Indexoperator f¨ur Variablen zur¨uckgeliefert:
String::reference String::operator [] (unsigned idx) { // Index nicht im erlaubten Bereich ?
if (idx >= len) { throw std::out_of_range("string index out of range"); } }
return reference(buffer[idx]);
Bei der Initialisierung des R¨uckgabewerts wird dazu das Zeichen im String u¨ bergeben, auf das verwiesen werden soll. Die Referenz-Komponente ch wird damit initialisiert. Auf diese Weise, besteht auch weiterhin Kontrolle u¨ ber die Operationen, die mit dem R¨uckgabewert des Indexoperators durchgef¨uhrt werden. Die Verwendung dieser Referenz zeigt folgendes Beispiel:
// dyna/stringtest2.cpp // C++-Headerdatei f¨ur I/O #include #include "string.hpp" // C++-Headerdatei f¨ur Strings
int main () { typedef Bsp::String string; // zwei Strings anlegen
string vorname = "Jicolai"; string nachname = "Nosuttis";
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
391
string name; // die ersten Zeichen der Strings vertauschen
char c = vorname[0]; vorname[0] = nachname[0]; nachname[0] = c; }
std::cout << vorname << ' ' << nachname << std::endl;
In der Anweisung
char c = vorname[0]; liefert der Ausdruck vorname[0] eine reference, die mit der Konvertierungsfunktion operator char () (siehe Seite 234) automatisch in ein char umgewandelt werden kann. Bei
vorname[0] = nachname[0]; liefern beide Seiten der Zuweisung eine Referenz. Der Zuweisungsoperator l¨asst dann eine entsprechende Zuweisung zu. Bei
nachname[0] = c; wird die zweite Form des Zuweisungsoperators verwendet, die es erlaubt, einer String-Referenz ein char zuzuweisen. Das Kopieren und jede weitere Operation mit dem R¨uckgabewert des Indexoperators ist nicht erlaubt:
++vorname[0];
// FEHLER, ++ f¨ur Referenzen nicht definiert
Nach diesem Muster kann man jederzeit auch geschachtelte Ausdr¨ucke unter Kontrolle halten. Man muss nur die F¨ahigkeiten von R¨uckgabewerten kontrollieren, indem man als R¨uckgabetyp immer eine selbst definierte Klasse verwendet.
6.2.7
Ausnahmebehandlung mit Parametern
Wenn innerhalb einer Klasse eine Ausnahme ausgel¨ost wird, ist es oft sinnvoll, einem Fehlerobjekt als Parameter Daten u¨ ber diesen Fehler mitzugeben. Beim Indexoperator von Strings ist es sicherlich sinnvoll den fehlerhaften Index und auch den String, f¨ur den der Index ung¨ultig war, zu u¨ bergeben. Damit ist es sehr viel einfacher, eine Ausnahme sinnvoll auszuwerten. ¨ Ubergabe des fehlerhaften Index als Parameter Die Klasse String (sie wird in Abschnitt 6.1 eingef¨uhrt und in Abschnitt 6.2.1 um den Operator [ ] erweitert) k¨onnte dazu eine eine spezielle Fehlerklasse definieren, in der die Fehlerobjekte den fehlerhaften Index als Komponente besitzen:
Sandini Bib
392
Kapitel 6: Dynamische und statische Komponenten
// dyna/string3.hpp namespace Bsp { class String { public: // Fehlerklasse:
class RangeError { public: // fehlerhafter Index int index; // Konstruktor (initialisiert index)
};
RangeError (int i) : index(i) { }
... // Operator [] f¨ur Variablen und Konstanten
}
char& operator [] (unsigned); const char operator [] (unsigned) const;
};
Der Konstruktor der Klasse Bsp::String::RangeError stellt u¨ ber die angegebene Initialisierungsliste5 sicher, dass die Komponente index beim Erzeugen eines Fehlerobjekts mit dem als Parameter i u¨ bergebenen fehlerhaften Index initialisiert wird. In der Quelldatei der Klasse String muss bei einer Bereichs¨uberschreitung dann nur noch ein entsprechendes Objekt als Ausnahme erzeugt werden, dem der fehlerhafte Index u¨ bergeben wird:
// dyna/string3.cpp /* Operator [] f¨ur Variablen */ char& String::operator [] (unsigned i) { // Index nicht im erlaubten Bereich?
if (i >= len) { // Ausnahme mit fehlerhaftem Index ausl¨osen
} }
5
throw RangeError(i);
return cstring[i];
Initialisierungslisten werden in den Abschnitten 4.1.7 und 6.4.2 eingef¨uhrt.
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
393
/* Operator [] f¨ur Konstanten */ const char String::operator [] (unsigned i) const { // Index nicht im erlaubten Bereich?
if (i >= len) { // Ausnahme mit fehlerhaftem Index ausl¨osen
} }
throw RangeError(i);
return cstring[i];
Wenn nun der Fehler auftritt, kann das Anwendungsprogramm nicht nur feststellen, dass der Fehler aufgetreten ist, sondern auch den fehlerhaften Index auswerten. So w¨are es z.B. m¨oglich, den fehlerhaften Index in einer Fehlermeldung auszugeben:
// dyna/stringtest3.cpp int main() { try { ...
}
} catch (const String::RangeError& error) { // main() mit Fehlermeldung und Fehlerstatus beenden std::cerr << "FEHLER: fehlerhafter Index " << error.index << " bei Zugriff auf String" << std::endl; return EXIT_FAILURE; }
Wie zu sehen ist, muss f¨ur das Objekt im Catch-Block wie bei Funktionen ein Name deklariert werden, um auf die Komponenten zugreifen zu k¨onnen. Man beachte außerdem, dass im CatchBereich direkt auf die Komponente index im Fehlerobjekt zugegriffen wird. Aus diesem Grund wird sie als public deklariert. Es ist nicht notwendig, die Komponente private zu deklarieren und f¨ur den Zugriff eine Elementfunktion zu definieren, da das Fehlerobjekt nach der Auswertung ohnehin zerst¨ort wird. Informationen uber ¨ das Objekt, das einen Fehler ausl¨ost Will man als Parameter des Ausnahmeobjekts den String u¨ bergeben, der den Fehler ausgel¨ost hat, ergibt sich ein Problem: Dieser String kann nur kopiert werden, da es sich um ein lokales Objekt handeln kann, das im G¨ultigkeitsbereich einer Ausnahmebehandlung gar nicht mehr existiert. Man darf den String also nicht als Zeiger oder Referenz deklarieren, sondern muss in der Ausnahmeklasse eine ganz normale Komponente vom Typ String deklarieren:
Sandini Bib
394
Kapitel 6: Dynamische und statische Komponenten
// dyna/string4.hpp namespace Bsp { class String { public: // Fehlerklasse:
class RangeError { public: int index; String value;
// fehlerhafter Index // String dazu
// Konstruktor (initialisiert index)
};
RangeError (String s, int i) : value(s), index(i) { }
...
}
};
Im Indexoperator werden nun bei einem falschen Index sowohl der String als auch der fehlerhafte Index zur Initialisierung des Ausnahmeobjekts u¨ bergeben:
// dyna/string4.cpp /* Operator [] f¨ur Variablen */ char& String::operator [] (unsigned i) { // Index nicht im erlaubten Bereich ?
if (i >= len) { /* Ausnahme: * - neu: String selbst und fehlerhaften Index u¨ bergeben */
} }
throw RangeError(*this,i);
return cstring[i];
/* Operator [] f¨ur Konstanten */ const char String::operator [] (unsigned i) const { // Index nicht im erlaubten Bereich ?
Sandini Bib
6.2 Weitere Aspekte dynamischer Komponenten
395
if (i >= len) { /* Ausnahme: * - neu: String selbst und fehlerhaften Index u¨ bergeben */
} }
throw RangeError(*this,i);
return cstring[i];
Eine Auswertung kann nun auf beide Komponenten zugreifen:
// dyna/stringtest4.cpp int main() { try { ...
}
} catch (const String::RangeError& error) { // main() mit Fehlermeldung und Fehlerstatus beenden std::cerr << "FEHLER: fehlerhafter Index " << error.index << " bei Zugriff auf String \"" << error.string << "\"" << std::endl; return EXIT_FAILURE; }
Man beachte, dass durch die Tatsache, dass das Objekt, das eine Ausnahme ausl¨ost, nicht als Parameter der Ausnahme u¨ bergeben werden kann, man auch nicht mehr die Identit¨at des Objekts feststellen kann. Wird diese M¨oglichkeit ben¨otigt, muss man entweder Objekt-IDs durchreichen (siehe Seite 418) oder die Adresse des betroffenen Objekts als Zeiger vom Typ const void* durchreichen. Dann kann man beim Behandeln dieser Ausnahme diese Adresse mit den Adressen der bekannten Objekte vergleichen.
6.2.8
Zusammenfassung
Funktionen k¨onnen f¨ur variable und konstante Objekte unterschiedlich u¨ berladen werden. Liefert eine Elementfunktion die M¨oglichkeit zu einem schreibenden Zugriff auf interne Komponenten, muss sichergestellt werden, dass die Funktion nicht f¨ur Konstanten aufgerufen werden kann. Funktionen, die dynamische Komponenten in einen anderen Typ konvertieren, k¨onnen mit unterschiedlichen Vor- und Nachteilen eine Kopie oder eine Konstante zur¨uckliefern.
Sandini Bib
396
Kapitel 6: Dynamische und statische Komponenten
F¨ur Bedingungen in Kontrollstrukturen k¨onnen automatische Typumwandlungen definiert werden, die es erlauben, ein Objekt direkt als Bedingung zu verwenden. Dies sollte mit Vorsicht eingesetzt werden. Insbesondere sollte damit kein Zugriff auf interne Komponenten m¨oglich sein. Das Schl¨usselwort mutable erlaubt die Modifikation einer Komponente auch f¨ur KonstantenElementfunktionen. Semantisch bedeutet das, dass die Komponente f¨ur die logische Konstantheit unerheblich ist. Default-Funktionen, die nicht sinnvoll sind, sollten ausdr¨ucklich verboten werden. Dazu reicht es, sie als private zu deklarieren. Proxy-Klassen erlauben es, auch in geschachtelten Operationen die Kontrolle zu behalten. Ausnahmeobjekte k¨onnen Komponenten besitzen. Wird dabei das Objekt u¨ bergeben, das eine Ausnahme ausl¨ost, muss davon eine Kopie angelegt werden.
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
397
6.3 Vererbung von Klassen mit dynamischen Komponenten Dieser Abschnitt geht auf einige weitere Punkte ein, die beim Ableiten von Klassen beachtet werden m¨ussen. Dazu geh¨oren vor allem Aspekte, die bei Klassen mit dynamischen Komponen¨ ten eine Rolle spielen. Betrachtet wird auch die Problematik beim Uberschreiben von FriendFunktionen. Verdeutlicht werden diese Aspekte am Beispiel einer Ableitung der Klasse Bsp::String. Diese wird um die Eigenschaft erweitert, eine Farbe zu besitzen. Als Datentyp f¨ur die Farbe wird ebenfalls die Klasse Bsp::String verwendet. Auf diese Weise ist die abgeleitete Klasse auch gleich eine Anwendung der Basisklasse.
6.3.1
Die Klasse Bsp::String als Basisklasse
Damit die Klasse Bsp::String abgeleitet werden kann, muss sie so implementiert werden, dass sie zur Vererbung geeignet ist (die Standard-String-Klasse std::string ist nicht zur Vererbung geeignet). Ausgehend von der Version der Klasse Bsp::String aus Abschnitt Abschnitt 6.1 (ohne Indexoperator) muss die Klassendeklaration dazu wie folgt ge¨andert werden:
Die privaten Komponenten erhalten das Schl¨usselwort protected (der private Konstruktor f¨ur den Summenstring bleibt allerdings privat). Die Funktionen zum Einlesen und Ausgeben werden virtual deklariert. Der Destruktor wird als virtual deklariert.
Daraus ergibt sich f¨ur die Basisklasse Bsp::String folgende Headerdatei:
// dyna/string5.hpp #ifndef STRING_HPP #define STRING_HPP // Headerdatei f¨ur I/O
#include // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class String { protected: char* buffer; unsigned len; unsigned size; public:
// Zeichenfolge als dynamisches Feld (Array) // aktuelle Anzahl an Zeichen // Speicherplatzgr¨oße von buffer
Sandini Bib
398
Kapitel 6: Dynamische und statische Komponenten // Default- und char*-Konstruktor
String (const char* = ""); // Aufgrund dynamischer Komponenten:
String (const String&); // Copy-Konstruktor String& operator= (const String&); // Zuweisung virtual ~String(); // Destruktor (neu: virtuell) // Vergleichen von Strings
friend bool operator== (const String&, const String&); friend bool operator!= (const String&, const String&); // Hintereinanderh¨angen von Strings
friend String operator+ (const String&, const String&); // Ausgabe mit Streams
virtual void printOn (std::ostream&) const; // Eingabe mit Streams
virtual void scanFrom (std::istream&); // Anzahl der Zeichen // Beachte: darf beim Ableiten nicht u¨ berschrieben werden
unsigned length () const { return len; }
};
private: /* Konstruktor aus L¨ange und Puffer * - intern f¨ur Operator + */ String (unsigned, char*);
// Standard-Ausgabeoperator
inline std::ostream& operator << (std::ostream& strm, const String& s) { // String auf Stream ausgeben s.printOn(strm); return strm; // Stream zur¨uckliefern }
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
399
// Standard-Eingabeoperator
inline std::istream& operator >> (std::istream& strm, String& s) { s.scanFrom(strm); // String von Stream einlesen return strm; // Stream zur¨uckliefern } /* Operator !=
* - als Umsetzung auf Operator == inline implementiert */
inline bool operator!= (const String& s1, const String& s2) { return !(s1==s2); } } // **** ENDE Namespace Bsp ******************************** #endif // STRING_HPP Virtuell oder nicht virtuell ? Bemerkenswert ist die Tatsache, dass die Elementfunktion length() nicht als virtuell deklariert wurde. Dies ist eine Design-Entscheidung zugunsten besseren Laufzeitverhaltens. Sie bedeutet, dass es Probleme gibt, wenn eine abgeleitete Klasse diese Funktion u¨ berschreibt und ein Objekt der abgeleiteten Klassen dann unter der Basisklasse verwendet wird. Da das Abfragen der Anzahl der Zeichen eigentlich nicht anders implementiert werden kann, ist die damit verbundene Einschr¨ankung aber vertretbar (man k¨onnte nat¨urlich die Bedeutung der Komponente buffer außer Kraft setzen; dies w¨are allerdings eine Verletzung der Grundregel, dass Komponenten in abgeleiteten Klassen ihre Bedeutung behalten sollten, siehe Seite 351). Der Vorteil besteht daf¨ur darin, dass dieser Funktionsaufruf wirklich inline ersetzt werden kann. Bei virtuellen Funktionen muss Code generiert werden, der zur Laufzeit den tats¨achlichen Datentyp pr¨uft und dann auch eine Funktion aufruft.
6.3.2
Die abgeleitete Klasse FarbString
Abgeleitet wird die String-Klasse als String, der eine bestimmte Farbe besitzt. Entsprechend heißt die abgeleitete Klasse FarbString. Hinzu kommt als zus¨atzliche Komponente eine Farbe, die dem String jeweils zugeordnet wird. Wie schon erw¨ahnt wurde, ist die Farbe selbst ein Objekt der Klasse Bsp::String. Man h¨atte auch einen Aufz¨ahlungstyp verwenden k¨onnen, doch diese Variante erm¨oglicht es, gleichzeitig die Verwendung der String-Klasse als Basisklasse und als angewendete Klasse zu verdeutlichen. Headerdatei der Klasse FarbString Die Headerdatei der von String abgeleiteten Klasse FarbString hat folgenden Aufbau, der anschließend noch im Einzelnen erl¨autert wird:
Sandini Bib
400
Kapitel 6: Dynamische und statische Komponenten
// dyna/fstring1.hpp #ifndef FARBSTRING_HPP #define FARBSTRING_HPP // Headerdatei der Basisklasse
#include "string.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Klasse FarbString * - abgeleitet von String */ class FarbString : public String { protected: // Farbe, die der String besitzt String farb; public:
// Default-, String- und String/String-Konstruktor
FarbString (const String& s = "", const String& f = "black") : String(s), farb(f) { } // Farbe abfragen und setzen
const String& farbe () { return farb; } void farbe (const String& neueFarbe) { farb = neueFarbe; } // Ein- und Ausgabe mit Streams
virtual void printOn (std::ostream&) const; virtual void scanFrom (std::istream&); // Vergleichen von FarbStrings
friend bool operator== (const FarbString& s1, const FarbString& s2) { return static_cast(s1) == static_cast(s2) && s1.farb == s2.farb; }
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
401
friend bool operator!= (const FarbString& s1, const FarbString& s2) { return !(s1==s2); }
};
} // **** ENDE Namespace Bsp ******************************** #endif // FARBSTRING_HPP Zun¨achst wird die neue Komponente f¨ur die Farbe definiert. Da das Symbol farbe als Funktion zum Setzen und Abfragen der Farbe verwendet wird, tr¨agt die Komponente den Namen farb (sinnvolle Namen bei Schnittstellen sollten vorgehen):6
class FarbString : public String { protected: // Farbe, die der String besitzt String farb; ...
}; Als N¨achstes wird der Konstruktor definiert. Als Parameter k¨onnen dabei bis zu zwei Strings u¨ bergeben werden. Der erste String ist die Zeichenfolge (Default: Leerstring), und der zweite String ist die Farbe (Default: "black"):
class FarbString : public String { ...
public:
// Default-, String- und String/String-Konstruktor
FarbString (const String& s = "", const String& f = "black") : String(s), farb(f) { } ...
}; In der Initialisierungsliste wird der erste String s als initiale Zeichenfolge an die Basisklasse String hochgereicht. Mit der als zweiten Parameter u¨ bergebenen Farbe f wird die Komponente farb initialisiert. Die Elementfunktion farbe() wurde so u¨ berladen, dass sie sowohl zum Setzen als auch zum Abfragen der Farbe verwendet werden kann. Dies ist eine g¨angige Technik in C++. Wird kein Parameter u¨ bergeben, liefert sie die aktuelle Farbe, wird ein Parameter u¨ bergeben, ist dies die neue Farbe: 6
Es gibt keine generell u¨ bliche L¨osung f¨ur das Dilemma des Namenskonflikts zwischen einer einfachen Komponente und der Elementfunktion, die diese Komponente setzt und abfragt. Manchmal wird vor die Komponente ein Unterstrich gesetzt, manchmal wird sie einfach abgek¨urzt und manchmal beginnen die Elementfunktionen mit Pr¨afixen wie get und set.
Sandini Bib
402
Kapitel 6: Dynamische und statische Komponenten
class FarbString : public String { ...
public: // Farbe abfragen const String& farbe () { return farb; } void farbe (const String& neueFarbe) { // Farbe setzen farb = neueFarbe; } ...
}; Virtuell oder nicht virtuell ? Auch die Elementfunktionen zum Setzen und Abfragen der Farbe werden in diesem Fall nicht ¨ virtuell definiert. Auch hier gilt, dass sie damit beim Ableiten nicht zum Uberschreiben geeignet sind. Dies ist erneut eine Design-Entscheidung zugunsten besseren Laufzeitverhaltens. Da das Setzen und Abfragen der Farbe eigentlich nicht anders implementiert werden kann, ist die damit verbundene Einschr¨ankung vertretbar. Es bedeutet aber, dass, wenn in abgeleiteten Klassen beim ¨ Andern der Farbe andere Dinge anzupassen sind, dies nicht mehr m¨oglich ist (oder zumindest nur um den Preis, dass in Programmen keine Zeiger oder Referenzen auf Basisklassen verwendet werden sollten). Die Funktionen zum Einlesen und Ausgeben werden dagegen, wie auch schon in der Basisklasse, virtuell definiert, damit jeweils die richtige Eingabe- bzw. Ausgabefunktion aufgerufen wird:
class FarbString : public String { ...
public: virtual void printOn (std::ostream&) const; virtual void scanFrom (std::istream&); ...
};
6.3.3
Ableiten von Friend-Funktionen
Um eine automatische Typumwandlung auch f¨ur den ersten Operator zu erm¨oglichen, wurden die Operatoren zum Testen auf Gleichheit und Ungleichheit bereits in der Basisklasse String als globale Friend-Funktionen definiert. Sie m¨ussen in der abgeleiteten Klasse allerdings u¨ berschrieben werden, da neben den Zeichenfolgen auch die Farben von zwei FarbStrings gleich sein m¨ussen. Um den Test auf Gleichheit so einfach wie m¨oglich zu implementieren, wird er auf die Implementierung der Basisklasse zur¨uckgef¨uhrt und um den Test auf Gleichheit f¨ur die neue Komponente erg¨anzt. Da es sich aber um eine globale Funktion handelt, kann dies nicht u¨ ber den Bereichsoperator, sondern nur u¨ ber eine explizite Typumwandlung realisiert werden:
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
403
class FarbString : public String { ...
public: friend bool operator== (const FarbString& s1, const FarbString& s2) { return static_cast(s1) == static_cast(s2) && s1.farb == s2.farb; } ...
}; Mit dem Operator static_cast<> werden die Parameter s1 und s2 explizit in den Typ const String& umgewandelt. Damit wird sichergestellt, dass der Vergleichsoperator f¨ur den Datentyp der Basisklasse String aufgerufen wird. Hinzu kommt der zus¨atzliche Test auf Gleichheit f¨ur die Farbe, der ebenfalls zum Aufruf des Vergleichsoperators der Klasse String f¨uhrt, da die Komponente farb diesen Typ besitzt. Es gibt nun also eine Vergleichsoperation f¨ur zwei Strings und zwei FarbStrings. Was passiert aber, wenn ein String mit einem FarbString verglichen wird? In diesem Fall gibt es zwei M¨oglichkeiten:
Der FarbString wird implizit in einen String umgewandelt. Dies ist m¨oglich, da jedes Objekt einer abgeleiteten Klasse grunds¨atzlich als Objekt der Basisklasse verwendet werden kann und somit eine automatische Typumwandlung von FarbString nach String definiert ist. Der String wird in einen FarbString umgewandelt. Dies ist m¨oglich, da es f¨ur die Klasse FarbString einen Konstruktor gibt, dem ein String als Parameter u¨ bergeben werden kann. Dieser Konstruktor definiert automatisch eine entsprechende Typumwandlung.
Man k¨onnte nun meinen, damit sei der Ausdruck mehrdeutig und somit nicht u¨ bersetzbar. In C++ hat eine von der Sprache vordefinierte automatische Typumwandlung aber eine h¨ohere Priorit¨at als eine, die durch vom Anwender definierte Funktionen (Konstruktoren, Konvertierungsfunktionen) erm¨oglicht wird (siehe auch Seite 575). Also wird der Vergleich f¨ur zwei Strings aufgerufen. Friend und virtual Friend-Funktionen sind grunds¨atzlich nicht virtuell. Beim Aufruf einer Friend-Funktion wird auch bei Zeigern und Referenzen immer die Funktion aufgerufen, die f¨ur den Typ der Parameter definiert ist. Dies bedeutet, dass der Vergleichsoperator f¨ur den Datentyp String aufgerufen wird, wenn zwei FarbStrings u¨ ber Zeiger oder Referenzen auf Strings verglichen werden. Dies ist der Preis f¨ur die M¨oglichkeit der automatischen Typumwandlung f¨ur den ersten Operanden als FriendFunktion.
Sandini Bib
404
Kapitel 6: Dynamische und statische Komponenten
Wer will, dass in Abh¨angigkeit vom Typ zur Laufzeit die richtige Funktion aufgerufen wird, sollte den Operator als Elementfunktion definieren. Zus¨atzlich kann ja ein globaler Operator definiert werden, der ein Objekt vom Typ char* mit einem String bzw. mit einem FarbString vergleicht. Eine andere M¨oglichkeit w¨are die Implementierung des Operators als globale Funktion, die keine Friend-Funktion ist und stattdessen eine interne Hilfsfunktion aufruft (dazu folgt gleich noch ein Beispiel). Da Friend-Funktionen nicht virtuell sein k¨onnen, muss auch der Test auf Ungleichheit erneut implementiert werden, obwohl er genauso wie in der Basisklasse implementiert wird. Er liefert einfach das negierte Ergebnis des Tests auf Gleichheit:
class FarbString : public String { ...
public: friend bool operator!= (const FarbString& s1, const FarbString& s2) { return !(s1==s2); } ...
}; Da die Implementierung der Basisklasse keine virtuelle Funktion ist, ruft ihr Test auf Ungleichheit immer den Test auf Gleichheit f¨ur Objekte vom Typ String auf. Dies w¨urde dann auch f¨ur FarbStrings gelten. Die Farbe w¨urde also keine Rolle mehr spielen. Aus diesem Grund muss der Operator erneut implementiert werden. Auch hier gilt, dass bei Zeigern oder Referenzen auf Strings unabh¨angig vom tats¨achlichen Datentyp immer die String-Funktion aufgerufen wird. Vermeidung von Friend-Funktionen Grunds¨atzlich bleibt also festzuhalten, dass Friend-Funktionen bei der Vererbung problematisch sind. Sie werden nicht vererbt, k¨onnen aber f¨ur Objekte der abgeleiteten Klasse aufgerufen werden. Dabei werden die Objekte aber zu Objekten der Basisklasse, was beim Aufruf von Hilfsfunktionen in den Friend-Funktionen zum Aufruf der falschen Funktionen f¨uhren kann. Es ist auch nicht erlaubt, eine Operation sowohl als Elementfunktion als auch als FriendFunktion zu definieren:
class String { ...
public: bool operator== (const String& s2); friend bool operator== (const String& s1, const String& s2); // FEHLER ...
};
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
405
Sofern eine Klasse zur Vererbung vorgesehen ist, sollte deshalb auf Friend-Funktionen grunds¨atzlich verzichtet werden. Die automatische Typumwandlung f¨ur den ersten Operanden kann man auch durch zus¨atzliche globale Hilfsfunktionen erm¨oglichen. Eine andere M¨oglichkeit ist die Technik, die auch schon bei den Ein- und Ausgabeoperatoren angewendet wird. Man definiert den Operator als globale Funktion, die nur eine virtuelle Elementfunktion aufruft, die die eigentliche Arbeit u¨ bernimmt. Dies s¨ahe in der Basisklasse z.B. wie folgt aus:
class String { ...
public: virtual bool equals (const String& s2); ...
}; inline bool operator== (const String& s1, const String& s2) { return s1.equals(s2); } inline bool operator!= (const String& s1, const String& s2) { return !s1.equals(s2); } Eine abgeleitete Klasse muss dann nur noch die neue Version von equals() implementieren:
class FarbString : public String { ...
public: virtual bool equals (const FarbString& s2) { return String::equals(s2) && farb == s2.farb; } ...
};
6.3.4
Quelldatei der abgeleiteten Klasse FarbString
Implementiert wird der Zuweisungsoperator in der Quelldatei der abgeleiteten Klasse FarbString . Auch hier macht es in der Regel Sinn, die Implementierung der Basisklasse aufzurufen und die neuen Komponenten zus¨atzlich zuzuweisen. Hinzu kommen die relativ trivialen Implementierungen der Funktionen zum Einlesen und Ausgeben:
Sandini Bib
406
Kapitel 6: Dynamische und statische Komponenten
// dyna/fstring1.cpp // Headerdatei der eigenen Klasse
#include "fstring.hpp" // **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* Ausgabe auf Stream */ void FarbString::printOn (std::ostream& strm) const { // Zeichenfolge mit Farbe in Klammern ausgeben
}
String::printOn(strm); strm << " (in " << farb << ')';
/* Einlesen eines FarbStrings von einem Input-Stream */ void FarbString::scanFrom (std::istream& strm) { // Inhalt und Farbe nacheinander einlesen
}
String::scanFrom (strm); farb.scanFrom (strm);
} // **** ENDE Namespace Bsp ******************************** Die Funktion zum Einlesen liest einfach nur zwei Strings f¨ur den Inhalt und die Farbe ein. Eine aufw¨andigere Implementierung k¨onnte die Farbe als optionale Angabe interpretieren, indem o¨ ffnende und schließende Klammern entsprechend ausgewertet werden.
6.3.5
Anwendung der Klasse FarbString
Angewendet wird die Klasse FarbString wie jede andere Klasse auch, wie das folgende Beispiel zeigt:
// dyna/fstest1.cpp // Headerdatei f¨ur I/O
#include // Headerdatei f¨ur die Klasse FarbString
#include "fstring.hpp"
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
int main () { Bsp::FarbString f("hallo"); Bsp::FarbString r("rot","red");
407
// FarbString mit Default-Farbe // FarbString mit Rot als Farbe
std::cout << f << " " << r << std::endl; // FarbStrings ausgeben f.farbe("green");
// Farbe von f auf Gr¨un setzen
std::cout << f << " " << r << std::endl; // FarbStrings ausgeben }
std::cout << "Summenstring: " << f + r << std::endl;
Das Programm liefert als Ausgabe:
hallo (in black) rot (in red) hallo (in green) rot (in red) Summenstring: hallorot Die Addition wurde f¨ur FarbStrings nicht neu implementiert. Welche Farbe sollte der Summenstring auch besitzen? Da FarbStrings aber als Strings verwendet werden k¨onnen, ist eine Addition m¨oglich, wobei die Implementierung der Basisklasse aufgerufen wird. Der R¨uckgabewert ist damit ein String und kein FarbString, wie die Ausgabe des Programms auch zeigt.
6.3.6
Ableiten der Spezialfunktionen fur ¨ dynamische Komponenten
Man mag sich fragen, warum in der Klasse FarbString nicht auch die speziellen Funktionen f¨ur dynamische Komponenten erneut implementiert wurden. Schließlich bestand in der Basisklasse die Notwendigkeit, deren Default-Implementierungen durch eigene Implementierungen zu ersetzen, und die Komponenten wurden ja geerbt. Die Default-Implementierungen sind allerdings so definiert, dass diese Notwendigkeit nicht besteht:
Der Copy-Konstruktor wird wie alle Konstruktoren verkettet top-down aufgerufen. Dabei ruft der Default-Copy-Konstruktor einer abgeleiteten Klasse automatisch den g¨ultigen CopyKonstruktor der Basisklasse auf. Der Copy-Konstruktor der Basisklasse k¨ummert sich also automatisch um die Behandlung der geerbten dynamischen Komponenten. Neu hinzugekommene Komponenten werden komponentenweise kopiert. In diesem Fall wird also außerdem automatisch der String farb kopiert. Da es sich um einen Datentyp mit selbst definiertem Copy-Konstruktor handelt, wird dieser automatisch aufgerufen. Der Destruktor wird wie alle Destruktoren verkettet bottom-up aufgerufen. Damit werden alle Aufr¨aumungsarbeiten der Basisklasse automatisch durchgef¨uhrt. Da der Destruktor der Basisklasse virtuell ist, sind auch die Destruktoren aller abgeleiteten Klassen automatisch virtuell.
Sandini Bib
408
Kapitel 6: Dynamische und statische Komponenten
F¨ur den Zuweisungsoperator gilt Entsprechendes. Der generierte Default-Zuweisungsoperator weist zun¨achst die geerbten Komponenten zu, indem er den Zuweisungsoperators der Basisklasse aufruft. Anschließend werden dann alle neuen Komponenten komponentenweise zugewiesen. Damit wird f¨ur die geerbten Komponenten der selbst implementierte Zuweisungsoperator und f¨ur farb ebenfalls der selbst implementierte Zuweisungsoperator automatisch aufgerufen.
Wie man sieht, behandeln die generierten Default-Operationen den Sachverhalt, dass man gegebenenfalls dynamische Komponenten erbt, ausreichend gut. Dies ist wichtig, da man damit in einer Basisklasse auch sp¨ater erst dynamische Komponenten einf¨uhren kann, ohne dass damit alle abgeleiteten Klassen ge¨andert werden m¨ussen. F¨ur den Fall, dass man einen Copy-Konstruktor einmal in einer abgeleiteten Klasse implementieren muss, sei hier exemplarisch gezeigt, wie dieser f¨ur die Klasse FarbString aussehen m¨usste: Mit Hilfe einer Initialisierungsliste wird einfach der Copy-Konstruktor der Basisklasse aufgerufen, und dann wird jede neue Komponenten kopiert:
class FarbString : public String { ...
public: FarbString (const FarbString& s) : String(s), farb(s.farb) { }
// Copy-Konstruktor
...
}; Entsprechend sieht die Implementierung eines eigenen Zuweisungsoperators aus. Zun¨achst wird der Zuweisungsoperator der Basisklasse aufgerufen, und dann werden alle neuen Komponenten zugewiesen:
FarbString& FarbString::operator= (const FarbString& s) { // Zuweisung eines FarbStrings an sich selbst hat keinen Effekt if (this == &s) { // FarbString zur¨uckliefern return *this; } // Zuweisungsoperator der Basisklasse aufrufen
String::operator = (s); // neue Komponenten zuweisen
farb = s.farb; }
return *this;
// ge¨anderten FarbString zur¨uckliefern
Sandini Bib
6.3 Vererbung von Klassen mit dynamischen Komponenten
6.3.7
409
Zusammenfassung
Friend-Funktionen k¨onnen niemals virtuell sein. Aus diesem Grund sind Friend-Funktionen bei Vererbung problematisch und sollten vermieden werden. Ist ein Destruktor in der Basisklasse virtuell, ist er das auch in allen davon abgeleiteten Klassen. Sind in einer Basisklasse Copy-Konstruktor, Destruktor und Zuweisungsoperator notwendig, m¨ussen diese nicht in einer davon abgeleiteten Klasse notwendig sein. Die DefaultImplementierungen dieser Operationen in abgeleiteten Klassen rufen die selbst definierten Implementierungen der Basisklasse automatisch auf.
Sandini Bib
410
Kapitel 6: Dynamische und statische Komponenten
6.4 Klassen verwenden Klassen In diesem Abschnitt wird eine Klasse f¨ur eine einfache Personenverwaltung eingef¨uhrt, anhand derer hier und im folgenden Abschnitt einige weitere Eigenschaften von Klassen vorgestellt werden. In diesem Abschnitt wird dabei zun¨achst noch einmal deutlich gemacht, was passiert, wenn Objekte aus anderen Objekten bestehen. Damit werden auch die Vorteile von Initialisierungslisten erl¨autert.
6.4.1
Objekte als Komponenten anderer Klassen
In den bisherigen Beispielen waren die Komponenten einer Klasse meistens fundamentale Datentypen wie int und char. Klassen k¨onnen aber nat¨urlich auch selbst wieder in anderen Klassen Verwendung finden. String-Klassen sind das beste Beispiel daf¨ur, da Komponenten von Objekten sehr h¨aufig Strings sind. In einem solchen Fall werden mit der Initialisierung eines Objekts auch Teilobjekte (Subobjekte) initialisiert, aus denen das Objekt besteht. Das bedeutet, dass mehrere Konstruktoren aufgerufen werden. Dieser Umstand soll nachfolgend genauer behandelt werden. Konstruktoren rufen Konstruktoren auf Wenn Objekte einer Klasse aus Objekten anderer Klassen bestehen, werden bei der Initialisierung solcher Objekte gleich mehrere Konstruktoren aufgerufen. Zus¨atzlich zum Konstruktor des Gesamtobjekts m¨ussen die Objekte, aus denen dieses besteht, initialisiert werden. Dabei gilt:
Bevor die Anweisungen im Rumpf eines Konstruktors ausgef¨uhrt werden, werden erst die Konstruktoren f¨ur die Komponenten des Objekts aufgerufen.
Diese Reihenfolge ist auch vern¨unftig, da nur der Konstruktor des Gesamtobjekts die Bedeutung der Teilobjekte kennt und somit die M¨oglichkeit haben muss, die Startzust¨ande der Teilobjekte auszuwerten oder gegebenenfalls zu korrigieren. Dar¨uber hinaus ist definiert, dass die Reihenfolge beim Aufruf der Konstruktoren f¨ur die Komponenten, aus denen ein Objekt besteht, der Deklarationsreihenfolge entspricht. Die Reihenfolge wird nicht durch die Initialisierungsliste definiert. Diese Tatsache sollte allerdings nicht ausgenutzt werden, da sonst v¨ollig unlesbare Programme entstehen. Es w¨urde bedeuten, dass durch ein Vertauschen der Deklarationsreihenfolge das Programmverhalten ge¨andert wird. Die Reihenfolge wird nur definiert, um sicherzustellen, dass die Destruktoren immer in umgekehrter Reihenfolge aufgerufen werden, was z.B. f¨ur die Implementierung einer eigenen Speicherverwaltung wichtig sein kann.
6.4.2
Implementierung der Klasse Person
Die Verwendung von Objekten anderer Klassen wird am Beispiel einer Klasse Person betrachtet. Eine Person soll dabei zun¨achst nur aus einem Vor- und Nachnamen bestehen, die jeweils Objekte einer String-Klasse sind. Als String-Klasse wird die Standard-String-Klasse std::string verwendet. Sofern aber das interne Verhalten von Strings beschrieben wird, wird dies am inneren Aufbau von Objekten der in diesem Kapitel eingef¨uhrten Klasse Bsp::String erl¨autert.
Sandini Bib
6.4 Klassen verwenden Klassen
411
Die Headerdatei der Klasse Person hat folgenden Aufbau:
// dyna/person1.hpp #ifndef PERSON_HPP #define PERSON_HPP // Headerdateien f¨ur Hilfsklassen
#include <string> // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Person { private: std::string vname; std::string nname;
// Vorname der Person // Nachname der Person
public: // Konstruktor aus Nachname und optional Vorname
Person (const std::string&, const std::string& = ""); // Abfragen von Eigenschaften
const std::string& nachname () const { return nname; } const std::string& vorname () const { return vname; }
// Nachname liefern
// Vorname liefern
// vergleichen
bool operator == return vname } bool operator != return vname }
(const Person& p) const { == p.vname && nname == p.nname; (const Person& p) const { != p.vname || nname != p.nname;
...
}; } // **** ENDE Namespace Bsp ******************************** #endif // PERSON_HPP
Sandini Bib
412
Kapitel 6: Dynamische und statische Komponenten
An dieser Stelle sei zun¨achst darauf hingewiesen, dass es sich bei der Klasse Person um keine Klasse mit dynamischen Komponenten handelt, obwohl sie aus Teilobjekten besteht, die dynamische Komponenten besitzen. Die Probleme dynamischer Komponenten sind n¨amlich erfolgreich auf die Teilobjekte abgew¨alzt worden. Dort wurde daf¨ur gesorgt, dass die Zuweisung, der CopyKonstruktor und der Destruktor korrekt arbeiten. Die String-Klasse kann nun wie der Datentyp int verwendet werden. Wenn eine Person einer anderen Person zugewiesen wird, kann deshalb die Default-Implementierung, die komponentenweise zuweist, verwendet werden. Das gleiche gilt f¨ur das Anlegen einer Kopie. Entsprechend werden bei der Zerst¨orung eines Objekts der Klasse Person automatisch die Destruktoren der Teilobjekte aufgerufen. Der Konstruktor ist in der Quelldatei definiert und initialisiert die Person einfach mit den u¨ bergebenen Parametern:
// dyna/person1.cpp /* Konstruktor aus Nachname und Vorname * - Default f¨ur Vorname: "" */ Person::Person (const std::string& nn, const std::string& vn) // Vor- und Nachname anhand u¨ bergebener : nname(nn), vname(vn) // Parameter initialisieren
{ // mehr ist nicht zu tun
} Anlegen einer Person in Einzelschritten Wenn nun eine Person durch
Bsp::Person nico ("Josuttis", "Nicolai"); angelegt wird, geschieht durch die Verwendung der Initialisierungsliste Folgendes (dabei wird davon ausgegangen, dass die Klasse std::string genauso wie die Klasse Bsp::String aus Abschnitt 6.1.1 implementiert ist):
Zun¨achst wird Speicherplatz f¨ur das Objekt angelegt: v n a m e :
b u f f e r : l e n : s i z e :
n n a m e :
? ? ?
b u f f e r :
?
l e n : s i z e :
? ?
Sandini Bib
6.4 Klassen verwenden Klassen
413
Dann wird f¨ur die Komponente vname der Copy-Konstruktor der String-Klasse aufgerufen, da zur Initialisierung jetzt der String vn u¨ bergeben wird. Dieser initialisiert den Vornamen mit dem u¨ bergebenen String (siehe Abschnitt 6.1.3): v n a m e :
b u f f e r :
' N '
l e n : s i z e :
n n a m e :
7
' o '
' l '
' a '
' i '
b u f f e r :
? ? ?
Dass zun¨achst vname initialisiert wird, liegt nicht an der Initialisierungsliste, sondern an der Tatsache, dass die Komponente vname in der Klasse Person vor der Komponente nname deklariert wird. Anschließend wird f¨ur die Komponente nname ebenfalls der Copy-Konstruktor der StringKlasse aufgerufen, da zur Initialisierung der String nn u¨ bergeben wird: v n a m e :
b u f f e r : l e n : s i z e :
n n a m e :
7
' N '
' i '
' c '
' o '
' l '
' a '
' i '
' J '
' o '
' s '
' u '
' t '
' t '
' i '
7
b u f f e r : l e n : s i z e :
' c '
7
l e n : s i z e :
' i '
8
' s '
8
Schließlich werden die Anweisungen des Konstruktors der Klasse Person ausgef¨uhrt. Doch da die Komponenten bereits vollst¨andig initialisiert sind, ist in diesem Fall gar nichts mehr zu tun (was durchaus nicht untypisch ist).
Man beachte, dass eine Initialisierungsliste nicht Teil der Deklaration, sondern Teil der Implementierung einer Funktion ist. Da oft nach der Initialisierung gar keine Anweisungen mehr durchgef¨uhrt werden m¨ussen, wird der Konstruktor in der Praxis h¨aufig gleich als Inline-Funktion in der Headerdatei definiert:
namespace Bsp { class Person { ...
public:
Sandini Bib
414
Kapitel 6: Dynamische und statische Komponenten
Person (const std::string& nn, const std::string& vn = "") : nname(nn), vname(vn) { } ...
}
};
Einzelschritte ohne Initialisierungslisten An dieser Stelle kann man auch verdeutlichen, warum die Verwendung von Initialisierungslisten wichtig ist. Stellen wir uns vor, wir h¨atten den Konstruktor von Person so implementiert, dass keine Initialisierungslisten verwendet werden:
/* Konstruktor aus Nachname und Vorname * - schlecht: ohne Initialisierungsliste */
Person::Person (const std::string& nn, const std::string& vn) { nname = nn; vname = vn; } Wenn in diesem Fall eine Person durch
Bsp::Person nico ("Josuttis", "Nicolai"); angelegt wird, passiert n¨amlich im Einzelnen Folgendes:
Zun¨achst wird auch in diesem Fall der Speicherplatz f¨ur das Objekt angelegt: v n a m e :
b u f f e r : l e n : s i z e :
n n a m e :
? ? ?
b u f f e r :
?
l e n : s i z e :
? ?
Dann wird f¨ur die Komponente vname allerdings der Default-Konstruktor der String-Klasse aufgerufen (es wird ja kein Argument zur Initialisierung u¨ bergeben). Dieser initialisiert den Vornamen mit dem Leerstring (siehe Abschnitt 6.1.2):
Sandini Bib
6.4 Klassen verwenden Klassen v n a m e :
n n a m e :
415
b u f f e r :
?
l e n : s i z e :
? ?
b u f f e r : l e n : s i z e :
0 0
Danach wird die Komponente nname ebenfalls durch den Default-Konstruktor der StringKlasse mit dem Leerstring initialisiert: v n a m e :
b u f f e r : l e n : s i z e :
n n a m e :
0 0
b u f f e r : l e n : s i z e :
0 0
Schließlich werden die Anweisungen des Konstruktors der Klasse Person ausgef¨uhrt, die den Komponenten vname und nname dann die richtigen Werte zuweisen: v n a m e :
b u f f e r : l e n : s i z e :
n n a m e :
7
' i '
' c '
' o '
' l '
' a '
' i '
' J '
' o '
' s '
' u '
' t '
' t '
' i '
7
b u f f e r : l e n : s i z e :
' N '
8
' s '
8
Dazu wird der Zuweisungsoperator der String-Klasse aufgerufen, der den Speicherplatz f¨ur den eben initialisierten Leerstring wieder freigibt sowie neuen allokiert und initialisiert (siehe Abschnitt 6.1.5). Dieses Beispiel verdeutlicht einige Nachteile, die durch die Nichtverwendung einer Initialisierungsliste entstehen: Beide Komponenten werden u¨ ber den Default-Konstruktor mit einem Default-Wert belegt, der aber falsch ist und durch eine Zuweisung anschließend sofort ersetzt
Sandini Bib
416
Kapitel 6: Dynamische und statische Komponenten
werden muss. Im vorliegenden Beispiel bedeutete dies, dass v¨ollig unn¨otigerweise Speicherplatz angelegt und sofort wieder freigegeben wird, da er zur Initialisierung der u¨ bergebenen Namen nicht ausreicht. Dies bedeutet einen erheblichen Laufzeitnachteil (Speicherplatzverwaltung ist zeitaufw¨andig), der sich bei Objekten, die mehr oder komplexere Komponenten besitzen, noch vergr¨oßert. Eine solche fehlerhafte Initialisierung mit dem Default-Wert kann aber nicht nur sehr viel Zeit kosten, sondern auch irreparable Sch¨aden anrichten. Man denke z.B. an eine Klasse f¨ur ge¨offnete Dateien, die zun¨achst eine verkehrte Default-Datei o¨ ffnet. Schließlich kann es auch sein, dass f¨ur Klassen kein aufrufbarer Default-Konstruktor existiert. Dies ist immer dann der Fall, wenn zur Initialisierung mindestens ein Argument gebraucht wird. Solche Objekte k¨onnten dann nicht mehr als Teilobjekte in einer anderen Klasse dienen. Die Klasse Person hat z.B. keinen Default-Konstruktor, da es nach ihrer Spezifikation keinen Sinn macht, eine Person anzulegen, ohne zumindest den Namen zu u¨ bergeben. Die Klasse k¨onnte ohne Initialisierungslisten nicht mehr in anderen Klassen verwendet werden (man denke z.B. an eine Klasse Projekt, in der ein Projektleiter als Person eingetragen wird). Initialisierung von Konstanten und Referenzen als Komponenten Durch die Initialisierungslisten ist es auch mo¨ glich, die Komponente f¨ur den Nachnamen als Konstante zu deklarieren, da sie vom Konstruktor initialisiert und sp¨ater nicht mehr ver¨andert wird:
// dyna/person2.hpp #ifndef PERSON_HPP #define PERSON_HPP // Headerdateien f¨ur Hilfsklassen
#include <string> // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Person { private: std::string vname; const std::string nname;
// Vorname der Person // Nachname der Person (neu: Konstante)
public: // Konstruktor aus Nachname und optional Vorname
Person (const std::string& nn, const std::string& vn = "") : nname(nn), vname(vn) { } // Abfragen von Eigenschaften
Sandini Bib
6.4 Klassen verwenden Klassen
const std::string& nachname () const { return nname; } const std::string& vorname () const { return vname; }
417 // Nachname liefern
// Vorname liefern
...
}; } // **** ENDE Namespace Bsp ******************************** #endif // PERSON_HPP Doch Vorsicht: Komponenten als Konstanten zu deklarieren bedeutet, dass sie wirklich nicht ver¨andert werden k¨onnen, solange das Objekt existiert. Das bedeutet insbesondere, dass keine Zuweisung mit dem Default-Zuweisungsoperator m¨oglich ist, der allen Komponenten neue Werte zuweist. Insofern ist die Deklaration des Nachnamens f¨ur die Klasse Person nicht sinnvoll. Ein typisches Beispiel f¨ur konstante Komponenten sind Objekt-IDs (siehe Abschnitt 6.5.1). Genauso ist es m¨oglich, dass eine Klasse Referenzen als Komponenten besitzt. Auch diese m¨ussen u¨ ber eine Initialisierungsliste initialisiert werden. Man muss dabei allerdings sicherstellen, dass das Objekt, f¨ur das die Referenz steht, nicht k¨urzer existiert als das Objekt, das die Referenz als Komponenten besitzt. Insofern kann man Komponenten eigentlich nur dann als Referenzen verwenden, wenn die Lebensdauer des referenzierten Objekts von dem Objekt, das die Referenz besitzt, kontrolliert wird oder wenn das referenzierte Objekt w¨ahrend der gesamten Laufzeit des Programms existiert.
6.4.3
Zusammenfassung
Komponenten einer Klasse k¨onnen einen beliebigen Datentyp besitzen. Ob es sich dabei um einen Datentyp mit dynamischen Komponenten handelt, spielt f¨ur die Implementierung der Klasse keine Rolle. Sofern nicht anders angegeben, wird f¨ur Teilobjekte abstrakter Datentypen jeweils der DefaultKonstruktor aufgerufen. Ist dieser nicht definiert oder aufrufbar, kann kein Objekt der Klasse angelegt werden. Initialisierungslisten erm¨oglichen es, den Konstruktoren f¨ur Teilobjekte Argumente zu u¨ bergeben, wodurch statt des Default-Konstruktors ein entsprechend anderer Konstruktor aufgerufen wird. Initialisierungslisten sind Zuweisungen vorzuziehen. Klassen k¨onnen auch Konstanten und Referenzen als Komponenten besitzen. Diese mu¨ ssen im Konstruktor mit Hilfe einer Initialisierungsliste initialisiert werden.
Sandini Bib
418
Kapitel 6: Dynamische und statische Komponenten
6.5 Statische Komponenten und Hilfstypen Anhand der im vorigen Abschnitt vorgestellten Beispielklasse Person wird hier nun gezeigt, dass Klassen einen eigenen G¨ultigkeitsbereich besitzen. Das kann dazu verwendet werden, klassenspezifische Variablen zu definieren, die keinem bestimmten Objekt zugeordnet sind. In dem Zusammenhang wird auch aufgezeigt, wie innerhalb von Klassen Typen und Hilfsklassen definiert werden k¨onnen. Semantisch wird die Klasse Person um eine ID, die als konstante Komponente, und eine Anrede, die als Aufz¨ahlungstyp implementiert wird, erweitert.
6.5.1
Statische Klassenkomponenten
Eine Personenverwaltung bietet ein typisches Beispiel f¨ur zwei Eigenschaften von Klassen, die immer wieder gebraucht werden:
eine eindeutige ID f¨ur die Objekte eine Information u¨ ber die Anzahl der existierenden Objekte
Beide Eigenschaften lassen sich allerdings nur u¨ ber Variablen implementieren, die nicht zu einem konkreten Objekt, sondern allgemein zur Klasse geh¨oren. Solche so genannte Klassenvariablen7 sind globale Variablen, die aber zum G¨ultigkeitsbereich der Klasse geh¨oren. In C++ k¨onnen Klassenvariablen durch die Deklaration von statischen Klassenkomponenten implementiert werden. Statische Klassenkomponenten werden mit dem Schl¨usselwort static in der Klassenstruktur deklariert. Die Variablen geh¨oren damit zum G¨ultigkeitsbereich der Klasse und m¨ussen von außen u¨ ber den Bereichsoperator angesprochen werden (was nat¨urlich nur m¨oglich ist, wenn sie als o¨ ffentliche Komponente deklariert werden). Es handelt sich aber im Gegensatz zu nicht-statischen Komponenten um Variablen, die nur einmal f¨ur die gesamte Programmlaufzeit und nicht f¨ur jedes einzelne Objekt der Klasse angelegt werden. Jedes Objekt der Klasse hat aber Zugriff auf diese Variablen. Die folgende Spezifikation deklariert f¨ur die im vorigen Abschnitt vorgestellte Klasse Person die statischen Klassenkomponenten personenMaxID als Z¨ahler zur Vergabe von eindeutigen Personen-IDs und personenAnzahl als Variable, in der die aktuelle Anzahl von existierenden Personen verwaltet wird:
// dyna/person3.hpp #ifndef PERSON_HPP #define PERSON_HPP // Headerdateien einbinden
#include <string> // **** BEGINN Namespace Bsp ********************************
namespace Bsp {
7
Die Bezeichnung Klassenvariable stammt aus Smalltalk.
Sandini Bib
6.5 Statische Komponenten und Hilfstypen
class Person { /* neu: statische Klassenkomponenten */ private: static long personenMaxID; // h¨ochste ID aller Personen static long personenAnzahl; // aktuelle Anzahl aller Personen public: // aktuelle Anzahl aller Personen liefern
static long anzahl () { return personenAnzahl; } // nicht-statische Klassenkomponenten
private: std::string vname; std::string nname; const long pid;
// Vorname der Person // Nachname der Person // neu: eindeutige ID der Person
public: // Konstruktor aus Nachname und optional Vorname
Person (const std::string&, const std::string& = ""); // neu: Copy-Konstruktor
Person (const Person&); // neu: Destruktor
~Person (); // neu: Zuweisung
Person& operator = (const Person&); // Abfragen von Eigenschaften
const std::string& nachname () const { // Nachname liefern return nname; } const std::string& vorname () const { // Vorname liefern return vname; } // neu: ID liefern long id () const { return pid; }
419
Sandini Bib
420
Kapitel 6: Dynamische und statische Komponenten
friend bool operator == (const Person& p1, const Person& p2) { return p1.vname == p1.vname && p2.nname == p2.nname; } friend bool operator != (const Person& p1, const Person& p2) { return !(p1==p2); } ...
}; } // **** ENDE Namespace Bsp ******************************** #endif // PERSON_HPP Entscheidend f¨ur die Tatsache, dass es sich bei personenMaxID und personenAnzahl nicht um Komponenten handelt, die jedes Objekt der Klasse f¨ur sich verwaltet, ist die Deklaration der Komponenten als statische Komponenten:
class Person { private: static long personenMaxID; // h¨ochste ID aller Personen static long personenAnzahl; // aktuelle Anzahl aller Personen ...
}; Das bedeutet, dass diese Variablen nur genau einmal f¨ur die Klasse Person existieren und von allen Objekten der Klasse gemeinsam verwendet werden. Auf statische Komponenten kann nicht nur in normalen“ Elementfunktionen, sondern auch ” in speziellen statischen Elementfunktionen (in der objektorientierten Terminologie auch Klassenmethoden genannt) zugegriffen werden:
namespace Bsp { class Person { ...
public: // aktuelle Anzahl aller Personen liefern static long anzahl () {
}
return personenAnzahl;
...
}
};
Auch hier handelt es sich im Prinzip um globale Funktionen, die allerdings nur zum G¨ultigkeitsbereich der Klasse Bsp::Person geh¨oren. Sie haben damit Zugriff auf private statische Komponenten der Klasse Person und besitzen im Gegensatz zu nicht-statischen Elementfunktionen den Vorteil, auch ohne Bindung an ein Objekt dieser Klasse aufgerufen werden zu k¨onnen.
Sandini Bib
6.5 Statische Komponenten und Hilfstypen
421
Das folgende Anwendungsprogramm soll dies verdeutlichen:
// dyna/ptest3.cpp #include #include "person.hpp" int main () { std::cout << "Personenanzahl: " << Bsp::Person::anzahl() << std::endl; Bsp::Person nico ("Josuttis", "Nicolai");
}
std::cout << "Personenanzahl: " << Bsp::Person::anzahl() << std::endl;
Die Personenanzahl wird hier ohne Bindung an ein konkretes Objekt der Klasse ermittelt, indem die statische Funktion anzahl() des G¨ultigkeitsbereichs Bsp::Person aufgerufen wird. Auf diese Weise kann die Anzahl auch ermittelt werden, ohne dass u¨ berhaupt eine Person existiert. Im objektorientierten Sinne bedeuten statische Komponenten, dass die Klassen selbst Attribute und Operationen besitzen und insofern als Objekte betrachtet werden k¨onnen, Eine statische Elementfunktion ist die Methode einer Klasse. Der Aufruf der statischen Elementfunktion ist eine Nachricht an diese Klasse, auf die mit der Methode reagiert wird. Wenn ein konkretes Objekt der Klasse Person existiert, dann ist auch der Aufruf u¨ ber ein solches Objekt m¨oglich:
Bsp::Person nico("Josuttis","Nicolai"); ...
std::cout << "Personenanzahl: " << nico.anzahl() << std::endl; Da dies allerdings dazu f¨uhrt anzunehmen, es sei eine bestimmte Eigenschaft einer konkreten Person angesprochen, ist von einem solchen Aufruf in der Regel abzuraten. Initialisierung von statischen Klassenkomponenten Statische Klassenkomponenten werden in Klassenstrukturen nur deklariert und m¨ussen, wie jede statische Variable, einmal definiert und gegebenenfalls initialisiert werden. Dies muss außerhalb der Klassenstruktur in einer Quelldatei (sinnvollerweise in der Quelldatei der Klasse) geschehen. Eine Initialisierung innerhalb der Klassenstruktur ist falsch. Betrachten wir dazu die Implementierung der Klasse Person:
// dyna/person3.cpp // Headerdatei der Klasse einbinden
#include "person.hpp"
Sandini Bib
422
Kapitel 6: Dynamische und statische Komponenten
// **** BEGINN Namespace Bsp ********************************
namespace Bsp { /* neu: statische Klassenkomponenten initialisieren */ long Person::personenMaxID = 0; long Person::personenAnzahl = 0; /* Konstruktor aus Nachname und Vorname * - Default f¨ur Vorname: "" * - Vor- und Nachname werden mit Initialisierungsliste initialisiert * - neu: Die ID wird ebenfalls direkt initialisiert */
Person::Person (const std::string& nn, const std::string& vn) : nname(nn), vname(vn), pid(++personenMaxID) { ++personenAnzahl; // Anzahl der existierenden Personen erh¨ohen } /* neu: Copy-Konstruktor */ Person::Person (const Person& p) : nname(p.nname), vname(p.vname), pid(++personenMaxID) { ++personenAnzahl; // Anzahl der existierenden Personen erh¨ohen } /* neu: Destruktor */ Person::~Person () { --personenAnzahl; // Anzahl der existierenden Personen herabsetzen } /* neu: Zuweisung */ Person& Person::operator = (const Person& p) { if (this == &p) { return *this; }
Sandini Bib
6.5 Statische Komponenten und Hilfstypen
423
// alles außer ID zuweisen
nname = p.nname; vname = p.vname; return *this;
}
} // **** ENDE Namespace Bsp ******************************** Auch hier gilt, dass die Variable f¨ur den entsprechenden G¨ultigkeitsbereich u¨ ber den Bereichsoperator angesprochen wird:
namespace Bsp { long Person::personenMaxID = 0; long Person::personenAnzahl = 0; ...
} Selbstverst¨andlich darf jede statische Klassenkomponente nur an einer Stelle im Programm initialisiert werden. Der Konstruktor initialisiert die Komponenten der Klasse Person u¨ ber die Initialisierungsliste:
Person::Person (const std::string& nn, const std::string& vn) : nname(nn), vname(vn), pid(++personenMaxID) { ++personenAnzahl; // Anzahl der existierenden Personen erh¨ohen } Die Komponente nname wird mit dem ersten Parameter, nn, und die Komponente vname wird mit dem zweiten Parameter, vn, initialisiert. Zur Initialisierung der neu hinzugekommenen Komponente pid wird die inkrementierte Klassenkomponente mit der bisher h¨ochsten Personen-ID, personenMaxID, verwendet. Da die Komponente pid konstant ist, muss dieser Wert mit Hilfe einer Initialisierungsliste initialisiert werden; eine nachtr¨agliche Zuweisung w¨are nicht m¨oglich. Im Funktionsk¨orper wird außerdem der Z¨ahler f¨ur die Anzahl der existierenden Personen entsprechend erh¨oht. Damit die Anzahl jeweils stimmt, muss der Z¨ahler f¨ur die Anzahl existierender Personen bei der Zerst¨orung eines Objekts der Klasse Person auch wieder herabgesetzt werden. Aus diesem Grund ist nun auch ein entsprechender Destruktor definiert:
Person::~Person () { --personenAnzahl; // Anzahl der existierenden Personen herabsetzen }
Sandini Bib
424
Kapitel 6: Dynamische und statische Komponenten
Der Destruktor wird f¨ur jedes Objekt aufgerufen, das existierte und zerst¨ort wird. Entsprechend muss auch wirklich f¨ur jedes Objekt, das angelegt wird, der Z¨ahler heraufgesetzt werden. Insbesondere darf man nicht vergessen, auch den Copy-Konstruktor zu definieren, damit auch, wenn ein Objekt der Klasse als Kopie eines existierenden Objekts angelegt wird, eine neue Objekt-ID vergeben und die Personenanzahl entsprechend erh¨oht wird.
Person::Person (const Person& p) : nname(p.nname), vname(p.vname), pid(++personenMaxID) { // Anzahl der existierenden Personen erh¨ohen
}
++personenAnzahl;
Ohne Implementierung des Copy-Konstruktors wird beim Kopieren eines Objekts die ID kopiert (damit h¨atten zwei Objekte die gleiche ID) und die Anzahl nicht erh¨oht (wohl aber bei der Zerst¨orung des Objekts vermindert). Außerdem muss der Zuweisungsoperator implementiert werden, damit nicht auch die ID zugewiesen wird. Er muss also so implementiert werden, dass alle Komponenten bis auf die ID zugewiesen werden:
const Person& Person::operator = (const Person& p) { if (this == &p) { return *this; } // alles außer ID zuweisen
nname = p.nname; vname = p.vname; }
return *this;
Da die ID als Konstante deklariert wurde, w¨are eine Zuweisung ansonsten gar nicht m¨oglich. Der Default-Zuweisungsoperator w¨urde auch die ID zuweisen, was aber ein Fehler ist.
6.5.2
Typdeklarationen innerhalb von Klassen
Die im vorigen Abschnitt vorgestellte M¨oglichkeit, in Klassen statische Komponenten und Funktionen zu definieren, deutet schon darauf hin, dass Klassen einen eigenen G¨ultigkeitsbereich besitzen. Dies kann auch dazu verwendet werden, klassenspezifische Typen zu definieren, die auf den G¨ultigkeitsbereich der Klasse beschr¨ankt sind. Das hat im Wesentlichen den Vorteil, dass globale Deklarationen vermieden werden. Der globale Namensbereich wird nicht unn¨otig mit Symbolen belastet, die zu Namenskonflikten f¨uhren k¨onnten. Es tr¨agt auch dem objektorientierten Ansatz Rechnung, eine Klasse als vollst¨andigen, in sich abgeschlossenen Bereich zu betrachten.
Sandini Bib
6.5 Statische Komponenten und Hilfstypen
425
Bei einer Klasse zur Personenverwaltung kann dies z.B. dazu verwendet werden, einen klassenspezifischen Aufz¨ahlungstyp (siehe Abschnitt 9.6.2) f¨ur die Anrede zu definieren. Die Klasse zur Personenverwaltung h¨atte dann z.B. folgende Spezifikation:
// dyna/person4.hpp #ifndef PERSON_HPP #define PERSON_HPP // Headerdateien einbinden
#include <string> // **** BEGINN Namespace Bsp ********************************
namespace Bsp { class Person { /* statische Klassenkomponenten */ private: static long personenMaxID; // h¨ochste ID aller Personen static long personenAnzahl; // aktuelle Anzahl aller Personen public: // aktuelle Anzahl aller Personen liefern
static long anzahl () { return personenAnzahl; } public: // neu: spezieller Aufz¨ahlungstyp f¨ur die Anrede
enum Anrede { herr, frau, firma, leer }; // nicht-statische Klassenkomponenten
private: Anrede std::string std::string const long
anr; vname; nname; pid;
// neu: Anrede (kann auch leer sein) // Vorname der Person // Nachname der Person // eindeutige ID der Person
public: // Konstruktor aus Nachname und optional Vorname
Person (const std::string&, const std::string& = ""); // Copy-Konstruktor
Person (const Person&);
Sandini Bib
426
Kapitel 6: Dynamische und statische Komponenten // Destruktor
~Person (); // Zuweisung
Person& operator = (const Person&); // Abfragen von Eigenschaften
const std::string& nachname () const { return nname; } const std::string& vorname () const { return vname; } long id () const { return pid; } const Anrede& anrede () const { return anr; }
// Nachname liefern
// Vorname liefern
// ID liefern
// neu: Anrede liefern // (eigener Aufz¨ahlungstyp)
// vergleichen
friend bool operator == (const Person& p1, const Person& p2) { return p1.vname == p1.vname && p2.nname == p2.nname; } friend bool operator != (const Person& p1, const Person& p2) { return !(p1==p2); } ...
}; } // **** ENDE Namespace Bsp ******************************** #endif // PERSON_HPP Der Aufz¨ahlungstyp Anrede wird einfach in der Klassendeklaration definiert und auch verwendet. Dies hat den Vorteil, dass f¨ur die verwendeten Symbole Anrede, herr, frau, firma und leer keine globalen Namenskonflikte drohen. Die o¨ ffentliche Deklaration erm¨oglicht es aber dennoch, diese Symbole außerhalb der Klasse anzusprechen. Dies geschieht mit dem Bereichsoperator, wie das folgende Anwendungsbeispiel zeigt:
// dyna/ptest4.cpp #include #include "person.hpp"
Sandini Bib
6.5 Statische Komponenten und Hilfstypen
427
int main () { Bsp::Person nico("Josuttis","Nicolai"); /* Variable vom Typ Anrede der Klasse Bsp::Person deklarieren * und mit dem Wert leer der Klasse Bsp::Person initialisieren */ Bsp::Person::Anrede keineAnrede = Bsp::Person::leer; ...
}
if (nico.anrede() == keineAnrede) { std::cout << "Anrede von Nico wurde nicht gesetzt" << std::endl; }
Wie die Deklaration von keineAnrede zeigt, ist die Qualifizierung mit dem Bereichsoperator sowohl bei der Verwendung des Datentyps als auch bei der Verwendung eines Werts notwendig, da sowohl der Datentyp als auch der Wert zum G¨ultigkeitsbereich der Klasse Person geh¨ort. Auf die gleiche Art und Weise k¨onnen in Klassen eigene Datentypen mit typedef angelegt werden.
6.5.3
Aufz¨ahlungstypen als statische Klassenkonstanten
Mitunter wird eine deklarierte statische Komponente noch in der Klassenstruktur ben¨otigt. Dies ist m¨oglich, wenn es sich um einen ganzzahligen Typ oder um einen Aufz¨ahlungstyp handelt:
class BspKlasse { private: static const int MAXANZ = 100; // OK, da ganzzahlig und konstant int elems[MAXANZ]; // OK ...
}; Diese M¨oglichkeit wurde erst recht sp¨at im Rahmen der Standardisierung eingef¨uhrt. Bis dahin half der Trick, einen Aufz¨ahlungstyp zu verwenden. Mitunter wird man also auch noch folgenden Code sehen:
class BspKlasse { private: enum { MAXANZ = 100 }; // OK int elems[MAXANZ]; // OK ...
}; Der Wert f¨ur einen Aufz¨ahlungstyp ist n¨amlich kein konstantes Objekt, sondern nur ein symbolischer Name, f¨ur den bei der Deklaration auch ein bestimmter Wert festgelegt werden darf.
Sandini Bib
428
Kapitel 6: Dynamische und statische Komponenten
6.5.4
Eingebettete und lokale Klassen
Die Tatsache, dass Klassen einen eigenen G¨ultigkeitsbereich besitzen, erm¨oglicht es auch, Klassen (oder Strukturen) auf den G¨ultigkeitsbereich einer Klasse einzuschr¨anken. Eine solche eingebettete Klasse (englisch member class oder nested class) wird z.B. wie folgt definiert:
class BspKlasse { public: /* eingebettete Hilfsklasse */ class Hilfsklasse { private: int x; public: void f(); ...
}; ...
}; Auf die Komponenten einer eingebetteten Klasse wird von ganz außen durch eine geschachtelte Qualifizierung mit dem Bereichsoperator zugegriffen. Die Funktion f() m¨usste z.B. wie folgt definiert werden:
void Klasse::Hilfsklasse::f () { ...
} Dies ist vor allem bei komplexen Klassen n¨utzlich, um bei der Definition dazugeh¨origer Hilfsklassen nicht den globalen Namensbereich mit Hilfsklassen zu belegen. Dieses Vorgehen vermeidet somit Namenskonflikte und unterst¨utzt die Datenkapselung (die Hilfsklassen k¨onnen auch als private deklariert werden). Ein typisches Beispiel f¨ur eingebettete Klassen sind Fehlerklassen, wie sie bei der Ausnahmebehandlung vorgestellt wurden (siehe Abschnitt 4.7). Es ist sogar m¨oglich, dass Klassen lokal f¨ur den G¨ultigkeitsbereich einer Funktion deklariert werden. Dies wird nur selten gebraucht. Man mache sich aber klar, dass eine Struktur in C++ nichts anderes als eine Klasse ist, die als Default o¨ ffentliche Komponenten besitzt. Vor diesem Hintergrund bedeutet das also, dass innerhalb von Funktionen lokale Strukturen deklariert werden k¨onnen, was schon etwas sinnvoller klingt. Vorw¨artsdeklaration von eingebetteten Klassen Die Komponenten einer eingebetteten Klasse k¨onnen auch außerhalb der umgebenden Klasse deklariert werden. Dadurch k¨onnen eingebettete Klassen sogar in anderen Modulen definiert werden.
Sandini Bib
6.5 Statische Komponenten und Hilfstypen
429
Das obige Beispiel sieht dann wie folgt aus:
class BspKlasse { public: /* eingebettete Hilfsklasse */ class Hilfsklasse; ...
}; class BspKlasse::Hilfsklasse { private: int x; public: void f(); ...
};
6.5.5
Zusammenfassung
Klassen k¨onnen statische Komponenten besitzen. Dabei handelt es sich um Objekte, die pro Klasse nur einmal existieren und von allen Objekten der Klasse gemeinsam verwendet werden. Sie entsprechen globalen Objekten, die auf den Namens- und G¨ultigkeitsbereich einer Klasse beschr¨ankt sind. Statische Klassenkomponenten m¨ussen außerhalb der Klassendeklaration genau einmal definiert und gegebenenfalls initialisiert werden. Statische Elementfunktionen erlauben es, auf statische Komponenten zuzugreifen, ohne dass ein dazugeh¨origes Objekt der Klasse angegeben werden muss. Sie entsprechen globalen Funktionen, die auf den Namens- und G¨ultigkeitsbereich einer Klasse beschr¨ankt sind. Mit Aufz¨ahlungstypen lassen sich statische Klassenkonstanten definieren, die auch zur Deklaration anderer Komponenten verwendet werden k¨onnen. Klassen bilden einen eigenen G¨ultigkeitsbereich, in dem es m¨oglich ist, eigene Datentypen wie Aufz¨ahlungstypen oder Hilfsklassen (eingebettete Klassen) zu definieren. F¨ur Objekt-IDs m¨ussen alle Konstruktoren (auch der Copy-Konstruktor) und der Zuweisungsoperator einer Klasse selbst definiert werden. Die Komponente f¨ur die ID sollte als Konstante deklariert werden. F¨ur Objektz¨ahler m¨ussen alle Konstruktoren (auch der Copy-Konstruktor) und der Destruktor einer Klasse selbst definiert werden.
Sandini Bib
Sandini Bib
Kapitel 7
Templates Dieses Kapitel stellt das Konzept der Templates vor. Mit Templates kann Quellcode f¨ur verschiedene Datentypen parametrisiert werden. Dadurch kann man eine Minimum-Funktion oder eine Mengenklasse so implementieren, dass der Datentyp der Parameter bzw. Elemente noch nicht festgelegt wird. Es handelt sich aber um keinen Code f¨ur beliebige Objekte; steht der Datentyp der Parameter bzw. Elemente fest, findet eine vollst¨andige Typpr¨ufung f¨ur alle Operationen statt. In diesem Kapitel werden zun¨achst Funktionstemplates vorgestellt. Es folgen Klassentemplates. Abgerundet wird das Kapitel durch Hinweise zum Umgang mit Templates in der Praxis und spezielle Design-Techniken in Verbindung mit Templates.
7.1 Motivation fur ¨ Templates In Programmiersprachen mit typgebundenen Variablen kommt es relativ oft vor, dass Funktionen, die das Gleiche machen, mehrfach definiert werden m¨ussen, da sich die Datentypen der Parameter unterscheiden. Ein typisches Beispiel ist eine Funktion, die das Maximum zweier Werte zur¨uckliefert. Sie muss f¨ur jeden Datentyp, f¨ur den das Maximum zweier Werte gebraucht wird, implementiert werden. In C kann dieser Zwang zwar durch die Definition von Makros umgangen werden; da ein Makro aber ein einfacher Textersatz ist, kann die Verwendung des Makros zu Problemen f¨uhren (¨uberhaupt keine Typpr¨ufung mehr, Seiteneffekte usw.). Der Aufwand f¨ur eine mehrfache Implementierung existiert nicht nur bei Funktionen, sondern auch bei speziellen Datentypen mit den dazugeh¨origen Funktionen, wie etwa bei Containerklassen. Wenn die Verwaltung f¨ur eine Menge von Objekten implementiert wird, muss immer festgelegt werden, welchen Datentyp diese Objekte haben. Wenn eine im Prinzip gleiche Mengenverwaltung f¨ur verschiedene Datentypen ben¨otigt wird, m¨usste ohne ein spezielles Sprachmittel also jedes Mal alles neu implementiert werden. Ein typisches Beispiel f¨ur die mehrfache Implementierung bei der Verwaltung unterschiedlicher Objekte ist die Implementierung eines Stacks (Kellers). Bei einem Stack m¨ussen Elemente eines bestimmten Typs ein- und wieder ausgekellert werden. Mit den bisherigen Sprachmitteln muss dies f¨ur jeden einzelnen Datentyp, f¨ur den ein Stack gebraucht wird, neu implementiert werden, da bei der Deklaration des Kellers der Datentyp der Objekte, die verwaltet werden, angegeben werden muss. Aus diesem Grund werden immer wieder Stacks neu implementiert, ob431
Sandini Bib
432
Kapitel 7: Templates
wohl der Algorithmus eigentlich jedes Mal gleich ist. Dies kostet nicht nur unn¨otig Zeit, sondern ist auch eine permanente Fehlerquelle. C++ besitzt aus diesem Grund das Sprachmittel der Templates (deutsch: Schablonen). Templates sind Funktionen und Klassen, die nicht f¨ur einen bestimmten Typ, sondern f¨ur einen noch festzulegenden Typ implementiert werden. Im Anwendungsprogramm kann dann eine solche Implementierung f¨ur einen bestimmten Datentyp, f¨ur den die Funktion oder Klasse gebraucht wird, realisiert werden. Auf diese Weise muss eine Maximum-Funktion f¨ur zwei Objekte mit beliebigem Typ nur einmal exemplarisch implementiert werden. Containerklassen wie Stacks, verkettete Listen usw. m¨ussen ebenfalls nur einmal implementiert und getestet werden und k¨onnen dann f¨ur jeden beliebigen Datentyp verwendet werden. Die Definition und Anwendung von Templates wird im Folgenden zun¨achst f¨ur Funktionen am Beispiel der Maximum-Funktion und dann f¨ur Klassen am Beispiel der Klasse Stack vorgestellt.
7.1.1
Terminologie
Auch beim Thema Templates sind die Begriffe nicht klar definiert. So wird z.B. bei einer Funktion, bei der Datentypen parametrisiert sind, mal von einem Funktionstemplate (englisch: function template), mal von einer Template-Funktion (englisch: template function) gesprochen. Da es sich aber eben noch nicht um eine fertige Funktion handelt, sollte man den Begriff Funktionstemplate verwenden. Analog sollte man bei einer Klasse, f¨ur die Datentypen parametrisiert sind, von einem Klassentemplate statt von einer Template-Klasse sprechen. Noch verwirrender ist die Verwendung des Wortes instantiieren. Im Umfeld von Templates wird damit der Vorgang bezeichnet, der aus Template-Code den tats¨achlich zu u¨ bersetzenden Code generiert. Diese Namensgebung ist insofern ungl¨ucklich, da dieser Begriff im objektorientierten Umfeld f¨ur das Anlegen von Objekten einer Klasse verwendet wird. Im Umfeld von C++ h¨angt die Bedeutung des Wortes instantiieren also immer vom genauen Kontext ab.
Sandini Bib
7.2 Funktionstemplates
433
7.2 Funktionstemplates Wie bereits in der Einf¨uhrung zu diesem Abschnitt erw¨ahnt wurde, dienen Funktionstemplates dazu, mehrere Funktionen, die sich nur im Datentyp ihrer Parameter unterscheiden, nur einmal zu definieren und sie dann f¨ur verschiedene Datentypen zu verwenden. Im Unterschied zu Makros sind Funktionstemplates aber kein blinder“ Textersatz, sondern ” Funktionen, die semantisch gepr¨uft und ohne unerw¨unschte Seiteneffekte kompiliert werden. Die Gefahr, dass ein Parameter n++ wie bei einem Makro mehrfach ersetzt wird und somit aus einer einfachen eine mehrfache Inkrementierung wird, besteht nicht.
7.2.1
Definition von Funktionstemplates
Definiert werden Funktionstemplates wie normale Funktionen f¨ur einen angenommenen Typ, der vor der Deklaration angegeben wird. Ein Maximum-Template kann z.B. wie folgt deklariert werden:
template const T& max (const T&, const T&); Die erste Zeile definiert T f¨ur die folgende Deklaration als Typparameter:
template Hier wird das Schl¨usselwort typename verwendet, das festlegt, dass es sich beim folgenden Symbol um einen Datentyp handelt. Dieses Schl¨usselwort wurde erst relativ sp¨at in C++ aufgenommen. Vorher hat man an dieser Stelle das Schl¨usselwort class verwendet:
template Semantisch gibt es an dieser Stelle zwischen diesen beiden W¨ortern keinen Unterschied. Auch bei der Verwendung des Schl¨usselwortes class muss der Datentyp nicht unbedingt eine Klasse, sondern kann auch ein fundamentaler oder ein mit typedef definierter Datentyp sein. Die Verwendung des Symbols T f¨ur einen Template-Typ ist dabei nicht zwingend, aber sehr typisch. In der folgenden Deklaration kann (und muss) T als Datentyp in einer ParameterDeklaration verwendet werden und steht dann f¨ur den Typ des Parameters, der beim Aufruf u¨ bergeben wird. Die Definition wird entsprechend implementiert:
// tmpl/max1.hpp template const T& max (const T& a, const T& b) { return (a > b ? a : b); } Die Anweisungen innerhalb der Funktion unterscheiden sich in keiner Weise von Anweisungen anderer Funktionen. In diesem Fall wird also das Maximum zweier Werte eines angenommenen Typs T unter Anwendung des Vergleichsoperators zur¨uckgeliefert. Wie in Kapitel 4.4 bereits erw¨ahnt wurde, wird durch die Verwendung von konstanten Referenzen (const T&) das Anlegen ¨ von Kopien bei der Ubergabe der Parameter und des R¨uckgabewerts verhindert.
Sandini Bib
434
7.2.2
Kapitel 7: Templates
Aufruf von Funktionstemplates
Angewendet wird ein Funktionstemplate wie jede andere Funktion. Dies zeigt folgendes Beispielprogramm:
// tmpl/max1.cpp #include #include <string> #include "max.hpp" int main() { int a, b; // zwei Variablen vom Datentyp int std::string s, t; // zwei Variablen der Klasse std::string ...
}
std::cout << max(a,b) << std::endl; std::cout << max(s,t) << std::endl;
// max() f¨ur zwei ints // max() f¨ur zwei Strings
In dem Moment, in dem die Funktion max() f¨ur zwei Objekte eines Typs aufgerufen wird, wird die Schablone zur Realit¨at. Das bedeutet, der Compiler verwendet die Template-Definition und realisiert sie, indem er f¨ur T jeweils int bzw. std::string einsetzt und entsprechenden Code erzeugt. Ein Template wird also nicht als Code u¨ bersetzt, der mit beliebigen Typen umgehen kann, sondern nur als Schablone intern festgehalten, die dazu dient, im Anwendungsfall f¨ur die entsprechenden Typen jeweils den entsprechenden Code zu realisieren. Wird max() f¨ur sieben verschiedene Typen aufgerufen, werden also auch sieben Funktionen kompiliert. Den Vorgang, bei dem der zu u¨ bersetzende Code aus dem Template-Code generiert wird, nennt man auch Instantiierung. Diese Namensgebung ist insofern ungl¨ucklich, da dieser Begriff im objektorientierten Umfeld f¨ur das Anlegen von Objekten einer Klasse verwendet wird. Ich werde diesen Vorgang deshalb im weiteren Verlauf des Buches auch weiterhin mitunter als Realisierung bezeichnen. Aufruf und Realisierung sind nur m¨oglich, wenn alle in dem Funktionstemplate angewendeten Operationen f¨ur den Datentyp der Parameter auch definiert sind. Um die Maximum-Funktion f¨ur Objekte der Klasse std::string aufrufen zu k¨onnen, muss f¨ur Strings also der Vergleichsoperator < definiert sein. Man beachte, dass Templates im Gegensatz zu Makros kein blinder Textersatz sind. Der Aufruf
max (x++, z *= 2); ist zwar nicht sehr sinnvoll, funktioniert aber. Es wird wirklich das Maximum der beiden als Argumente angegebenen Ausdr¨ucke geliefert, und jeder Ausdruck wird nur einmal ausgewertet (x also z.B. nur einmal inkrementiert).
Sandini Bib
7.2 Funktionstemplates
7.2.3
435
Praktische Hinweise zum Umgang mit Templates
¨ Templates sprengen das herk¨ommliche Compiler/Linker-Modell mit getrennten Ubersetzungseinheiten. Man kann nicht einfach Templates in einem Modul definieren und u¨ bersetzen und getrennt davon die Anwendung eines Templates u¨ bersetzen und dann beides zusammenbinden. Es ist vielmehr so, dass erst bei der Anwendung von Templates klar ist, f¨ur welche Datentypen ein Template u¨ bersetzt werden muss. Es gibt unterschiedliche M¨oglichkeiten, mit diesem Problem umzugehen. Die einfachste und portabelste M¨oglichkeit ist dabei die, s¨amtlichen Template-Code in Headerdateien unterzubringen. Durch die Einbindung der Headerdateien bei der Anwendung ist sichergestellt, dass der ¨ Code zum Ubersetzen f¨ur die dann feststehenden Datentypen auch zur Verf¨ugung steht. Insofern ist es kein Zufall, dass die Definition des max()-Templates im vorliegenden Beispiel in einer Headerdatei stand. Bemerkenswert ist dabei allerdings, dass das Wort inline (siehe Abschnitt 4.3.3) nicht angegeben werden muss. Funktionstemplates sind implizit inline. Auf weitere Aspekte des Umgangs mit Templates in der Praxis wird in Abschnitt 7.6 noch eingegangen.
7.2.4
Automatische Typumwandlung bei Templates
Bei der Kl¨arung des Datentyps eines Template-Parameters ist keine automatische Typumwandlung m¨oglich. Falls in einem Funktionstemplate also mehrere Parameter vom Typ T deklariert werden, m¨ussen die u¨ bergebenen Argumente den gleichen Datentyp besitzen. Ein Aufruf der Maximum-Funktion mit zwei Objekten verschiedenen Typs ist also nicht m¨oglich:
template const T& max (const T&, const T&); // beide Parameter haben Typ T ...
int i; long l; ...
max(i,l)
// FEHLER: i und l haben verschiedene Datentypen
Explizite Qualifizierung Man kann beim Aufruf von Templates aber durch eine explizite Qualifizierung festlegen, f¨ur welchen Datentyp ein Template verwendet werden soll:
max(i,l)
// OK, ruft max() mit long als T auf
In diesem Fall wird das Funktionstemplate max<>() f¨ur den Typ long als T realisiert. Nun wird wie bei herk¨ommlichen Funktionen nur noch gepr¨uft, ob die u¨ bergebenen Argumente als long verwendet werden k¨onnen, was durch eine implizite Typumwandlung m¨oglich ist. Templates mit mehreren Parametern Man k¨onnte alternativ auch das Template so definieren, dass es Parameter unterschiedlichen Typs erlaubt:
Sandini Bib
436
Kapitel 7: Templates
template T1 max (const T1&, const T2&); // Parameter k¨onnen unterschiedliche Typen haben ...
int i; long l; ...
max(i,l)
// OK: liefert int
Das Problem ist dabei, dass man sich in diesem Fall beim R¨uckgabetyp f¨ur einen der Parametertypen entscheiden. Dies ist in diesem Beispiel schlecht, da man nicht sagen kann, welcher der beiden Argumenttypen m¨achtiger ist und sicherlich diesen Datentyp zur¨uckliefern sollte. Außerdem wird durch eine Typumwandlung des zweiten Parameters in den R¨uckgabetyp ein neues lokales tempor¨ares Objekt erzeugt. Dies darf aber nicht als Referenz zur¨uckgeliefert werden. Der R¨uckgabetyp muss deshalb wie hier angegeben T statt const T& lauten. Insofern ist die M¨oglichkeit der expliziten Qualifizierung in diesem Fall sicherlich besser.
7.2.5
¨ Uberladen von Templates
Templates k¨onnen f¨ur bestimmte Datentypen u¨ berladen werden. Damit ist es m¨oglich, eine Template-Implementierung f¨ur bestimmte Datentypen durch eine andere Implementierung zu ersetzen. Daraus ergeben sich mehrere Vorteile:
Funktionstemplates k¨onnen f¨ur andere Datentyp-Kombinationen aufrufbar gemacht werden (es k¨onnte z.B. eine Funktion f¨ur das Maximum von float und Bsp::Bruch definiert werden). Implementierungen k¨onnen f¨ur einen speziellen Datentyp optimiert werden. Datentypen, f¨ur die die Template-Implementierung untauglich ist, k¨onnen eine korrekte Implementierung erhalten.
Beim Beispiel des Funktionstemplates f¨ur das Maximum w¨are beispielsweise ein Aufruf mit C-Strings (Datentyp const char*) fehlerhaft:
const char* s1; const char* s2; ...
const char* maxstring = max(s1,s2); // FEHLER: vergleicht Adressen Im Template werden n¨amlich mit > die Adressen und nicht die Inhalte der Strings verglichen (siehe Abschnitt 3.7.3), was bei C-Strings sicherlich nicht sinnvoll ist. ¨ Das Problem wird durch das Uberladen des Templates f¨ur C-Strings behoben:
inline const char* max (const char* a, const char* b) { return (std::strcmp(a,b) > 0 ? a : b); }
Sandini Bib
7.2 Funktionstemplates
437
¨ Ein Uberladen w¨are in diesem Fall auch f¨ur Zeiger im Allgemeinen m¨oglich. Auf diese Weise k¨onnte man festlegen, dass beim Aufruf von max() f¨ur Zeiger nicht die Zeiger selbst (also ihre Adressen) verglichen werden, sondern die Werte der Objekte, auf die sie verweisen. Dies s¨ahe wie folgt aus:
template T* const & max (T* const & a, T* const & b) { return (*a > *b ? a : b); } ¨ Man beachte, dass zur Ubergabe des Zeigers als konstante Referenz das const hinter dem Stern angegeben werden muss. Ansonsten handelt es sich um einen Zeiger, der auf eine Konstante verweist (siehe auch Seite 199). Verwendet man also folgende u¨ berladene Funktionen:
// tmpl/max2.hpp #include #include template const T& max (const T& a, const T& b) { std::cout << "max<>() fuer T" << std::endl; return (a > b ? a : b); } template T*const& max (T*const& a, T*const& b) { std::cout << "max<>() fuer T*" << std::endl; return (*a > *b ? a : b); } inline const char* max (const char* a, const char* b) { std::cout << "max<>() fuer char*" << std::endl; return (std::strcmp(a,b) > 0 ? a : b); } und ruft diese durch folgendes Programm auf:
// tmpl/max2.cpp #include
Sandini Bib
438
Kapitel 7: Templates
#include <string> #include "max.hpp" int main () { int a=7; int b=11; std::cout << max(a,b) << std::endl;
// zwei Variablen vom Datentyp int // max() f¨ur zwei ints
std::string s="hallo"; // zwei Strings std::string t="holla"; std::cout << ::max(s,t) << std::endl; // max() f¨ur zwei Strings int* p1 = &b; // zwei Zeiger int* p2 = &a; std::cout << *max(p1,p2) << std::endl; // max() f¨ur zwei Zeiger
}
const char* s1 = "hallo"; // zwei C-Strings const char* s2 = "otto"; std::cout << max(s1,s2) << std::endl; // max() f¨ur zwei C-Strings
erh¨alt man folgende Ausgabe:
max<>() 11 max<>() holla max<>() 11 max<>() otto
fuer T fuer T fuer T* fuer char*
Der Aufruf von max() f¨ur strings wird explizit f¨ur den globalen Namensbereich qualifiziert, da auch in der Standardbibliothek ein std::max() definiert wird. Da strings zu std geh¨oren, wird ohne Qualifizierung auch diese Funktion gefunden, was zu einer entsprechenden Mehrdeutigkeit f¨uhrt (siehe Koenig-Lookup auf Seite 184). Als Zeiger-Variante kann man auch einfach folgende Funktion definieren:
template T* max (T* a, T* b) { return (*a > *b ? a : b); }
Sandini Bib
7.2 Funktionstemplates
439
Allerdings haben einige Compiler noch das Problem, dass sie beim Aufruf von max() mit zwei Zeigern diese Version und die Version f¨ur const T& als gleichwertig ansehen und eine Mehrdeutigkeit melden. Dies ist aber ein Fehler der Compiler.
7.2.6
Lokale Variablen
Funktionstemplates d¨urfen intern beliebige Variablen des Schablonentyps besitzen. So kann z.B. ein Funktionstemplate, das die Werte zweier Parameter vertauscht, wie folgt implementiert werden1 :
template void swap (T& a, T& b) { T tmp(a); a = b; b = tmp; } Die lokalen Variablen d¨urfen auch statisch sein. In dem Fall wird f¨ur jeden Typ, f¨ur den die Funktion aufgerufen wird, im Programm eine statische Variable angelegt.
7.2.7
1
Zusammenfassung
Templates sind Schablonen f¨ur Code, der nach der Festlegung der tats¨achlichen Datentypen u¨ bersetzt wird. Das Generieren von u¨ bersetzbarem Code aus Template-Code nennt man in C++ instantiieren. Templates d¨urfen mehrere Template-Parameter besitzen. Funktionstemplates d¨urfen u¨ berladen werden.
Vgl. die Implementierung der Funktion swap() auf Seite 191
Sandini Bib
440
Kapitel 7: Templates
7.3 Klassentemplates Genauso wie Typen von Funktionen parametrisiert werden k¨onnen, ist es auch m¨oglich, einen oder mehrere Typen in Klassen zu parametrisieren. Dies ist vor allem bei Containerklassen sinnvoll, die dazu dienen, andere Objekte zu verwalten. Klassentemplates bieten die M¨oglichkeit, diese zu implementieren, obwohl der Typ der verwalteten Objekte noch nicht feststeht. Typische Beispiele f¨ur Klassentemplates sind Klassen zur Mengenverwaltung, Stacks, verkettete Listen und so weiter. Im Rahmen der objektorientierten Modellierung werden Templates auch als parametrisierbare Klasse bezeichnet. Nachfolgend wird die Implementierung eines Klassentemplates am Beispiel einer Klasse f¨ur Stacks betrachtet. Diese verwendet intern ein Klassentemplate der Standardbibliothek, vector<> (siehe Abschnitt 3.5.1 und Abschnitt 9.1.1).
7.3.1
Implementierung des Klassentemplate Stack
Auch f¨ur das Klassentemplate f¨ur Stacks gilt, dass sich die Deklaration und die Definition in einer Headerdatei befinden:
// tmpl/stack1.hpp #include namespace Bsp { // ******** Beginn Namensbereich Bsp:: template class Stack { private: std::vector elems;
};
public: Stack(); void push(const T&); T pop(); T top() const;
// Konstruktor
template Stack::Stack () { // nichts mehr zu tun
}
// Elemente
// Konstruktor // Element einkellern // Element auskellern // oberstes Element
Sandini Bib
7.3 Klassentemplates
441
template void Stack::push (const T& elem) { // Kopie einkellern elems.push_back(elem); } template T Stack::pop () { if (elems.empty()) { throw "Stack<>::pop(): der Stack ist leer"; } // oberstes Element merken T elem = elems.back(); elems.pop_back(); // oberstes Element auskellern return elem; // gemerktes oberstes Element zur¨uckliefern } template T Stack::top () const { if (elems.empty()) { throw "Stack<>::top(): der Stack ist leer"; } // oberstes Element als Kopie zur¨uckliefern return elems.back(); } } // ******** Ende Namensbereich Bsp:: Deklaration des Klassentemplates Beim Template-Code f¨ur Klassen gilt zun¨achst das Gleiche wie bei Funktionstemplates: Die Deklaration wird von einer Anweisung angef¨uhrt, in der T zum Typparameter erkl¨art wird (selbstverst¨andlich k¨onnen auch bei Klassentemplates mehrere Typparameter definiert werden):
template class Stack { ...
}; Auch hier kann statt typename alternativ das Schl¨usselwort class verwendet werden:
template class Stack { ...
};
Sandini Bib
442
Kapitel 7: Templates
In der Klasse kann dann der Typ T wie jeder andere Datentyp zur Deklaration von Klassenkomponenten und Elementfunktionen verwendet werden. Im vorliegenden Beispiel wird festgelegt, dass die Elemente des Stacks intern mit Hilfe eines Vektors f¨ur Elemente vom Typ T verwaltet werden (das Template wird also mit Hilfe eines anderen Templates programmiert); die Funktion push() verwendet eine konstante T-Referenz als Parameter, und pop() und top() liefern ein Objekt vom Typ T zur¨uck. Der Datentyp der Klasse ist Stack, wobei T ein Template-Parameter ist. Deshalb muss dieser Datentyp auch u¨ berall dort verwendet werden, wo der Datentyp der Klasse angegeben werden muss. Nur bei der Angabe des Klassennamens
class Stack { ...
}; sowie bei den Namen der Konstruktoren und des Destruktors muss nur Stack angegeben werden, da eindeutig ist, was gemeint ist. Bei Parametern und R¨uckgabewerten besteht diese Eindeutigkeit nicht. Sollten diese angegeben werden m¨ussen, s¨ahe dies am Beispiel der Deklaration von Copy-Konstruktor und Zuweisungsoperator wie folgt aus:2
template class Stack { ...
Stack (const Stack&); Stack& operator= (const Stack&);
// Copy-Konstruktor // Zuweisungsoperator
...
}; Implementierung der Elementfunktionen Bei der Definition der Elementfunktionen zu einem Klassentemplate muss ebenfalls jeweils angegeben werden, dass es sich um Funktionstemplates handelt. Auch diese Funktionen sind nur Schablonen, die bei einer Realisierung der Klasse zu entsprechendem Code f¨uhren. Daher muss, wie das Beispiel push() zeigt, auch der vollst¨andige Datentyp Stack zur Qualifizierung verwendet werden:
template void Stack::push (const T& elem) { // Kopie einkellern elems.push_back(elem); } 2
Es gibt im Standard allerdings einige Regelungen, die festlegen, wo in einer Klassendeklaration die Angabe von Stack statt Stack reicht. Wer wie ich sicher gehen will, verwendet Stack im Zweifelsfall u¨ berall, wo der Datentyp der Klasse ben¨otigt wird.
Sandini Bib
7.3 Klassentemplates
443
Die Funktionen werden dabei auf entsprechende Funktionen des Vektors umgesetzt. Das oberste Element befindet sich immer hinten. Man beachte, dass pop_back() zwar das hinterste Element entfernt, es aber nicht zur¨uckliefert. Insofern muss dieses Element vor dem Entfernen zwischengespeichert werden:
template T Stack::pop () { if (elems.empty()) { throw "Stack<>::pop(): der Stack ist leer"; } // oberstes Element merken T elem = elems.back(); elems.pop_back(); // oberstes Element auskellern return elem; // gemerktes oberstes Element zur¨uckliefern } Außerdem muss beachtet werden, dass beim Vektor die Elementfunktionen pop_back() und back() (letztere liefert das letzte Element) ein undefiniertes Verhalten haben, wenn der Vektor keine Elemente enth¨alt (siehe Seite 523 und Seite 525). Insofern wird dies bei beiden Funktionen gepr¨uft, und gegebenenfalls eine Ausnahme vom Typ const char* ausgel¨ost:
template T Stack::top () const { if (elems.empty()) { throw "Stack<>::top(): der Stack ist leer"; } // oberstes Element als Kopie zur¨uckliefern return elems.back(); } Selbstverst¨andlich k¨onnten die Funktionen auch innerhalb der Klassendeklaration implementiert werden:
template class Stack { ...
void push (const T& elem) { elems.push_back(elem); } ...
};
// Kopie einkellern
Sandini Bib
444
7.3.2
Kapitel 7: Templates
Anwendung des Klassentemplate Stack
Wenn ein Objekt des Klassentemplates deklariert oder definiert wird, muss jeweils explizit angegeben werden, welcher Typ als Parameter verwendet werden soll:
// tmpl/stest1.cpp #include #include #include #include
<string> "stack.hpp"
int main() { try { Bsp::Stack intStack; Bsp::Stack<std::string> stringStack;
// Stack f¨ur Integer // Stack f¨ur Strings
// Integer-Stack manipulieren
intStack.push(7); std::cout << intStack.pop() << std::endl; // String-Stack manipulieren
std::string s = "hallo"; stringStack.push(s); std::cout << stringStack.pop() << std::endl; std::cout << stringStack.pop() << std::endl;
}
} catch (const char* msg) { std::cerr << "Exception: " << msg << std::endl; return EXIT_FAILURE; }
Auch hier wird mit der Deklaration die Schablone f¨ur den entsprechenden Datentyp realisiert. Mit der Verwendung von stringStack wird f¨ur die Klasse und alle aufgerufenen Elementfunktionen entsprechender Code f¨ur den Typ std::string generiert. Die Betonung liegt dabei auf alle aufgerufenen Elementfunktionen“. Bei Klassentemplates ” gilt die Regel, dass nur f¨ur die Funktionen Template-Code generiert wird, die auch aufgerufen werden. Dies hat nicht nur den Vorteil, Zeit und Platz zu sparen. Man kann Klassentemplates auch f¨ur Datentypen verwenden, die gar nicht alle F¨ahigkeiten aller Elementfunktionen haben, solange diese nicht wirklich ben¨otigt werden. Ein Beispiel w¨are eine Klasse, in der manche Elementfunktionen mit dem Operator < sortieren. Solange diese Elementfunktionen nicht aufge-
Sandini Bib
7.3 Klassentemplates
445
rufen werden, muss der als Template-Parameter u¨ bergebene Datentyp auch nicht den Operator < definiert haben. Insgesamt wird in diesem Fall also f¨ur zwei Klassen Code generiert. Besitzt ein Klassentemplate statische Komponenten, werden entsprechend auch zwei verschiedene statische Komponenten angelegt. Ein Klassentemplate bildet f¨ur jeden eingesetzten Datentyp einen eigenen Typ, der u¨ berall verwendet werden kann:
void foo (const Bsp::Stack& s) // Parameter s ist int-Stack { Bsp::Stack istack[10]; // istack ist ein Feld von zehn int-Stacks ...
} Dabei wird in der Praxis h¨aufig eine Typedef-Anweisung verwendet, um die Anwendung von Klassentemplates lesbarer (und flexibler) zu halten:
typedef Bsp::Stack IntStack; void foo (const IntStack& s) // Parameter s ist int-Stack { IntStack istack[10]; // istack ist ein Feld von zehn int-Stacks ...
} Die Template-Argumente k¨onnen beliebige Datentypen sein, beispielsweise float-Zeiger oder sogar ein Stack von Stacks f¨ur ints:
Bsp::Stack floatPtrStack; // Stack von float-Zeigern Bsp::Stack > intStackStack; // Stack von int-Stacks Entscheidend ist, dass alle aufgerufenen Operationen f¨ur diese Datentypen erlaubt sind. Man beachte, dass zwei aufeinander folgende schließende Template-Klammern durch ein Trennzeichen getrennt werden m¨ussen. Ansonsten handelt es sich um den Operator >>, der an dieser Stelle einen Syntaxfehler ausl¨osen w¨urde:
Bsp::Stack> intStackStack;
7.3.3
// FEHLER: >> nicht erlaubt
Spezialisieren von Klassentemplates
Klassentemplates k¨onnen spezialisiert werden. Das bedeutet, das man ein Klassentemplate f¨ur ¨ spezielle Datentypen individuell implementiert. Wie beim Uberladen von Funktionstemplates (siehe Seite 436) ist dies auch wieder sinnvoll, um Implementierungen f¨ur bestimmte Datentypen zu optimieren oder um bei Datentypen, f¨ur die die Template-Implementierung zu einem Fehlverhalten f¨uhrt, dieses Fehlverhalten zu vermeiden. Man kann allerdings nicht nur einzelne Elementfunktionen spezialisieren, sondern muss die Klasse als Ganzes spezialisieren.
Sandini Bib
446
Kapitel 7: Templates
F¨ur eine explizite Spezialisierung muss man vor der Klassendeklaration template<> schreiben und den festgelegten Template-Datentyp hinter dem Klassennamen angeben:
template<> class Stack<std::string> { ...
}; Jede Elementfunktion muss entsprechend mit template<> beginnen, und T muss jeweils durch den festgelegten Template-Datentyp ersetzt werden:
template<> void Stack<std::string>::push (const std::string& elem) { // Kopie einkellern elems.push_back(elem); } Hier ist ein vollst¨andiges Beispiel einer Spezialisierung des Klassentemplates Stack<> f¨ur den Datentyp std::string:
// tmpl/stack2.hpp #include <deque> #include <string> namespace Bsp { // ******** Beginn Namensbereich Bsp:: template<> class Stack<std::string> { private: std::deque<std::string> elems;
};
public: Stack() { } void push(const std::string&); std::string pop(); std::string top() const;
// Elemente
// Konstruktor // Element einkellern // Element auskellern // oberstes Element
template<> void Stack<std::string>::push (const std::string& elem) { // Kopie einkellern elems.push_back(elem); }
Sandini Bib
7.3 Klassentemplates
447
template<> std::string Stack<std::string>::pop () { if (elems.empty()) { throw "Stack<std::string>::pop(): der Stack ist leer"; } std::string elem = elems.back(); // oberstes Element merken elems.pop_back(); // oberstes Element auskellern return elem; // gemerktes oberstes Element zur¨uckliefern } template<> std::string Stack<std::string>::top () const { if (elems.empty()) { throw "Stack<std::string>::top(): der Stack ist leer"; } // oberstes Element als Kopie zur¨uckliefern return elems.back(); } } // ******** Ende Namensbereich Bsp:: In diesem Fall wurde f¨ur Strings der interne Vektor f¨ur die Elemente durch eine Deque ersetzt. Dies hat zwar keinen entscheidenden Vorteil, doch zeigt es, dass die Implementierung eines Klassentemplates f¨ur einen speziellen Datentyp v¨ollig anders aussehen kann. Die in der Standardbibliothek definierten numerischen Limits sind ein weiteres Beispiel f¨ur die Verwendung von Template-Spezialisierungen (siehe Abschnitt 9.1.4). Partielle Spezialisierungen Man kann Templates auch nur teilweise spezialisieren (partiell spezialisieren). Zum Klassentemplate
template class MeineKlasse { ...
}; kann es z.B. folgende partielle Spezialisierungen geben: // partielle Spezialisierung: beide Typen sind gleich
template class MeineKlasse { ...
};
Sandini Bib
448
Kapitel 7: Templates
// partielle Spezialisierung: der zweite Typ ist ein int
template class MeineKlasse { ...
}; // partielle Spezialisierung: beide Typen sind Zeiger
template class MeineKlasse { ...
}; Diese Templates werden nun wie folgt angesprochen:
MeineKlasse mif; MeineKlasse mff; MeineKlasse mfi; MeineKlasse