Beruflich Dokumente
Kultur Dokumente
Programmer’s Choice
Sandini Bib
Nicolai Josuttis
Objektorientiertes
Programmieren in C++
Ein Tutorial für Ein- und Umsteiger
Die Informationen in diesem Produkt werden ohne Rücksicht auf einen eventuellen Patentschutz veröffentlicht.
Warennamen werden ohne Gewährleistung der freien Verwendbarkeit benutzt. Bei der Zusammenstellung von Texten
und Abbildungen wurde mit größter Sorgfalt vorgegangen. Trotzdem können Fehler nicht vollständig ausgeschlossen
werden. Verlag, Herausgeber und Autoren können für fehlerhafte Angaben und deren Folgen weder eine juristische
Verantwortung noch irgendeine Haftung übernehmen. Für Verbesserungsvorschläge 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ässig.
Fast alle Hardware- und Softwarebezeichnungen, die in diesem Buch erwähnt 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äglichem und recyclingfähigem PE-Material.
10 9 8 7 6 5 4 3 2 1
03 02 01
ISBN 3-8273-1771-1
Titelbild: Euphorbia pithyus, Wolfsmilch,
c Karl Blossfeld Archiv – Ann und Jürgen Wilde, Zülpich/VG Bild-Kunst Bonn, 2001
Lektorat: Susanne Spitzer, susanne.spitzer@gmx.net
Korrektorat: Friederike Daenecke, Zülpich
Herstellung: TYPisch Müller, Arcevia, Italien, typmy@freefast.it
Satz: Nicolai Josuttis, Braunschweig
Druck und Verarbeitung: Kösel, Kempten (www.KoeselBuch.de)
Printed in Germany
Sandini Bib
Inhaltsverzeichnis
v
Sandini Bib
vi Inhaltsverzeichnis
Inhaltsverzeichnis vii
3.5.9 Stream-Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
3.5.10 Ausblick . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
3.5.11 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
3.6 Ausnahmebehandlung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
3.6.1 Motivation für das Konzept der Ausnahmebehandlung . . . . . . . . . . 98
3.6.2 Das Konzept der Ausnahmebehandlung . . . . . . . . . . . . . . . . . 100
3.6.3 Standard-Ausnahmeklassen . . . . . . . . . . . . . . . . . . . . . . . . 101
3.6.4 Ausnahmebehandlung am Beispiel . . . . . . . . . . . . . . . . . . . . 101
3.6.5 Behandlung nicht abgefangener Ausnahmen . . . . . . . . . . . . . . . 106
3.6.6 Hilfsfunktionen zur Behandlung von Ausnahmen . . . . . . . . . . . . 107
3.6.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 108
3.7 Zeiger, Felder und C-Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
3.7.1 Zeiger . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
3.7.2 Felder . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
3.7.3 C-Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115
3.7.4 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 119
3.8 Freispeicherverwaltung mit new und delete . . . . . . . . . . . . . . . . . . . 120
3.8.1 Der Operator new . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
3.8.2 Der Operator delete . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
3.8.3 Dynamische Speicherverwaltung für Felder . . . . . . . . . . . . . . . 122
3.8.4 Fehlerbehandlung für new . . . . . . . . . . . . . . . . . . . . . . . . . 124
3.8.5 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125
3.9 Kommunikation mit der Außenwelt . . . . . . . . . . . . . . . . . . . . . . . . 126
3.9.1 Argumente aus dem Programmaufruf . . . . . . . . . . . . . . . . . . . 126
3.9.2 Zugriff auf Umgebungsvariablen . . . . . . . . . . . . . . . . . . . . . 127
3.9.3 Abbruch von Programmen . . . . . . . . . . . . . . . . . . . . . . . . 128
3.9.4 Aufruf von weiteren Programmen . . . . . . . . . . . . . . . . . . . . 129
3.9.5 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
viii Inhaltsverzeichnis
Inhaltsverzeichnis ix
x Inhaltsverzeichnis
Inhaltsverzeichnis xi
7 Templates 431
7.1 Motivation für Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 431
7.1.1 Terminologie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 432
7.2 Funktionstemplates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 433
7.2.1 Definition von Funktionstemplates . . . . . . . . . . . . . . . . . . . . 433
7.2.2 Aufruf von Funktionstemplates . . . . . . . . . . . . . . . . . . . . . . 434
7.2.3 Praktische Hinweise zum Umgang mit Templates . . . . . . . . . . . . 435
7.2.4 Automatische Typumwandlung bei Templates . . . . . . . . . . . . . . 435
7.2.5 Überladen von Templates . . . . . . . . . . . . . . . . . . . . . . . . . 436
7.2.6 Lokale Variablen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 439
7.2.7 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 439
7.3 Klassentemplates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 440
7.3.1 Implementierung des Klassentemplate Stack . . . . . . . . . . . . . . 440
7.3.2 Anwendung des Klassentemplate Stack . . . . . . . . . . . . . . . . . 444
7.3.3 Spezialisieren von Klassentemplates . . . . . . . . . . . . . . . . . . . 445
7.3.4 Default Template-Parameter . . . . . . . . . . . . . . . . . . . . . . . 448
7.3.5 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 451
7.4 Werte als Template-Parameter . . . . . . . . . . . . . . . . . . . . . . . . . . . 452
7.4.1 Beispiel für die Verwendung von Werte-Parametern . . . . . . . . . . . 452
7.4.2 Einschränkungen bei Werte-Parametern . . . . . . . . . . . . . . . . . 454
7.4.3 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 455
7.5 Weitere Aspekte von Templates . . . . . . . . . . . . . . . . . . . . . . . . . . 456
7.5.1 Das Schlüsselwort typename . . . . . . . . . . . . . . . . . . . . . . . 456
7.5.2 Komponenten als Templates . . . . . . . . . . . . . . . . . . . . . . . 457
7.5.3 Statische Polymorphie mit Templates . . . . . . . . . . . . . . . . . . . 460
7.5.4 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 464
7.6 Templates in der Praxis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 465
7.6.1 Übersetzen von Template-Code . . . . . . . . . . . . . . . . . . . . . . 465
7.6.2 Fehlerbehandlung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 470
7.6.3 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 472
Sandini Bib
xii Inhaltsverzeichnis
Inhaltsverzeichnis xiii
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
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ür). Es sollte alle Eigenschaften und Vorteile des
C++-Standards verwenden und nicht mehr auf C als Voraussetzung aufsetzen.
Herausgekommen ist eine vollständige Überarbeitung dieses Buchs. Sie verdeutlicht, dass
der neue C++-Standard auch zu einer neuen Form der Programmierung mit C++ geführt hat.
Statt Zeiger und Felder braucht man nun zunächst 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öchten. 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 Klas-
sen und Klassenhierarchien vorgestellt. Abgerundet wird das Buch durch ein umfangreiches Ka-
pitel über Templates, das deren Potential – auch im Rahmen der objektorientierten Programmie-
rung – verdeutlicht.
Ich wünsche allen Lesern so viel Spaß, wie ich beim Schreiben gehabt habe.
Danke
Erneut möchte ich allen Personen danken, die durch unterschiedlichstes Feedback zu dieser
Überarbeitung beigetragen haben.
Dazu gehören vor allem viele Reviewer, die den neuen Text schon vorab gelesen haben und
maßgeblich zur Qualität des Buchs beigetragen haben. Vielen Dank an Johannes Hampel, Peter
Heilscher, Olaf Janßen, Jan Langer, Bernd Mohr, Olaf Petzold, Michael Pitz, André Pönitz, Da-
niel Rakel, Kurt Watzka, Andreas Wetjen und Sascha Zapf. Dazu gehören aber auch die vielen
Leser, die mir Rückmeldungen zur vorherigen Auflage gegeben haben.
Ein gehöriger Dank gebührt auch meinen Ansprechpartnern von Addison Wesley. Susanne
Spitzer, Margrit Seifert und Friederike Daenecke haben mich bei der Realisierung dieser Über-
arbeitung maßgeblich geholfen.
Schließlich danke ich natürlich meiner Familie, die dieses Projekt wie immer mit viel Geduld
und Verständnis unterstützt haben (auch wenn ich, wie beim Formulieren dieser Zeilen, mal
wieder zu spät zum Essen kam). Ganz herzlichen Dank, Ulli, Lucas, Anica und Frederic.
1
Sandini Bib
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ähriger Projekt- und Schulungsarbeit gewonnen habe, in einem Buch für C++-Program-
mierer festgehalten.
Dabei hat der verspätete Termin der Fertigstellung den Vorteil, dass die neuesten Änderungen
der Sprachspezifikation in das Buch integriert werden konnten. Außerdem konnten die wesent-
lichen Sprachmittel, Templates und Ausnahmebehandlung, mit getesteten Beispielen aufgezeigt
und durch praktische Erfahrungen ergänzt werden.
Danke
Ich möchte 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örke, Bernhard Witte und die anderen Mitarbeiter haben mir mit viel zeitlichem und
materiellen Aufwand sehr geholfen.
Weiter möchte ich allen Personen danken, mit denen ich Themen und Inhalt des Buchs disku-
tiert habe. Dazu gehören insbesondere zahlreiche Personen, die das Buch zur Probe gelesen und
wertvolle Korrekturhinweise gegeben haben (ich kann sie gar nicht alle aufzählen). Dazu gehören
aber auch sehr, sehr viele Personen, mit denen ich zum Teil weltweit (über das UNIX-Netzwerk
Internet) verschiedene Aspekte der Sprache diskutiert habe.
Außerdem danke ich Susanne Spitzer und Judith Muhr von Addison-Wesley für die konstruk-
tive 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ügung stand.
2
Sandini Bib
Kapitel 1
Über dieses Buch
Dieses Kapitel erläutert die Motivation und den konzeptionellen Ansatz dieses Buchs und gibt
einen Überblick über den Inhalt.
3
Sandini Bib
1.2 Voraussetzungen
Voraussetzung für das Verständnis des Buchs ist es, dass der Leser mit den Konzepten höherer
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++ rea-
lisieren, vermittelt. Sicherlich sind vorhandene Kenntnisse der objektorientierten Konzepte aber
von Vorteil, da das Buch diese zwangsläufig ein wenig durch die C++-Brille betrachtet.
Den Lesern, die bereits C oder Java kennen, werden vor allem die fundamentalen Sprachmit-
tel 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öglichkeit, dass man fast jede Art von Datentypen,
Klassen, Funktionen (Prozeduren) oder Bibliotheken implementieren kann. Damit kann man für
fast jede Problematik eine geeignete einfache Schnittstelle definieren. Dies hat den Vorteil, dass
Teilprobleme nur einmal gelöst und Dienstleistungen nur einmal implementiert werden müssen.
1.3 Systematik 5
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:
cppbuch@josuttis.de
Damit alle Leser von Rückmeldungen 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ührung in das Thema und führt die Grundbegriffe für
das Verständnis von C++ ein. Nach einer Einführung in die Geschichte und die Design-Aspekte
von C++ werden dabei in einer Art Schnelldurchlauf“ die wichtigsten Sprachmittel von C++ im
”
Überblick kurz eingeführt, bevor es dann in den folgenden Kapiteln in die Details geht.
2.1.1 Designkriterien
C++ wurde als objektorientierte Weiterentwicklung von C entworfen. Die Sprache C sollte um
objektorientierte Sprachmittel erweitert werden. Da die Kompatibilität zu C einen sehr hohen
Stellenwert hatte, kann im Prinzip jedes C-Programm als C++-Programm übersetzt werden (klei-
ne Modifikationen wie etwa bei Namenskonflikten durch neue Schlüsselwörter 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ür die keine objekt-
orientierte Lösung angemessen ist, nicht gleich die Sprache wechseln muss.
9
Sandini Bib
2.1.2 Sprachversionen
C++ ist eine inzwischen standardisierte Sprache. Ein ANSI/ISO-Komitee1 hat 1998 eine welt-
weit 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ür $ 18,– als PDF-Datei erhältlich. In Deutschland ist der Standard bei DIN2 ebenfalls verfügbar,
kostet dort aber knapp DM 500,–.
Da die Compilerbauer neue Spezifikationen der Sprache immer erst implementieren müs-
sen, hinken die Compiler der aktuellen Spezifikation zur Zeit leider immer noch etwas hinterher.
Dies führt allerdings nur in wenigen Einzelfällen zu Einschränkungen. So kann man inzwischen
davon ausgehen, dass im Prinzip alle hier im Buch vorgestellten Programme mit den neuesten
Versionen der verschiedenen Compiler übersetzbar und lauffähig sind.
1
ANSI: American National Standard Institute, ISO: International Organization for Standardization
2
DIN: Deutsches Institut für Normung e.V.
Sandini Bib
Strukturen
Ein wesentlicher Fortschritt in der Geschichte der Programmiersprachen war die Möglichkeit,
mehrere Daten zu einem Datenverbund zusammenzufassen und so verschiedene zusammen-
gehörende Eigenschaften in einen Datentyp zu bündeln. Solche Strukturen oder Records erlauben
es, dass eine Variable alle Daten enthält, die zu dem von der Variablen repräsentierten Sachver-
halt gehören.
Strukturen bieten damit die Möglichkeit eine mögliche Art der Abstrahierung, nämlich die
Zusammenfassung verschiedener Einzelteile, in Programmen auszudrücken. Eine Struktur für
den Datentyp Auto besteht aus den Einzelelementen (Komponenten) wie Motor, Karosserie und
vier Rädern.
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 pro-
grammiert, versucht man, die Objekte, die im Problemfeld des Programms eine Rolle spielen, zu
ermitteln und zu implementieren. Dabei steht zunächst 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 Eigen-
schaften wie Kilometerstand und Höchstgeschwindigkeit 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önnen das Vor-
und 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önnten die Spieler, der Spielstand
und die abgelaufene Zeit sein.
Sandini Bib
Strukturen ermöglichen es, Objekte in Programmen zu verwalten. Sie bieten die Möglichkeit,
ein Objekt letztlich in die einzelnen Attribute aufzuspalten und mit Hilfe der von einer Program-
miersprache unterstützten Datentypen zu programmieren.
Abstrakte Datentypen
In Strukturen oder Records können 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äsentieren, sondern genauso wichtig ist das, was man
mit ihnen machen kann, also die Operationen, die für ein Objekt aufgerufen werden können.
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ören, welche Zustände
das Objekt ü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ören auch Vorgänge, die
mit dem Objekt passieren können (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 unter-
stützt. Es gibt zwar eine Unterstützung für den Sachverhalt, dass sich ein Objekt aus Attributen
zusammensetzt, es gibt aber keine Unterstützung dafür, dass auch Operationen und Randbedin-
gungen dazugehören. Diese müssen als Ergänzung separat programmiert werden.
Klassen
Zur Realisierung von abstrakten Datentypen hat man in der objektorientierten Programmierung
den Begriff Klasse eingeführt. Eine Klasse ist die Implementierung eines abstrakten Datentyps.
Sie beschreibt für ein Objekt im Gegensatz zur Struktur nicht nur dessen Attribute (Daten), son-
dern auch dessen Operationen (Verhalten).
Eine Klasse Auto definiert z.B., dass ein Auto aus Motor, Karosserie und vier Rädern 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 Syn-
onyme verwendet. Manchmal werden die Begriffe dahingehend unterschieden, dass Eigenschaf-
ten 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önnen dann mehrere Variablen erzeugt werden. In der objektorientierten
Programmierung nennt man diese Variablen Instanzen.
Instanzen sind also die Realisierung oder Ausprägung der Objekte, die in einer Klasse be-
schrieben werden. Diese Instanzen bestehen aus den in der Klasse beschriebenen Daten oder
Elementen und können mit den dort definierten Operationen manipuliert werden.
Sandini Bib
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ür Klasse und Instanz.
Methoden
In der objektorientierten Sprachwelt nennt man die für Objekte definierten Operationen auch
Methoden. Jede Operation, die für 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ösen können. Wenn für ein Auto die Ope-
ration 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 über die im Problembereich vorhandenen Objekte. Abhängig
von ihrer jeweiligen Rolle werden ihr Aufbau und ihr Verhalten beschrieben. Daraus ergibt sich
der Programmablauf fast von selbst.
Sowohl der Aufbau als auch das Verhalten, einschließlich Initialisierung und Zerstörung, 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ür ganzzahlige Werte zur Verfügung.
– kmstand definiert den aktuellen Kilometerstand des Autos. Der dazu verwendete Daten-
typ float steht in C++ für Gleitkommawerte zur Verfügung.
– kennzeichen definiert das aktuelle Kennzeichen des Autos. Der dazu verwendete Da-
tentyp string steht in C++ für Strings (Zeichenfolgen) zur Verfügung.
Folgende Operationen sind definiert:
– lieferBaujahr() dient dazu, das Baujahr abzufragen und als ganzzahligen Wert zurück-
zuliefern.
– fahren() dient dazu, mit dem Auto eine als Gleitkommawert km übergebene Strecke
zurückzulegen.
– lieferKMStand() dient dazu, den aktuellen Kilometerstand des Autos abzufragen und
als Gleitkommawert zurückzuliefern.
Hinzu kommen spezielle Funktionen, mit denen man das Verhalten beim Erzeugen und Zer-
stören eines Autos beeinflussen kann:
– Auto() definiert, was beim Erzeugen eines Autos automatisch passieren soll. Dazu gehört
z.B., dass Baujahr, Kilometerstand und Kennzeichen entsprechend initialisiert werden.
– ~Auto() definiert, was beim Zerstören 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ällt für Operationen. Da die Funktionen grundsätzlich nur für Autos
aufgerufen werden können, ist damit automatisch immer ein dazugehöriges Auto vorhanden.
Weder Initialisierungs- noch Aufräumungsarbeiten müssen explizit aufgerufen werden. Da-
mit kann der Anwender diese Dinge nicht vergessen.
Ein entsprechendes Anwendungsprogramm sieht in C++ wie folgt aus:
void autotest ()
{
Auto a; // Auto a anlegen und initialisieren
...
if (a.lieferBaujahr() < 1900) { // Baujahr abfragen und auswerten
}
...
} // automatisches Aufräumen und Zerstören am Blockende
Mit der Deklaration von a wird eine Instanz (ein konkretes Objekt) als Auto angelegt und auto-
matisch initialisiert. Der Zugriff erfolgt dann über die für Autos definierten Funktionen (Metho-
den). Dabei ist ein Funktionsaufruf ein Zugriff auf die Komponente der Klassenstruktur, der über
den Punkt-Operator erfolgt. Am Blockende, wenn das Objekt seinen Gültigkeitsbereich verlässt,
wird eine für das Aufräumen definierte Funktion automatisch aufgerufen.
2.2.3 Datenkapselung
Ein Problem von herkömmlichen Strukturen besteht darin, dass jederzeit auf alle Komponenten
einer solchen Struktur zugegriffen werden kann. Jedes Programmstück bietet also für alle An-
wender die Möglichkeit, den Zustand eines Objekts beliebig zu verändern. Damit besteht die
Gefahr, bei Anwendung einer Klasse Inkonsistenzen und Fehler zu produzieren. So könnte ein
Anwendungsprogramm von Auto z.B. einfach das Baujahr ändern oder den Kilometerstand her-
absetzen.
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ühren können, die der Designer
des entsprechenden Datentyps für einen ö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 über spezielle Zugriffs-
schlüsselwörter definiert werden:4
class Auto {
private:
int baujahr; // Baujahr als ganzzahliger Wert
float kmstand; // aktueller Kilometerstand als Gleitkommazahl
string kennzeichen; // aktuelles Kennzeichen
public:
int lieferBaujahr (); // Baujahr abfragen
void fahren (float km); // km Kilometer fahren
3
Manchmal wird statt encapsulation auch der Begriff information hiding verwendet. Dies ist aber – zu-
mindest 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üsselwörter ist für alle Komponenten kein Zugriff von außen möglich (private:
ist Default). Insofern funktioniert das zuvor vorgestellte Anwendungsprogramm erst mit dieser Version.
Sandini Bib
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
Für den Anwender steht nur das WAS im Vordergrund. Die ö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 belie-
big verändert werden, ohne dass dies Auswirkungen auf den Anwendungscode hätte. Auf diese
Weise kann eine erste Implementierung z.B. später aus Performance-Gründen verbessert werden.
Sandini Bib
2.2.4 Vererbung
Wenn man das Verhalten von Objekten beschreibt, gibt es oft Eigenschaften, die verschiedenar-
tige Objekte gemeinsam haben. Im natürlichen Sprachgebrauch werden dafür Oberbegriffe ver-
wendet. 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ür diesen ist
es unerheblich, was für Autos transportiert werden; allenfalls die geometrischen Maße der Autos
sind von Interesse. Dennoch werden verschiedene Autos transportiert, und deren Unterschiede
können nach dem Ausladen der Autos sehr wohl wieder von Interesse werden.
Für dieses Abstraktionsmittel von Generalisierung und Spezialisierung gibt es in den her-
kömmlichen Programmiersprachen kein äquivalentes Datenmodell. In der objektorientierten Pro-
grammierung gibt es dafür das Konzept der Vererbung und der Polymorphie.
Verschiedene Klassen können 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 übernommen (geerbt) und in
der Regel durch genauere Eigenschaften ergänzt werden.
Abbildung 2.2 zeigt ein entsprechendes Beispiel. Die Klasse Fahrzeug beschreibt als Basis-
klasse, welche Eigenschaften ein Fahrzeug hat. Dazu gehören z.B. das Attribut baujahr oder
die Operation lieferBaujahr(). Für 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ätzlich das Attribut kmstand sowie die Operationen fahren() und lieferKMStand().
Laster haben zusätzlich die Operationen beladen() und ausladen(). Auf diese Weise kann
immer weiter verfeinert werden. Es entsteht eine Klassenhierarchie. Grundsätzliches Kennzei-
chen 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ächst dient sie der Konsistenz und zur Ein-
sparung von Code. Gemeinsame Eigenschaften verschiedener Klassen müssen nur einmal im-
plementiert und gegebenenfalls auch nur an einer Stelle geändert werden. Der andere Vorteil ist,
dass das Abstraktionsmittel des Oberbegriffs unterstützt wird. Dies wird in Abschnitt 2.2.5 bei
der Einführung in die Polymorphie noch verdeutlicht.
In der Praxis kann es auch notwendig sein, einzelne geerbte Eigenschaften zu ändern. Auch
dies ist mit Einschränkungen möglich. Die Vererbung wird natürlich sinnlos, wenn nichts mehr
übernommen wird. In diesem Fall sollte man eine separate Klasse implementieren. Dabei gibt es
natürlich Grenzfälle.
Sandini Bib
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 z e u g M i t T a c h o F a h r r a d
k m s t a n d : f l o a t a u f p u m p e n ( )
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
A u t o L a s t e r B u s
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 ( )
2.2.5 Polymorphie
Von den Vorteilen, die sich aus der Vererbung ergeben, wurden bisher nur die Konsistenz und die
Einsparung von Code aufgeführt. Die Vererbung ist aber außerdem eine wichtige Grundvoraus-
setzung, um Polymorphie zu ermöglichen. Die Arbeit mit Oberbegriffen wird nämlich dann erst
sinnvoll, wenn sich Objekte zeitweise auf ihren Oberbegriff reduzieren lassen.
Nehmen wir an, es soll eine Menge von Autos z.B. für eine KFZ-Werkstatt betrachtet wer-
den. Das können völlig verschiedene Autos sein, unterschiedliche Fabrikate, unterschiedliche
Modelle, unterschiedliche Ausführungen. 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änden sehr verschie-
den aus. Die im täglichen Sprachgebrauch verwendete Aufforderung Wechsle bei den Autos den
”
Motor“ wird also zu unterschiedlichen Aktionen führen.
Um diesen Sachverhalt widerzuspiegeln, kann man nun für jede Klasse, die eine Art von Au-
tos beschreibt, eine eigene Version des Motor-Wechselns implementieren. Das Besondere an der
Polymorphie ist nun, dass für eine solche inhomogene Menge von verschiedenen Autos jeweils
die Operation motorWechseln() aufgerufen werden kann und automatisch je nach Auto-Typ
Sandini Bib
die richtige Funktion aufgerufen wird. Polymorphie bedeutet letztlich die Fähigkeit, dass eine
Operation vom Objekt selbst interpretiert wird, und zwar unter Umständen erst zur Laufzeit, da
beim Kompilieren nicht unbedingt bekannt ist, welche Operation gemeint ist.
Es gibt verschiedene Möglichkeiten, Polymorphie zu implementieren. Den radikalsten Weg
geht der Prototyp aller objektorientierten Sprachen, Smalltalk. Dort sind Variablen an keinen
Typ gebunden und können somit jede Art von Objekt repräsentieren. Wenn für 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ängig davon wird entweder die zum Objekt gehörende
Operation aufgerufen (die von der entsprechenden Klasse definierte Methode durchgeführt), oder
es wird ausgegeben, dass die Operation nicht sinnvoll ist.
In C++ nutzt man zur Realisierung der Polymorphie im Normalfall die Vererbung.5 Wenn
ä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ür ein Element in
der Menge dann eine Operation aufgerufen wird, kann veranlasst werden, dass erst zur Laufzeit
geprüft wird, zu welcher Klasse das Objekt tatsächlich gehört, und die dazugehörende Funktion
aufgerufen werden.
Ein Anwendungsprogramm könnte entsprechend eine inhomogene Menge von Autos dekla-
rieren, die verschiedenen Objekte zuweisen und für alle Objekte die Funktion motorWechseln()
aufrufen. In dem Fall wird automatisch die richtige Funktion aufgerufen, die sich aus der tatsäch-
lichen Klasse des jeweiligen Autos ergibt:
AutoMenge autos; // Menge von Autos
VW v; // Objekt der Klasse VW
Opel o; // Objekt der Klasse Opel
5
Man kann Polymorphie auch mit Hilfe von Templates implementieren. Darauf wird in Abschnitt 7.5.3
eingegangen.
Sandini Bib
Dabei ist zu beachten, dass es für die Implementierung unerheblich ist, was es für abgeleitete
Klassen von Auto gibt. Es gibt keine fest verdrahtete Fallunterscheidung. Wenn man sich ent-
schließt, eine neue Auto-Klasse als abgeleitete Klasse von Auto zu implementieren, so kann
autos auch diese enthalten, ohne neu kompiliert werden zu müssen.
2.3.1 Ausnahmebehandlung
Auch bei abstrakten Datentypen kann es zu unplanmäßigen Problemen kommen. So kann z.B.
eine Deklaration einer Menge nicht gelingen, wenn der dafür anzulegende Speicherplatz nicht
vorhanden ist. Deklarationen besitzen allerdings keinen Rückgabewert.
In einem solchen Fall gibt es zunächst die Möglichkeit, das Programm mit einer Fehlermel-
dung zu beenden oder eine entsprechende Warnung auszugeben und fortzufahren. Ob und wie
weit das sinnvoll ist, hängt allerdings sehr oft von der Situation ab und kann nicht generell sinn-
voll festgelegt werden.
Aus diesem Grund wurde ein Konzept für die Ausnahmebehandlung, die so genannte Aus-
nahmebehandlung (englisch: exception handling), in C++ integriert. Wenn eine unerwartete Si-
tuation 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 Funk-
tionen 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ückgabewerte von Funktio-
nen verwendet, ergibt sich noch ein anderer Vorteil dieser Art von Fehlerbehandlung: Der norma-
le Datenfluss wird von der Fehlerbehandlung entkoppelt. In herkömmlichen Programmen müssen
oft Rückgabewerte auf Fehlerfälle geprüft werden und dienen damit nicht nur dem Zweck des
normalen Datenflusses. Das hat zur Folge, dass der normale Datenfluss ständig von Anweisun-
gen für 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 Objek-
te, also gleichartige Ausnahmen mit deren Eigenschaften, definieren. Sie können sogar in einer
hierarchischen Beziehung zueinander stehen, wodurch verschiedene Typen von Ausnahmen ver-
allgemeinert oder spezialisiert und entsprechend auch allgemein oder speziell behandelt werden
können.
Sandini Bib
2.3.2 Templates
Manche objektorientierten Sprachen besitzen Typfreiheit. Das bedeutet, eine Variable ist nicht
von einem bestimmten Typ, sondern kann für alles Mögliche 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 bestimm-
ten Typ. Dies ist nicht unbedingt ein Nachteil, da dadurch immer schon beim Kompilieren si-
chergestellt wird, dass Typen nicht vermischt werden und eine Operation für ein Objekt auch
aufrufbar ist.
Eine Typbindung kann aber auch sehr hinderlich sein, wie das Beispiel der Automenge zeigt.
Für alle möglichen Datentypen, für die eine Menge gebraucht wird, muss auch eine entsprechen-
de Mengenklasse implementiert werden.
Um diese Nachteile der Typbindung zu umgehen, wurden in C++ Templates (Schablonen)
eingeführt. Damit können einzelne Funktionen oder ganze Klassen ohne Festlegung auf einen
bestimmten Typ implementiert werden. Es wird eine Schablone für einen oder mehrere angenom-
mene Typen implementiert. Je nach Bedarf kann eine solche Schablone dann für verschiedene
Typen zu realem Programmcode werden.
Auf diese Weise lässt sich z.B. eine Klasse Menge zur Verwaltung aller möglichen Objekt-
arten implementieren:6
template <typename T> // Schablone für angenommenen Datentyp T
class Menge {
private:
T* elems; // dynamisches Feld (Array) von Ts
unsigned anzahl; // aktuelle Anzahl
public:
void insert(T); // Objekt vom Typ T einfügen
T operator[](int); // Indexoperator definieren
int anzahl(); // Anzahl der Elemente liefern
6
Die hier vorgestellte Implementierung lässt noch einige für das wirkliche Funktionieren notwendige De-
tails außer Acht.
Sandini Bib
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ören (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 genom-
men noch entsprechend qualifizieren:
std::string s; // String der Standardbibliothek
Entsprechend sollte man alle selbst definierten Symbole in entsprechenden eigenen Namensbe-
reichen definieren.
2.4 Terminologie
In der objektorientierten Sprachwelt herrscht ein gewisses begriffliches Chaos. Ein und dersel-
be 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 ob-
jektorientierte Begriffe mit Begriffen der herkömmlichen prozeduralen Programmierung, speziell
mit C-Begriffen, vermischt werden. Ich werde deshalb im Folgenden die wichtigsten Begriffe für
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 betrach-
ten. In der Sprache C++ gibt es allerdings keinen Unterschied zwischen den Begriffen Instanz
und Objekt. In der Sprachspezifikation wird der Begriff Instanz ü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 eigent-
lichen zu kompilierenden Codes aus Template-Code gemeint. Schon aus diesem Grund schließe
ich mich der gängigen 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 Ob-
jekt einen bestimmten Typ besitzt, womit dann dessen Klasse gemeint ist. Man könnte 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ür 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ößte Sprachverwirrung. Sie werden im Um-
feld der objektorientierten Programmierung und im Umfeld von C++ Elemente, Komponenten,
Member, Attribute, Daten usw. genannt. Die für Objekte aufrufbaren Operationen bezeichnet
man als Methoden, Elementfunktionen oder Memberfunktionen. Ich verwende in diesem Buch
für Klassenelemente als Oberbegriff die Bezeichnung Komponente mit der Unterscheidung in
Datenelement und Elementfunktion.
Man kann über diese Namensgebung sicherlich streiten. Ein Leser, der aus der objektorien-
tierten Sprachwelt kommt, fragt sich z.B. mit Recht, warum Elementfunktion statt Methode und
Objekt statt Instanz verwendet wird. Ich habe mich für die C++-Sprachwelt entschieden, da we-
niger 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 über-
nommene angloamerikanische parallel zu eingedeutschten Bezeichnungen. Im Zweifelsfall ver-
wende ich den Begriff, der meiner Meinung nach verbreiteter ist, worüber man aber auch ohne
Ende streiten kann. So heißt es in diesem Buch Compiler statt Übersetzer und Template statt
Schablone, aber Rückgabewert statt Returnwert und Ausnahme statt Exception.
Letztlich ist die Namensgebung auch einfach eine Frage des Geschmacks und der Gewohn-
heit, 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
Übersetzung aller Begriffe. Wie meinte doch ein Reviewer“ so richtig: Kein Weltraum links
” ”
auf dem Gerät“ ist wesentlich unverständlicher 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 Sicht-
weise 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ügung stel-
len 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öhere Datentypen und Klassen zu programmieren.
In diesem Kapitel beschränken wir uns zunächst auf die Grundkonzepte, die für die Sicht-
weise der Anwendungsprogrammierer relevant sind. Dazu gehören der prinzipielle Aufbau von
C++-Programmen, die Syntax von Funktionen und Funktionsaufrufen, die Verwendung der fun-
damentalen 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
1
Falls sich dieses Programm nicht übersetzen lässt, kann dies daran liegen, dass ein C++-Compiler verwen-
det wird, der noch nicht standardkonform ist. In dem Fall könnte eine modifizierte Form dieses Programms
funktionieren, die in Abschnitt 3.1.5 auf Seite 31 vorgestellt wird.
Sandini Bib
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 Semi-
kolon 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ür En-
”
de der Zeile“ ( endline“). Es sorgt dafür, dass ein Zeilenumbruch ausgegeben wird und der
”
Ausgabepuffer geleert wird.
Ergänzt 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.
Die andere Art (die in C++ neu hinzugekommen ist) ist die Möglichkeit, den Rest einer Zeile
auszukommentieren. Zwei Schrägstriche //“ führen (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ängig. Ein mit //“ begonnener
”
Kommentar wird mit */“ nicht beendet:
”
// Dieser Kommentar */ reicht wirklich bis zum Zeilenende
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.
Eigentlich müsste jede Funktion, die einen Rückgabewert deklariert, auch eine dazugehörige
Return-Anweisung besitzen, mit der der Wert zurückgeliefert wird. Doch main() bildet hier
eine Ausnahme. Am Ende von main() befindet sich immer eine implizite Anweisung, die 0
zurückliefert:
return 0;
Jedes Programm ist also als Default immer erfolgreich gelaufen. Man kann die Anweisung
natürlich auch explizit hinschreiben:2
#include <iostream> // Deklarationen für Ein-/Ausgaben
3.1.4 Ein-/Ausgaben
Die Funktionalität von Ein-/Ausgaben ist in C++ kein eigentliches Sprachmittel, sondern eine
Anwendung von Elementen, die mit Hilfe der Sprachmittel programmiert wurden und über eine
Standardbibliothek angeboten werden. Für die Ein-/Ausgabe werden also Sprachmittel angewen-
det, die in diesem Buch erst noch vermittelt werden. Aus diesem Grund wird an dieser Stelle nur
ganz rudimentär auf die Grundkonzepte der Ein-/Ausgabe eingegangen, so dass man einfache
Ein- und Ausgaben formulieren kann.
Der Mechanismus ist aus Anwendersicht relativ einfach. Zunächst müssen die standardisier-
ten Sprachmittel von C++ für Ein- und Ausgaben im Programm bekannt gemacht werden. Dies
geschieht mit Hilfe einer Include-Anweisung:
#include <iostream>
Diese Include-Anweisung wird vom so genannten Präprozessor bearbeitet. Dieser fügt die in
iostream befindlichen Deklarationen ein, als wären sie direkt hier von Hand hingeschrieben
worden.
Mit der Einbindung der Deklarationen zu den IOStreams (Datenströme zur Ein- und Ausga-
be) werden unter anderem die in Tabelle 3.1 aufgeführten Symbole definiert.
2
Falls Ihr Compiler meint, dass im ersten Beispiel am Ende von main() eine entsprechende Return-
Anweisung fehlt, ist er leider noch nicht standardkonform.
Sandini Bib
Symbol Bedeutung
std::cin Standard-Eingabekanal (typischerweise die Tastatur)
std::cout Standard-Ausgabekanal (typischerweise der Bildschirm)
std::cerr Standard-Fehlerausgabekanal (typischerweise der Bildschirm)
std::endl Symbol für die Ausgabe eines Zeilenumbruchs und das wirkliche Ab-
senden der Ausgabe ( newline“ und flushing“)
” ”
Tabelle 3.1: Fundamentale Symbole der Standard-Ein-/Ausgabe
Die Eingabe erfolgt über den Standard-Eingabekanal, dem im Allgemeinen die Tastatur zuge-
ordnet ist. Die Ausgabe erfolgt auf den Standard-Ausgabekanal, dem im Allgemeinen der Bild-
schirm zugeordnet ist. Beides kann aber vom Betriebssystem oder mit Hilfe von Systemfunktio-
nen auch umgelenkt“ werden.
”
Für Fehlermeldungen existiert zusätzlich der Standard-Fehlerausgabekanal, dem im Allge-
meinen ebenfalls der Bildschirm zugeordnet ist. Durch Trennung von normalen und Fehleraus-
gaben können diese Ausgaben von der Umgebung, in der das Programm läuft, unterschiedlich
behandelt werden. Auf diese Weise können z.B. Fehlermeldungen in einer Datei protokolliert
werden.
Um Daten auszugeben, müssen diese einfach mit dem Operator << an den Ausgabekanal
gesendet“ werden. Dabei ist es möglich, mehrere Ausgaben zu verketten:
”
#include <iostream>
...
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ängt ein Zeilenende an und sorgt dafür, 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äfix legt fest, dass
cout und endl jeweils im Namensbereich std definiert sind. Dieser Namensbereich steht für
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ören (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++ ein-
geführt. In den ersten Versionen von C++ gab es kein derartiges Konzept. Entsprechend hatten
die Headerdateien, die Symbole für die Anwendung von existierenden Bibliotheken und Kompo-
nenten definieren, einen anderen Aufbau. Insofern kann es vorkommen, dass man auf Programme
Sandini Bib
trifft, die einen leicht modifizierten Aufbau haben. Die frühere Version von main() sah in etwa
wie folgt aus:
// allg/halloalt.cpp
/* Das erste C++-Programm
* - Version für noch nicht standardkonforme Umgebungen
*/
3.1.6 Zusammenfassung
C++-Programme besitzen die Hauptfunktion main(). Diese Funktion wird zum Programm-
start automatisch aufgerufen, und mit dem Verlassen dieser Funktion wird das Programm
beendet.
main() kann mit return einen ganzzahligen Wert zurückliefern, der angibt, ob das Pro-
gramm erfolgreich gelaufen ist. Die Anweisung
return 0;
steht für ein Programmende nach einem erfolgreichen Programmverlauf; jeder andere Wert
steht für einen nicht erfolgreichen Programmverlauf.
Sandini Bib
int main ()
{
int zaehler = 0; // aktuelle Anzahl der gefundenen Zahlen
Auch in diesem Programm wird das Verhalten des Programms innerhalb der Funktion main()
programmiert:
int main()
{
...
}
Dort wird zunächst eine Variable definiert, die dazu dient zu zählen, wie viele vierstellige Zahlen
gefunden wurden, die die geforderte Eigenschaft besitzen:
int zaehler = 0;
Der Datentyp int steht für ganzzahlige Werte. Es handelt sich um einen fundamentalen Datentyp
der Sprache, der von C ü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ür 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ür jede Zahl zahl von 1000 bis 9999
for (int zahl=1000; zahl<10000; ++zahl) {
...
}
For-Schleifen sind ein in C++ verallgemeinerter Mechanismus für Schleifen, die ü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ührt:
int zahl=1000 // Laufvariable zahl mit 1000 initialisieren
2. Die mittlere Anweisung definiert die Bedingung, unter der die Schleife läuft:
zahl<10000 // so lange zahl kleiner als 10000 ist,
++zahl;
}
}
Die Schleife initialisiert also eine Laufvariable zahl mit dem Wert 1.000 und lässt diese Varia-
ble für jeden Wert, der kleiner als 10.000 ist, die entsprechenden Anweisungen in der Schleife
durchführen. Die Schleife wird also für 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üngliche Zahl ergibt. Dazu werden
zunächst die beiden vorderen und hinteren Ziffern abgespalten:
int vorn = zahl/100; // die ersten beiden Ziffern
int hinten = zahl%100; // 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üft, ob die Summe der Quadrate dieser beiden Zahlen
wieder die ursprüngliche Zahl ergibt:
if (vorn*vorn + hinten*hinten == zahl) {
...
}
Der Operator * ist der Multiplikationsoperator, und + bildet die Summe. Ungewöhnlich mag
die Verwendung von zwei Gleichheitszeichen zum Vergleichen sein. Dies liegt daran, dass das
einfache Gleichheitszeichen für Zuweisungen verwendet wird:
a = b; // a bekommt den Wert von b
Die Designer von C hatten für Zuweisungen das einfache Gleichheitszeichen gewählt, da dieser
Operator mit Abstand am häufigsten vorkommt und es somit unnötig Zeit und Platz kostet, dafür
durch eine Schreibweise wie := zwei Zeichen zu verwenden.
Ist die Bedingung erfüllt, 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ähler für das Auftreten der Eigenschaft mit dem Inkrement-Operator
um eins erhöht:
++zaehler;
Sandini Bib
Datentyp Bedeutung
int ganzzahliger Wert (typische Wortgröße des Rechners)
float Gleitkommawert einfacher Genauigkeit
double Gleitkommawert doppelter Genauigkeit
char Zeichen (kann auch als ganzzahliger Wert verwendet werden)
bool Boolescher Wert (true oder false)
enum für Aufzählungstypen (Namen, die ganzzahlige Werte repräsentieren)
void nichts“ (für Funktionen ohne Rückgabewert
”
und leere Parameterlisten in ANSI-C)
Numerische Datentypen
Die numerischen Datentypen (int, float, double) können auf verschiedenen Plattformen eine
unterschiedliche Größe und damit einen unterschiedlichen Wertebereich besitzen. Die tatsächli-
che Größe der numerischen Datentypen ist in C++ also nicht festgelegt. Dahinter steckt das von
C übernommene Konzept, dass int die für die darunter liegende Hardware typische Größe be-
sitzt. Bei einem 32-Bit-Rechner hätte int also typischerweise 32 Bits, bei einem 64-Bit-Rechner
entsprechend typischerweise 64 Bits.
Allerdings kann die Größ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 Bedeutung
short eventuell kleiner als normal“
”
long eventuell größer als normal“
”
unsigned vorzeichenfrei
signed vorzeichenbehaftet
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öß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ößen ausgegangen
werden.
Datentyp Mindestgröße
char 1 Byte (8 Bits)
short int 2 Byte (16 Bits)
int 2 Byte (16 Bits)
long int 4 Byte (32 Bits)
float 6 Stellen bis 1037
double 10 Stellen bis 1037
long double 10 Stellen bis 1037
Die genauen Größen werden mit Hilfe der numeric_limits in der Headerdateien <limits> mit
zahlreichen weiteren Informationen zum Datentyp definiert (siehe Abschnitt 9.1.4 auf Seite 533).
Zeichen
Für einzelne Zeichen wird der Datentyp char verwendet. Zeichen-Literale werden jeweils in
einfache Anführungsstriche eingeschlossen. 'a' steht also für das Zeichen a“. Zwischen den
”
Anführungsstrichen darf jedes beliebige Zeichen bis auf einen Zeilenumbruch stehen. Man-
che Zeichen müssen allerdings mit einem Rückstrich (Backslash) maskiert werden. Außerdem
können mit Hilfe des Rückstrichs Sonderzeichen verwendet werden (siehe Tabelle 3.5). Das Lite-
ral '\n' steht also für einen Zeilenumbruch, das Literal '\'' steht für den einfachen Anführungs-
strich, und das Literal '\\' steht für den Rückstrich selbst.
Diese Sonderzeichen dürfen auch in String-Literalen verwendet werden. Der String
"Einige Sonderzeichen: \"\t\'\n\\\100\x3F\n"
steht also für 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 an-
dere Repräsentation für den Wert des Zeichens für den Zeilenumbruch im aktuellen Zeichensatz.
Man könnte den Typ char somit auch für Werte mit der Größe 1-Byte verwenden. Mit dem qua-
Sandini Bib
Zeichen Bedeutung
\' einfacher Anführungsstrich
\" doppelter Anführungsstrich
\\ Rückstrich (Backslash)
\n Zeilenumbruch / Newline
\t Tabulator-Zeichen
\oct-ziffern Zeichen mit oktalem Wert aus ein bis drei Ziffern
\xhex-ziffern Zeichen mit hexadezimalem Wert
\b Backspace
\f Form-Feed
\r Carriage-Return
lifizierenden Attribut signed oder unsigned könnte 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 Zeichen-
satz 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
*/
int main ()
{
// für 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<int>(c)
<< " Zeichen: " << c
<< std::endl;
}
}
Im Programm wird c als ganzzahliger Wert verwendet, der über die Werte ab 32 iteriert. Innerhalb
der Schleife wird c einmal als Zahl und einmal als Zeichen ausgegeben:
std::cout << ... << static_cast<int>(c) << ... << c << ...;
3
Da vom Standard nur die Mindestgröße von chars festgelegt wird, kann der tatsächliche Wertebereich
auch darüber hinausgehen.
Sandini Bib
Da c den Datentyp char hat, wird es standardmäßig als Zeichen ausgegeben. Durch den Aus-
druck
static_cast<int>(c)
wird c explizit in den Datentyp int umgewandelt. static_cast ist ein Operator, der Grundsätz-
lich zur logischen Typumwandlung verwendet werden kann, sofern diese Typumwandlung auch
möglich ist. Auf diese Weise wird sichergestellt, dass der ganzzahlige Wert von c ausgegeben
wird. Die Ausgabe lautet (bei einem ASCII-Zeichensatz):
Wert: 32 Zeichen:
Wert: 33 Zeichen: !
Wert: 34 Zeichen: "
Wert: 35 Zeichen: #
Wert: 36 Zeichen: $
Wert: 37 Zeichen: %
Wert: 38 Zeichen: &
...
Wert: 120 Zeichen: x
Wert: 121 Zeichen: y
Wert: 122 Zeichen: z
Wert: 123 Zeichen: {
Wert: 124 Zeichen: |
Wert: 125 Zeichen: }
Wert: 126 Zeichen: ~
Boolesche Werte
Für 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ür false und jeder andere Wert für
true.
In C++ wurde für Boolesche Werte der Datentyp bool mit den Literalen true und false
eingeführt. Um Rückwärtskompatibilität zu gewährleisten, können neben den Konstanten true
und false auch weiterhin noch ganzzahlige Werte verwendet werden. Dabei steht 0 grundsätz-
lich für false und jeder andere Wert für true. Bedingungen in Kontrollstrukturen sind somit
immer dann erfüllt, wenn der darin stehende Ausdruck den Wert true oder einen von null ver-
schiedenen ganzzahligen Wert besitzt.
3.2.3 Operatoren
C++ bietet eine ungewöhnlich große Anzahl von Operatoren. Die grundlegenden Operatoren,
die von C übernommen wurden, werden hier zunächst kurz vorgestellt. Im weiteren Verlauf des
Buchs werden alle anderen Operatoren eingeführt. Auf Seite 571 befindet sich eine Liste aller
C++-Operatoren.
Sandini Bib
Grundlegende Operatoren
Tabelle 3.6 listet die grundlegenden Operatoren auf, die man in jeder Programmiersprache zur
Verknüpfung von Werten wiederfindet.
Operator Bedeutung
+ Addition, positives Vorzeichen
- Subtraktion, negatives Vorzeichen
* Multiplikation
/ Division
% Modulo-Operator (Rest nach Division)
< kleiner
<= kleiner gleich
> größer
>= größer gleich
== gleich
!= ungleich
&& logisches UND (Auswertung bis zum ersten false)
|| logisches ODER (Auswertung bis zum ersten true)
! logische Negation
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ültiger Code; er macht nur nicht, was er soll. Mit der Zeile
if (x = 42) // korrekter Code, aber unerwünschte 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üllt 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ängig vom vorherigen Wert von x immer erfüllt ist.
Von manchen Programmierern wird diese Möglichkeit ausgenutzt, um besonders prägnanten
Code zu schreiben, über dessen Lesbarkeit man streiten kann. So ist es durchaus nicht unüblich,
folgende Bedingungen zu formulieren:
if (x = f()) // weist x den Rückgabewert von f() zu und
// testet gleichzeitig, ob dieser Wert ungleich 0 ist
Derartiger Code ist sicherlich ein Grund dafür, dass C und C++ manchmal einen etwas zwei-
felhaften Ruf haben. Man kann sehr prägnant formulieren, diesen Code aber auch als sehr un-
leserlich empfinden. Dies liegt aber auch daran, dass man derartige Möglichkeiten aus anderen
Sprachen nicht gewohnt ist. Man sollte also mit einer Verdammung derartiger Formulierungen
Sandini Bib
warten, bis man sich in C++ etwas zu Hause fühlt. Das bedeutet aber nicht, dass man sich an
alles gewöhnen sollte. Oberstes Ziel ist nach wie vor die Lesbarkeit des Codes.
Zuweisungen
Wie im vorigen Absatz schon erwähnt wurde, ist eine Zuweisung ein Operator, der durch ein
Gleichheitszeichen formuliert wurde.4 Doch C++ definiert wie C nicht nur einen Zuweisungs-
operator. Neben der reinen Zuweisung kann eine Variable mit Hilfe kombinierter Zuweisungs-
operatoren relativ zu ihrem aktuellen Wert modifiziert werden. Tabelle 3.7 listet alle Zuweisungs-
operatoren auf.
Operator Bedeutung
= einfache Zuweisung
*=
/=
%=
+=
-= a op= b“ entspricht im Allgemeinen a = a op b“
” ”
<<=
>>=
&=
^=
|=
Für alle Zuweisungsoperatoren gilt, dass eine Zuweisung Teil eines größ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öglich:
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ächst 42 zugewiesen. Der Ausdruck in der Klammer
liefert dann y, was schließlich x zugewiesen wird. Auf diese Weise erhält also auch x den Wert
42.
Entsprechend kann man eine Zuweisung zu einem Teil eines größeren Ausdrucks machen:
if ((x = f()) < 42) // x den Rückgabewert von f() zuweisen und
// diesen Wert mit 42 vergleichen
4
Die Designer von C meinten mit Recht, dass es eigentlich keinen Sinn machte, dem mit Abstand am
häufigsten vorkommenden Operator einen Namen zu geben, der wie in anderen Programmiersprachen
üblich aus zwei Zeichen (wie etwa :=) besteht.
Sandini Bib
Die anderen Zuweisungsoperatoren sind abkürzende Schreibweisen für die Verknüpfung zweier
Operatoren: dem Operator, der vor dem Gleichheitszeichen befindlichen Zeichen, und der Zu-
weisung. Dabei gilt für fundamentale Datentypen immer:
x op= y entspricht x = x op y
Diese Operatoren wurden eingeführt, 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öhen
nun
x += 7; // x um 7 erhöhen
schreiben konnte, konnte man wirklich Laufzeit sparen, da der Compiler auch ohne Codeana-
lyse 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 tri-
viale Optimierungen in der Regel beherrschen. Die Schreibweise wird aber dennoch weiterhin
verwendet, da sie prägnanter ist, ohne unleserlich zu sein.
Operator Bedeutung
++ Postfix-Inkrement ( a++“)
”
-- Postfix-Dekrement ( a--“)
”
++ Präfix-Inkrement ( ++a“)
”
-- Präfix-Dekrement ( --a“)
”
Tabelle 3.8: Inkrement- und Dekrement-Operatoren
Dabei gibt es aber jeweils zwei Varianten des Inkrement- und Dekrement-Operators, eine Präfix-
und eine Postfix-Version. Beide Versionen erhöhen bzw. vermindern den Wert einer Variablen
um 1. Der Unterschied besteht darin, was der Ausdruck als Ganzes liefert. Die Präfix-Version
(die Version, bei der der Operator vor der Variablen steht) liefert den Wert der Variablen nach der
Erhöhung:
x = 42;
std::cout << ++x; // gibt 43 aus (erst erhöhen, dann Wert von x)
std::cout << 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öhung:
x = 42;
std::cout << x++; // gibt 42 aus (erst Wert von x, dann erhöhen)
Sandini Bib
Bit-Operatoren
Die von C übernommene Hardware-Nähe 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
Die Operatoren << und >> sind uns schon bei der Ein-/Ausgabe begegnet. Die dortige Verwen-
dung ist, wenn man so will, eine Zweckentfremdung dieser Shift-Operatoren für spezielle Daten-
typen, den so genannten I/O-Streams. Diese Verwendung von << und >> führt 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öß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ührlich eingegangen.
Sandini Bib
Spezielle Operatoren
Drei weitere Operatoren verdienen eine speziellere Beachtung. Sie werden in Tabelle 3.10 auf-
gelistet.
Operator Bedeutung
?: bedingte Bewertung
, Folge von Ausdrücken
sizeof (...) Speicherplatz
Bedingte Bewertung
Der Operator ?: ermöglicht eine so genannte bedingte Bewertung. Abhängig von einer Be-
dingung wird einer von zwei möglichen Werten geliefert. Es handelt sich dabei um den ein-
zigen dreistelligen Operator von C++.
Der Ausdruck
bedingung ? ausdruck1 : ausdruck2
liefert ausdruck1, wenn die Bedingung erfüllt ist, und sonst ausdruck2.
Dadurch können If-Abfragen abgekürzt werden, bei denen ein Wert sowohl im Then- als
auch im Else-Fall manipuliert wird.
if (x < y) { // z das Minimum von x und y zuweisen
z = x;
}
else {
z = y;
}
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äre:
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ät hat, muss er in diesem Fall geklammert werden.
Komma-Operator
Der Komma-Operator erlaubt es, zwei Anweisungen zu einer Anweisung zusammenzuzie-
hen. Statt
x = 42;
y = 33;
Sandini Bib
3.2.4 Kontrollstrukturen
Alle Arten von Kontrollstrukturen wurden in C++ von C übernommen. Es gibt einfache und
mehrfache Fallunterscheidungen, verschiedene Schleifen und die Möglichkeit, mehrere Anwei-
sungen zu einem Block zusammenzufassen.
Fallunterscheidungen
In C++ gibt es folgende Kontrollstrukturen für 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
switch
dient zur mehrfachen Fallunterscheidung, wobei nur mit konstanten Werten verglichen wer-
den 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 übergebene Ausdruck mit diesem Wert überein, wird mit den Anweisungen
an dieser Stelle fortgefahren. Dabei können auch mehrere Case-Labels hintereinander stehen.
Die Anweisung break; dient innerhalb einer Switch-Anweisung dazu, einen Fall zu be-
enden und mit den Anweisungen hinter der Switch-Anweisung fortzufahren. Wird ein Fall
nicht durch eine Break-Anweisung abgeschlossen, wird mit den Anweisungen des nächsten
Falls fortgefahren.
Das optionale Label default: kennzeichnet alle anderen Fälle“. Dieser Default-Fall
”
kann an beliebiger Stelle stehen. Ist kein Default-Fall aufgeführt und passt keiner der ange-
gebenen Fälle, wird gleich hinter der Switch-Anweisung fortgefahren.
Schleifen
In C++ gibt es folgende Kontrollstrukturen für Schleifen:
while
In der While-Schleife wird vor jedem Durchlauf eine Schleifenbedingung getestet, die jeweils
erfüllt sein muss:
while (x < 7) {
std::cout << "x ist (immer noch) kleiner als 7" << std::endl;
++x; // x wird um 1 erhöht
}
std::cout << "x ist groesser oder gleich 7" << std::endl;
do-while
In der Do-While-Schleife wird dagegen nach jedem Durchlauf eine Schleifenbedingung ge-
testet, die zum Fortgang der Schleife jeweils erfüllt sein muss. Der Schleifenkörper wird im
Gegensatz zur While-Schleife also mindestens einmal durchlaufen.
Sandini Bib
Im Gegensatz zur Repeat-Until-Schleife von Pascal handelt es sich aber auch hier um eine
Bedingung, die für das Weiterlaufen der Schleife erfüllt sein muss, und nicht um eine Ab-
bruchbedingung:
// 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ämtliche Bedingungen für den Verlauf der
Schleife festgelegt:
for (initialisierung; bedingung; reinitialisierung)
Die Initialisierung wird am Anfang einmal durchgeführt. Solange dann die Bedingung erfüllt
ist, werden die Anweisungen im Schleifenkörper durchlaufen. Nach diesen Anweisungen
wird jedes Mal, bevor die Bedingung erneut bewertet wird, die Reinitialisierung durchgeführt.
Dies kann typischerweise dazu verwendet werden, eine Schleife über eine Wertfolge ite-
rieren zu lassen:
// Schleife, bei der i alle Werte von 0 bis 6 durchläuft
for (i=0; i<7; ++i) {
std::cout << "i hat den Wert: " << i << std::endl;
}
i wird am Anfang 0 zugewiesen. Die Schleife läuft, solange i einen Wert kleiner als 7 be-
sitzt. Mit dem Inkrement-Operator ++ wird i dabei jedes Mal nach dem Durchlaufen der
Anweisungen im Schleifenkörper um eins erhöht.
Da die Ausdrücke im Schleifenkopf der For-Schleife beliebig sein können, kann ein
Schleifenverlauf auch komplexer definiert werden:
/* Schleife, die für 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äuft, solange i größer oder gleich 0 ist.
Die einzelnen Ausdrücke im Kopf einer For-Schleife können auch wegfallen. Ist keine
Bedingung angegeben, bedeutet das, dass die Bedingung immer erfüllt ist. Das kann für End-
losschleifen verwendet werden:
// Endlosschleife
for (;;) {
std::cout << "das geht endlos so weiter" << std::endl;
}
Sandini Bib
Mit Hilfe des Komma-Operators (siehe Seite 44) kann die Initialisierung oder die Reinitia-
lisierung 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ührt, der vorliegende Code sollte aber dennoch verständlich sein):
// Werte in einem Feld vertauschen
int feld[100]; // Feld von 100 ganzzahligen Werten
Blöcke
Bei allen Kontrollstrukturen wurden in den Beispielen die Körper 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örper jeweils nur eine Anweisung oder eben ein Block von Anweisungen
sein kann. Bei zwei einzelnen Anweisungen würde nur die erste Anweisung zur Kontrollstruktur
gehören. Die zweite Anweisung wäre 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ötig ist, empfehle ich, auch bei nur einer Anweisung im Körper von
Kontrollstrukturen geschweifte Klammern zu verwenden:
Sandini Bib
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ützt auch vor dem Problem, dass beim Einfügen einer
zweiten Anweisung im Schleifenkörper die Klammern leicht vergessen werden.
3.2.5 Zusammenfassung
C++ bietet verschiedene fundamentale Datentypen für Zeichen, ganze Zahlen, Gleitkomma-
zahlen und Boolesche Werte.
Der genaue Wertebereich der Zahlen ist systemspezifisch.
C++ verfügt über zahlreiche Operatoren. Dazu gehören auch Operatoren zum Inkrementieren
und Dekrementieren sowie modifizierende Zuweisungen.
Der Operator = ist die Zuweisung; der Operator == ist der Test auf Gleichheit.
C++ verfügt über die üblichen Kontrollstrukturen (Fallunterscheidungen, Schleifen).
Die For-Schleife erlaubt es, sehr flexibel über Werte zu iterieren.
Mehrere Anweisungen können durch geschweifte Klammern zu einem Anweisungsblock zu-
sammengefasst werden.
Sandini Bib
3.3.1 Headerdateien
C++ ist eine Sprache mit Typprüfung. Das bedeutet, dass man zur Übersetzungszeit herausfinden
will, ob Operationen wie Funktionsaufrufe zumindest vom Datentyp her sinnvoll sind. Verteilt
man Funktionen auf unterschiedliche Module, die getrennt übersetzt werden, stellt sich die Frage,
wie man eine derartige Typprüfung gewährleisten kann.
Damit die Übergabe 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
Das folgende Programm zeigt dafür ein Beispiel. Es besteht aus drei Dateien, die, wie in Abbil-
dung 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 Q U E R _ H P P
# d e f i n e Q U E R _ H P P
i n t 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 : q u e r t e s t . c p p :
i n t q u e r s u m m e ( l o n g z a h l ) i n t m a i n ( )
{ {
. . . . . .
} x = q u e r s u m m e ( y ) ;
. . .
}
#endif
Sandini Bib
return quer;
}
In dieser Datei wird mit
#include "quer.hpp"
zunächst einmal die Datei mit der Deklaration eingebunden. Dadurch kann der Compiler fest-
stellen, ob es Widersprüche zwischen der Deklaration und der Implementierung der Funktion
gibt.
// allg/quertest.cpp
// Headerdatei mit der Deklaration von quersumme() einbinden
#include "quer.hpp"
C o m p ile r
q u e r . o o d e r
q u e r . c p p
q u e r . o b j
L in k e r
# i n c l u d e
q u e r . h p p q u e r t e s t o d e r
q u e r t e s t . e x e
# i n c l u d e
L in k e r
q u e r t e s t . o o d e r
q u e r t e s t . c p p
q u e r t e s t . o b j
C o m p ile r
Mit dem so erstellten ausführbaren 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, Varia-
blen, 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 (Übersetzungseinheiten) darstellen, in denen der Programmablauf definiert wird.
Darin befinden sich vor allem die Implementierungen der einzelnen Funktionen.
Sandini Bib
3.3.6 Systemdateien
Beim Linken werden nicht nur die eigenen Module zusammengebunden. Hinzu kommen auch
alle anderen Module, die Funktionen und Symbole für das Programm bereitstellen. Dazu gehören
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 dazugebun-
den, die mehrere zusammengehörige Module verwalten können. Sie besitzen je nach System
Endungen wie .a, .so, .lib oder .dll.
Auch für die Funktionen und Symbole der Ein- und Ausgabe gilt, dass alles für deren Ver-
wendung vor der Verwendung bekannt sein muss. Deshalb wird vom Testprogramm nach dem
gleichen Muster <iostream> 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äprozessor
Zu C++ gehört ein Präprozessor, mit dem der Code vor dem eigentlichen Kompilieren noch
modifiziert werden kann. Mit seiner Hilfe können z.B. Dateien eingebunden oder bestimmte
Teile des Codes ignoriert werden.
Die Anweisungen für den Präprozessor beginnen alle mit dem Zeichen # und müssen 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 <iostream> alle notwendigen Deklarationen zur Ein-/Ausgabe zur
Verfügung stehen. Wie dies von einer C++-Umgebung umgesetzt wird, ist Sache der Umgebung. In der
Praxis findet man allerdings in der Regel tatsächlich entsprechende Dateien ohne Endung in einem System-
verzeichnis des Compilers.
Sandini Bib
Bedingte Übersetzung
Neben der Einbindung von Dateien kann man mit dem Präprozessor noch beeinflussen, welche
Teile einer Datei wirklich kompiliert werden sollen. Da Konstanten einem Compiler auch als
Parameter übergeben werden können, 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 wer-
den (typischerweise als Option -Dkonstante).
Da für jedes System bestimmte Konstanten definiert sind, kann man auf diese Weise im Not-
fall auch Systemunterschiede berücksichtigen. Zum Beispiel:
#if defined __GNUC__
// Sonderbehandlung für GNU-Compiler
# if __GNUC__ == 2 && __GNUC_MINOR__ <= 95
// bis Version 2.95
...
# else
// danach
...
# endif
#elif defined _MSC_VER
// Sonderbehandlung für Microsoft Visual-C++-Compiler
...
#endif
Sandini Bib
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 älteren C-Programmen wurden Präprozessor 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<ANZ; ++i) { // Feld durchlaufen
...
}
Eine derartige Zweckentfremdung des Präprozessors für den eigentlichen Datenfluss kann und
sollte man in C++ aber unbedingt vermeiden. Es handelt sich nämlich um dummen“ Textersatz,
”
der weder den Gesetzen der Typprüfung unterliegt noch Gültigkeitsbereiche berücksichtigt. In
C++ gibt es dafür Konstanten:
const int anz = 100; // OK
...
int array[anz]; // Feld mit anz Elementen anlegen
for (int i=0; i<anz; ++i) { // Feld durchlaufen
...
}
Man kann bei derartigen Definitionen sogar Parameter übergeben. Diese so genannten Präpro-
zessor-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 Ab-
schnitt 7.2).
Sandini Bib
3.3.8 Namensbereiche
Wie in Abschnitt 3.1.5 bereits erläutert gibt es in C++ das Konzept der Namensbereiche (eng-
lisch: namespaces) zur logischen Gruppierung von Datentypen und Funktionen. Alle Symbole
können einem Namensbereich zugeordnet werden. Wird dieses Symbol dann außerhalb des Na-
mensbereichs 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ören (ein logisches Paket
oder eine Komponente bilden).
Das folgende Beispiel zeigt die Verwendung von Namensbereichen:
namespace A {
typedef ... String; // definiert Datentyp A::String
void foo (String); // definiert A::foo(A::String)
}
namespace B {
typedef ... String; // definiert Datentyp B::String
void foo (String); // definiert B::foo(B::String)
}
Das Schlüsselwort namespace ordnet die darin definierten Symbole dem angegebenen Gültig-
keitsbereich zu. Damit können diese Symbole nicht mehr mit gleichnamigen Symbolen aus an-
deren Namensbereichen oder globalen Symbolen kollidieren.
Zur Verwendung der Symbole außerhalb des Namensbereiches muss der Namensbereich
dann einfach qualifiziert werden:
A::String s1; // String aus Namensbereich A anlegen
B::String s2; // String aus Namensbereich B anlegen
++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
übergebenen Wert initialisiert und bleibt für die gesamte Dauer des Programms erhalten. Nach
dem Programmstart spielt der Initialwert keine Rolle mehr. Mit jedem Aufruf der Funktion wird
Sandini Bib
zunächst der Wert der Variablen um eins erhöht. In der Variablen wird also die Anzahl der Auf-
rufe dieser Funktion gezählt. Diese Anzahl wird dann jeweils auch ausgegeben.
Derartige statische Variablen oder Objekte sind immer dann sinnvoll, wenn man einen Zu-
stand 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.
3.3.10 Zusammenfassung
In C++ verwendet man zwei Arten von Dateien: Headerdateien für Deklarationen und Quell-
dateien für 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äprozessor können vor dem eigentlichen Kompilieren Headerdateien eingebunden
werden.
Mit dem Präprozessor kann Code geschrieben werden, der nur unter bestimmten Bedingun-
gen kompiliert wird.
Headerdateien sollten grundsätzlich von Präprozessor-Anweisungen umschlossen sein, die
Fehler durch mehrfache Einbindung vermeiden.
Für Makros und zur Definition von Konstanten gibt es in C++ bessere Möglichkeiten als den
Präprozessor.
Mit Namensbereichen können Symbole logisch gruppiert werden.
Mit static können lokale Variablen definiert werden, deren Lebensdauer über die gesamte
Programmdauer reicht.
Mit anonymen Namensbereichen kann die Sichtbarkeit von Variablen und Funktionen auf
Module eingeschränkt 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 ge-
nommen handelt es sich um eine so genannte Klasse, die die Eigenschaften und Fähigkeiten von
Strings definiert. Diese Klasse ist so implementiert, dass man mit Strings wie mit fundamenta-
len Datentypen operieren kann. So kann man Strings mit = zuweisen, mit == vergleichen und
mit + aneinander hängen. Hinzu kommen weitere Operationen, die man, wie bei Klassen üblich,
jeweils für Objekte (also Strings) aufrufen kann.
Die Tatsache, dass eine String-Klasse vorhanden ist, die diesen Datentyp wie einen funda-
mentalen Datentyp zur Verfügung stellt, bietet einen großen Vorteil. Die moderne Datenverarbei-
tung 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 ein-
fachen String-Datentyp gibt (z.B. in C oder Fortran) sind Strings oft die Ursache für allerlei
Probleme, die in C++ nicht mehr auftreten.
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
3.4 Strings 65
In der folgenden If-Abfrage wird geprüft, ob der String name leer ist:
if (name != "") {
...
}
Wie man sieht, kann man auch für Strings einfach die für Vergleiche ü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öhnlich aussehen. Ohne Kenntnisse der objektorientierten Pro-
grammierung würde man vielleicht einen Aufruf wie getLength(name) erwarten. Hier sehen
wir zum ersten Mal die objektorientierte Syntax für Operationen, die in Klassen für Objekte de-
finiert werden. Man wendet sich an das Objekt name und ruft für 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ängen 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ückliefert.
Insgesamt liefert das Programm also folgende Ausgabe:
Bjarne Stroustrup ist der Urvater von C++
"Bjarne Stroustrup" hat 17 Zeichen
Literale aus historischen Gründen 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 be-
deutet, dass bei der Deklaration einer Variablen auch gleich Speicherplatz für die dazugehörigen
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ächst auf nichts (NIL oder null) verweist. Erst mit einem Aufruf von new wird eine echte
Instanz mit Speicherplatz für die dazugehörigen Daten angelegt. In C++ gibt es zwar auch ein
new. Dies ist aber nur notwendig, um Objekte anzulegen, auf die unabhängig von Blockgrenzen
zugegriffen werden muss. Darauf wird in Abschnitt 3.8 eingegangen.
int main ()
{
const std::string anfang("http:"); // Beginn eines Links
const std::string trenner(" \"\t\n<>"); // Zeichen, die den Link beenden
std::string zeile; // aktuelle Zeile
std::string link; // aktueller HTML-Link
std::string::size_type anfIdx, endIdx; // Indizes
8
Der tatsächliche 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.
Sandini Bib
3.4 Strings 67
// 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;
}
Datentypen
In main() werden wiederum einige Variablen deklariert bzw. Objekte instantiiert:
Ein nicht änderbarer String anfang als Suchkriterium für den Anfang eines HTML-Links:
const std::string anfang("http:"); // Beginn eines Links
Es werden also alle Wörter 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ötig, da wir zeilenweise lesen) sowie die Zeichen < und > beendet
(siehe die Tabelle der Sonderzeichen auf Seite 38).
Zwei Variablen für die aktuelle Zeile bzw. den aktuellen Link:
std::string zeile; // aktuelle Zeile
std::string link; // aktueller HTML-Link
Zwei Variablen für den Index des Anfangs und den Index des Endes eines Links:
std::string::size_type anfIdx, endIdx; // Indizes
Als Datentypen werden für Strings der Datentyp std::string (string in der Standardbiblio-
thek) und std::string::size_type verwendet. size_type ist ein Hilfstyp von Strings für
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üst des Programms bildet eine Schleife, die zeilenweise aus der Stan-
dardeingabe einliest:
std::string zeile;
3.4 Strings 69
while testet jeweils den Rückgabewert von getline(). Dabei handelt es sich um den überge-
benen Standard-Eingabekanal. Diese Kanäle haben die Eigenschaft, dass man sie zur Zustand-
sabfrage als Bedingungen verwenden kann. Dazu werden bereits einige spezielle Sprachmittel
verwendet, die erst später 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 wie-
der die Syntax einer Elementfunktion, also zeile.operation(). Für die jeweils aktuelle Zeile
zeile wird also jeweils eine Operation aufgerufen, die diese Zeile in irgendeiner Form bearbei-
tet.
Zunächst wird mit find() das erste Vorkommen des Beginns eines Links gesucht:
const std::string anfang("http:"); // Beginn eines Links
std::string zeile; // aktuelle Zeile
std::string::size_type anfIdx; // Index
...
// suche erstes Vorkommen von "http:"
anfIdx = zeile.find(anfang);
Diese Operation liefert das erste Vorkommen des übergebenen Strings in dem String, für den
diese Operation aufgerufen wird. In diesem Fall wird also das erste Vorkommen von http:
in zeile gesucht. Zurückgeliefert wird der Index des ersten Zeichens der gefundenen Stelle.
Ist der String nicht vorhanden, wird die schon angesprochene Konstante std::string::npos
zurückgeliefert. Dies wird in der nächsten Zeile geprüft:
// 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äuft diese Schleife. Innerhalb der Schleife wird dazu jeweils das nächste Vorkommen von http:
gesucht.
Haben wir den Anfang gefunden, wird innerhalb der inneren Schleife das Ende des HTML-
Links gesucht. Die Operation find_first_of() ermöglicht es, nach mehreren Zeichen gleich-
zeitig zu suchen. Sobald eines der als Parameter übergebenen Zeichen gefunden wird, wird des-
sen Index zurückgeliefert. Auch hier wird std::string::npos zurückgeliefert, 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
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ächsten 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 übergebenen
Index bis zum Ende des Strings. Ein optionaler zweiter Parameter dient zur Angabe der maxima-
len Anzahl zu extrahierender Zeichen. In diesem Fall ist das die Differenz zwischen Ende- und
Anfangsindex.
Die nächste Anweisung gibt den Link aus. Dabei wird einschränkend geprüft, ob hinter
http: ü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ür
Strings, !=, + und =, verwendet.
Es bleibt schließlich noch die Suche nach einem eventuell vorhandenen weiteren HTML-
Link in der gleichen Zeile, was natürlich 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äuft die innere Schleife zur
Bearbeitung der aktuellen Zeile weiter.
Operation Effekt
=, assign() Neuen Wert zuweisen
swap() Werte zwischen zwei Strings vertauschen
+=, append(), push_back() Zeichen anhängen
insert() Zeichen einfügen
erase() Zeichen löschen
clear() Alle Zeichen löschen (String leeren)
resize() Anzahl der Zeichen verändern
(Zeichen am Ende löschen oder anfügen)
replace() Zeichen ersetzen
+ Summenstring bilden
==, !=, <, <=, >, >=, compare() Strings vergleichen
length(), size() Anzahl der Zeichen in einem String liefern
empty() Testen, ob ein String leer ist
[ ], at() Auf ein einzelnes Zeichen zugreifen
>>, getline() String einlesen
<< String ausgeben
find-Funktionen Teilstring oder Zeichen suchen
substr() Teilstring liefern
c_str() String als C-String verwenden
copy() Zeichen in einen Zeichenpuffer kopieren
Für eine genaue Beschreibung der Operationen (Parameter, Rückgabewert, Semantik) sei auf
entsprechende Referenzhandbücher zur C++-Klassenbibliothek verwiesen.
In Abschnitt 3.6 wird allerdings noch auf einen wichtigen allgemeinen Aspekt der String-
Klasse eingegangen, das Verhalten im Fehlerfall. Das Verhalten im Fehlerfall spielt insbesonde-
re beim Zugriff auf ein Zeichen im String eine Rolle. Beim Indexoperator führt 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ültig
ist. Verwendet man stattdessen die Elementfunktion at(), wird bei einem ungültigen Index eine
so genannte Ausnahme ausgelöst, die im Programm automatisch zu einer geordneten Fehlerbe-
handlung führt. Mehr zur Fehlerbehandlung beim Zugriff auf ein Zeichen findet man vor allem
in Abschnitt 3.6.4.
Sandini Bib
3.4.5 Zusammenfassung
Für Strings bietet die C++-Standardbibliothek den Datentyp std::string.
Damit können Strings wie fundamentale Datentypen verwendet werden.
Etliche Suchfunktionen erlauben es, Zeichen oder Teilstrings in Strings zu suchen.
Für String-Indizes gibt es den Datentyp std::string::size_type.
Sandini Bib
3.4 Strings 73
int main()
{
std::vector<int> menge; // Vektor-Container für ints
9
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 Übersetzung ist
mangels Zeit derzeit leider nicht erhältlich).
Sandini Bib
int main()
{
std::deque<std::string> menge; // Deque-Container für strings
Ein Vektor ist also schnell, wenn man Elemente am Ende einfügt oder löscht. Ein Einfügen 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ügen und Löschen von Elementen in der Mitte aber relativ langsam, da
immer Elemente verschoben werden müssen.
Dieser Unterschied in den Fähigkeiten spiegelt sich auch in der jeweiligen Schnittstelle der
beiden Klassen wider. Die Deque bietet Funktionen zum Einfügen und Löschen an beiden Enden.
Insofern kann man bei einer Deque die Elemente auch mit push_back() einfügen. Die Funktion
push_front() wird dagegen für Vektoren nicht angeboten.
Generell bieten STL-Container spezielle Funktionen, die die speziellen Fähigkeiten der Con-
tainer 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ügefunktion an be-
liebiger Stelle einfügen (dies wird später auf Seite 89 erläutert).
3.5.4 Iteratoren
Im Allgemeinen wird auf STL-Container mit Hilfe von Iteratoren zugegriffen. Iteratoren sind
Objekte, die über Container iterieren“ (wandern) können. Jedes Iterator-Objekt repräsentiert
”
eine Position in einem Container.
Der entscheidende Vorteil von Iteratoren ist, dass sie es erlauben, für alle Container die glei-
che Schnittstelle zum Zugriff auf die Elemente zu verwenden. So setzt der Inkrement-Operator
einen Iterator immer eine Position weiter. Dies ist unabhängig davon, ob er über ein Feld, eine
verkettete Liste oder eine andere Datenstruktur wandert. Dies ist möglich, da die eigentlichen
Iterator-Typen zu jedem Container passend zur Verfügung gestellt werden und somit die Daten-
organisation im Container berücksichtigen.
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äsentieren (gleicher Container, gleiche Positi-
on).
Operator =
weist einem Iterator die Position eines anderen Iterators zu.
Um mit Iteratoren arbeiten zu können, stellen Container entsprechende Elementfunktionen be-
reit. Die beiden wichtigsten sind:
begin()
liefert einen Iterator, der die Position des ersten Elements im Container repräsentiert.
end()
liefert einen Iterator, der die Position hinter dem letzten Element (engl.: past-the-end“) im
”
Container repräsentiert.
Sandini Bib
b e g i n ( ) e n d ( )
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ür Iteratoren, die über Container
wandern, definieren lässt: 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 <iostream>
#include <vector>
int main()
{
std::vector<int> menge; // Vektor-Container für ints
Nachdem zunächst wieder die Menge mit den Werten 1 bis 6 gefüllt wurde, werden alle Elemente
in der For-Schleife mit Hilfe eines Iterators ausgegeben. Der Iterator pos wird dabei zunächst als
Iterator der dazugehörigen Containerklasse definiert:
std::vector<int>::iterator pos;
Der Typ des Iterators ist also iterator zum Container vector<int> in der Standardbibliothek
”
std“. Dies zeigt, dass Iteratoren jeweils von der dazugehörigen Containerklasse zur Verfügung
gestellt werden.
In der For-Schleife selbst wird der Iterator mit der Position des ersten Elements initialisiert
und wandert dann ü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 ( )
genau einen Nachfolger zeigt. Dadurch ist kein wahlfreier Zugriff mehr möglich. Um auf das
zehnte Element zuzugreifen, müssen erst die ersten neun aufgesucht werden. Ein Wechsel auf
benachbarte Elemente ist in beiden Richtungen in konstanter (immer der gleichen) Zeit möglich.
Der Zugriff auf ein bestimmtes Element dauert aber in linearer Zeit (ist proportional zur Entfer-
nung zum Anfang). Dafür ist das Einfügen und Löschen von Elementen an allen Stellen gleich
schnell. Es müssen lediglich die entsprechenden Zeiger umgehängt werden.
Das folgende Beispiel definiert eine Liste für Zeichen, fügt die Zeichen von 'a' bis 'z' ein
und diese mit Hilfe eines Iterators wieder aus:
// stl/list1.cpp
#include <iostream>
#include <list>
int main()
{
std::list<char> menge; // List-Container für chars
b e g i n ( ) p o s + + e n d ( )
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ürfen 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ürfen. Auch hier werden
sie automatisch nach ihrem Wert sortiert.
Map
Ein Map-Container – oder kurz eine Map“ – verwaltet Schlüssel/Wert-Paare. Zu jedem Ele-
”
ment gehört ein identifizierender Schlüssel, nach dem sortiert wird, und ein dazugehöriger
Wert. Jeder Schlüssel darf hier nur einmal vorkommen.
Multimap
Ein Multimap-Container – oder kurz eine Multimap“ – entspricht einer Map mit dem Un-
”
terschied, dass die Schlüssel mehrfach vorkommen dürfen.
Alle diese Container verwenden intern typischerweise einen Binärbaum als Datenstruktur. Auch
zu diesen Containern folgen zwei Beispiele.
int main()
{
std::set<int> menge; // Set-Container für ints
Sandini Bib
2 6
1 3 5
std::set<int>::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ücksichtigung
der Baumstruktur des Containers jeweils das richtige Nachfolgeelement findet. Vom dritten Ele-
ment wird hoch zum vierten und wieder hinunter zum fünften Element traversiert (siehe Abbil-
dung 3.8).
pos ++
2 6
1 3 5
// stl/mmap1.cpp
#include <iostream>
#include <map>
#include <string>
int main()
{
// Datentyp der Menge
typedef std::multimap<int,std::string> IntStringMMap;
Der Datentyp definiert eine Multimap, deren Elemente den Datentyp int als Schlüssel und den
Datentyp string als Wert besitzen.
Da die Elemente Wertepaare sind, werden sie mit Hilfe von make_pair() eingefügt:
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 über einen
Iterator auf die Elemente mit *pos zu, erhält 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üssel) und mit .second auf den zweiten Teil des Wertepaares (in diesem
Fall also auf den zum Schlüssel gehörenden Wert) zugreifen. Zum Ausgeben von Schlüssel und
Wert könnte man also Folgendes schreiben:
std::cout << (*pos).first << ' '; // Schlüssel des Elements ausgeben
std::cout << (*pos).second << ' '; // Wert des Elements ausgeben
Man beachte, dass man den Ausdruck mit dem Stern-Operator klammern muss, da er eine nied-
rigerer Priorität als der Punkt-Operator besitzt. Es gibt für diese Kombination von Operatoren
allerdings den Operator -> als Abkürzung, 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önnte wie folgt lauten:
Die besten Parties sind: feucht lang
Man beachte, dass es zwei Elemente mit dem Schlüssel 5 gibt. Je nach Implementierung der
Standardbibliothek ist also auch folgende Ausgabe möglich:
Die besten Parties sind: lang feucht
3.5.7 Algorithmen
Wie die bisherigen Beispiele zeigen, wird die tatsächliche Datenstruktur von STL-Containern
weitgehend gekapselt. Es mag zwar einige spezielle Operationen geben (wie den Indexoperator
für 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önnen. Die STL bietet auch gleich eine Reihe von derartigen Funk-
tionen. Diese werden dort Algorithmen genannt. Es gibt unter anderem Algorithmen zum Finden,
Vertauschen, Sortieren, Kopieren, Aufaddieren und Modifizieren von Elementen.
Sandini Bib
Ein kleines Beispiel soll einige mögliche Algorithmen und deren Anwendung zeigen:
// stl/algo1.cpp
#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> menge; // Vektor-Container für ints
std::vector<int>::iterator pos; // Iterator
Im Beispiel wird zunächst eine Menge mit sechs unsortierten Integern angelegt:
std::vector<int> 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ückgeliefert wird ein Iterator
für die Position dieses Elements. Bei der Zuweisung
pos = min_element (menge.begin(), menge.end());
liefert min_element() also einen Iterator für 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ürlich wäre das auch gleich in einem Schritt möglich:
std::cout << *std::max_element(menge.begin(),menge.end())
<< std::endl;
Der nächste angewendete Algorithmus ist std::sort(). Er sortiert die Elemente in dem Be-
reich, der durch die ü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 ver-
wenden. Man kann insbesondere durch Verwendung anderer Datentypen die Datenstrukturen
bequem wechseln. Das gleiche Beispiel läuft 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
int main()
{
std::vector<std::string> menge; // Container für Strings
std::vector<std::string>::iterator pos; // Iterator
#include <algorithm>
int main()
{
std::list<int> menge1;
std::vector<int> menge2;
/* LAUFZEITFEHLER:
* - Elemente in die zweite Menge kopieren
*/
std::copy (menge1.begin(), menge1.end(), // Quellbereich
menge2.begin()); // Zielbereich
}
Der Algorithmus std::copy() erhält 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ügbar ist, was im besten Fall zu einem Absturz führt (dann merkt man wenigstens, dass
etwas schief gegangen ist).
Um solche Fehler zu vermeiden, gibt es zwei Möglichkeiten:
1. Man muss dafür sorgen, dass der Zielbereich groß genug ist.
2. Man muss einfügende Iteratoren verwenden.
Damit der Zielbereich groß genug ist, muss er entweder gleich mit der richtigen Größe ange-
legt oder explizit auf die richtige Größe gesetzt werden. Beides ist aber nur bei sequenziellen
Containern (Vektoren, Deques, Listen) möglich.
Das folgende Programm zeigt dies an einem Beispiel:
// stl/copy2.cpp
#include <iostream>
#include <vector>
#include <list>
#include <deque>
#include <algorithm>
Sandini Bib
int main()
{
std::list<int> menge1;
std::vector<int> menge2;
Einfügende Iteratoren
Die andere Möglichkeit, einfügende Iteratoren zu verwenden, zeigt folgendes Beispiel:
// stl/copy3.cpp
#include <iostream>
#include <vector>
#include <list>
#include <deque>
#include <algorithm>
int main()
{
Sandini Bib
std::list<int> menge1;
std::vector<int> menge2;
std::deque<int> menge3;
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 <iostream>
#include <vector>
#include <algorithm>
#include <string>
int main()
{
using namespace std; // Alle Symbole in std sind global
/* Strings von der Standard-Eingabe bis zum Ende der Daten einlesen
* - von der ‘‘Eingabe-Menge’’ cin einfügend in menge kopieren
*/
copy (istream_iterator<string>(cin), // Beginn Quellbereich
istream_iterator<string>(), // Ende Quellbereich
back_inserter(menge)); // Zielbereich
der Standardbibliothek. Vorsicht, eine derartige Direktive sollte nur in einem Kontext verwendet
werden, in dem man weiß, dass dadurch keine Konflikte oder unerwünschten Seiteneffekte her-
vorgerufen werden. Insofern sollten Using-Direktiven nie in Headerdateien verwendet werden.
In der Anweisung
copy (istream_iterator<string>(cin), // Beginn Quellbereich
istream_iterator<string>(), // Ende Quellbereich
back_inserter(menge)); // Zielbereich
werden gleich zwei spezielle Iteratoren erzeugt:
Der Ausdruck
istream_iterator<string>(cin)
erzeugt für den Input-Stream cin einen Iterator, der string-Elemente einliest. Diese Ele-
mente werden vom Iterator jeweils mit dem Eingabeoperator >> eingelesen.
Der Ausdruck
istream_iterator<string>()
erzeugt, da kein Parameter übergeben wurde, einen speziellen End-Of-Stream-Iterator. Er
steht für einen Stream, von dem nicht mehr gelesen werden kann.
Der copy()-Algorithmus lässt den ü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önnen. Insgesamt definiert der übergebene Bereich also alle
”
von cin gelesenen strings“. Diese werden mittels copy() mit Hilfe eines Back-Inserters in
menge eingefügt.
Nachdem die eingelesenen Elemente in der Menge sortiert worden sind, werden in der An-
weisung
copy (menge.begin(), menge.end(), // Quellbereich
ostream_iterator<string>(cout,"\n")); // Zielbereich
alle Elemente der Menge in den Zielbereich“ cout kopiert. Der Ausdruck
”
ostream_iterator<string>(cout,"\n")
erzeugt dabei für den Output-Stream cout einen Iterator, der string-Elemente ausgibt. Auch
hier kann ein beliebiger Typ angegeben werden, für den dann jeweils der Ausgabe-Operator <<
aufgerufen wird. Der optionale zweite Parameter definiert, welche Zeichenfolge jeweils zwischen
zwei Elementen eingefügt wird. In diesem Fall ist es ein Zeilentrenner, der dafür 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ägnant in C++ mit Hilfe der STL programmiert werden kann.
Durch ein Auswechseln der Datentypen kann außerdem schnell und einfach geprüft werden,
ob andere Datenstrukturen als Vektoren die Aufgabe schneller erledigen. So könnte 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
3.5.10 Ausblick
Wie schon am Anfang des Abschnitts erwähnt, war dies nur ein allgemeiner Einstieg in die
STL. Viele Details, Hintergrundinformationen und ergänzende Techniken wurden nicht erläutert.
Dennoch reichen diese Beispiele aus, um erst einmal Mengen programmieren zu können.
Im weiteren Verlauf werden STL-Container noch an verschiedener Stelle verwendet. In Kapi-
tel 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äutert, wie man mit Hilfsfunktionen und Funktionsobjekten Verar-
beitungskriterien für STL-Algorithmen definieren kann.
Für weitere Details sei auf spezielle Bücher 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örigen typischen Datenstruk-
turen:
Container Typische Datenstruktur
vector dynamisches Feld (Array)
deque nach beiden Enden offenes dynamisches Feld
list doppelt verkettete Liste
set Binärbaum ohne Duplikate
multiset Binärbaum mit Duplikaten
map Binärbaum für Schlüssel/Wert-Paare ohne Duplikate
multimap Binärbaum für Schlüssel/Wert-Paare mit Duplikaten
Iteratoren erlauben es, bei allen Containern unabhängig von der dazugehörigen Datenstruktur
auf die Elemente zuzugreifen.
Verschiedene Algorithmen sind für Standardzugriffe auf Mengen vordefiniert.
Inserter sind spezielle Iteratoren, die einfügend 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ür Datentypen definieren.
Sandini Bib
Mit
using namespace std;
kann man die Symbole der Standardbibliothek global verfügbar machen. Diese Möglichkeit
sollte nie in Headerdateien verwendet werden.
Sandini Bib
3.6 Ausnahmebehandlung
Dieser Abschnitt führt in die so genannte Ausnahmebehandlung (als Anglizismus auch Exception-
Handling genannt) ein. Mit der Ausnahmebehandlung wird eine neue Art der Behandlung von
Fehlern und Ausnahmesituationen ermöglicht. Im Gegensatz zu herkömmlichen Sprachen, in
denen die Fehlerbehandlung bei Funktionsaufrufen über die normalen Schnittstellen, Parameter
und Rückgabewerte, stattfindet, handelt es sich um ein Sprachmittel, bei dem Fehler oder Aus-
nahmen parallel zum Datenfluss mit einem separaten Mechanismus behandelt werden. Durch
diese Trennung wird das Programmverhalten überschaubarer und sicherer.
In diesem Kapitel wird nach einer konzeptionellen Einführung 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öst werden, erläutert.
Datenfluss
Funktionsaufruf Funktionsaufruf
Fehlerbehandlung
3.6 Ausnahmebehandlung 99
Es ist z.B. typisch, dass spezielle Rückgabewerte Fehlerfälle anzeigen. Dies führt dazu, dass für
den Rückgabewert immer zusätzlich auch eine Sonderbehandlung implementiert werden muss.
Wenn diese Sonderbehandlung entfällt, kann das Programm in einen undefinierten Zustand ge-
raten, der fatale Folgen haben kann. Für die Fehlerbehandlung wird dabei sogar manchmal der
Wertebereich für die Rückgabewerte erweitert. Ein Beispiel dafür ist die C-Funktion getchar(),
die ein Zeichen liest und zurückliefert. Damit neben jedem beliebigen Zeichen auch ein Fehler-
wert zurückgeliefert werden kann, ist der Typ des Rückgabewerts nicht char, sondern int.
In objektorientierten Sprachen wie C++ kommt nun noch der Sonderfall hinzu, dass man-
che Operationen überhaupt keinen Rückgabewert haben können, mit dem ein Fehler angezeigt
werden kann. Ein Beispiel dafür ist das Anlegen von Objekten durch eine einfache Deklarati-
on. Sofern für Objekte bei einer Initialisierung z.B. Speicherplatz angefordert wird, Argumente
verarbeitet oder Dateien geöffnet werden, kann dies schief gehen:
std::string s("hallo"); // kann fehlschlagen
Auch ein Zugriff mit dem Indexoperator kann zu einem Fehlerfall führen:
s[20] = 'q'; // kann fehlschlagen
Derartige Fehlerfälle können mit den herkömmlichen Sprachmitteln nur schlecht oder gar nicht
getestet und behandelt werden; denn wie soll getestet werden, ob beim Erzeugen eines tem-
porären 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ömmlichen Sprachmitteln eigentlich nur
die Möglichkeit, 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önnen, da der Kontext, aus dem heraus der Fehler ver-
ursacht wurde, nicht bekannt ist. Andererseits kann der Fehler auch nicht an die Aufrufumge-
bung zurückgemeldet werden, da Rückgabewerte entweder gar nicht definiert sind oder anderen
Zwecken dienen.
Es wird also ein Mechanismus benötigt, der eine Trennung von Fehlererkennung und Feh-
lerbehandlung ermöglicht und dabei nicht die Übergabe von Parametern oder Rückgabewerten
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
Das Konzept der Ausnahmebehandlung bietet einen solchen Mechanismus: An beliebiger Stelle
im Code können Fehler erkannt und über einen eigenen Mechanismus an die jeweilige Aufruf-
umgebung gemeldet werden. In dieser Umgebung kann der Fehler dann abgefangen und entspre-
chend der aktuellen Situation sinnvoll behandelt werden. Geschieht dies nicht, wird der Fehler
nicht einfach ignoriert, sondern führt zu einem (definierbaren) geregelten Programmende (und
keinem Programmabbruch).
Um einer möglichen Fehlinterpretation vorzubeugen, sei darauf hingewiesen, dass der Be-
griff Ausnahmebehandlung (Exception-Handling) in C++ bedeutet, dass Fehler oder Situationen
behandelt werden, die ausnahmsweise im normalen Programmablauf auftreten. Es ist kein Me-
chanismus für 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 Un-
terbrechungsbehandlung gesprungen wird. Die Ausnahmen, die für die Ausnahmebehandlung
eine Rolle spielen, werden im Programm durch spezielle C++-Anweisungen ausgelöst und in-
nerhalb der aktuellen Gültigkeitsbereiche verarbeitet.
Man beachte auch, dass der Mechanismus Ausnahmebehandlung“ und nicht Fehlerbehand-
” ”
lung“ genannt wird. Die Ausnahmen müssen nicht unbedingt Fehler sein, und nicht jeder Fehler
ist eine Ausnahmesituation (bei Benutzereingaben sind Fehler sogar eher die Regel). Der Mecha-
nismus kann und sollte dazu verwendet werden, in außergewöhnlichen Situationen, die nicht der
Bandbreite eines normalen“ Programmablaufs entsprechen, geordnet und flexibel zu reagieren.
”
Fehlerhafte Benutzereingaben gehören eher nicht dazu.
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önnen erläu-
ternde Eigenschaften einer Ausnahme (etwa ein fehlerhafter Index oder eine erläuternde Mel-
dung) definiert werden. Operationen können dazu dienen, weitere Informationen zu einer
Ausnahme abzufragen. Für verschiedene Arten von Ausnahmen gibt es entsprechend auch
Sandini Bib
verschiedene Klassen. Jede Ausnahmeklasse beschreibt also eine bestimmte Art von Aus-
nahmesituationen.
Tritt eine Ausnahme auf, wird einfach ein Objekt der entsprechenden Ausnahmeklasse erzeugt.
Diese Objekte können, wie alle Objekte, auch Komponenten besitzen, die die Ausnahmesitua-
tion oder die Umstände, unter denen sie auftrat, beschreiben. Es handelt sich sozusagen um die
Parameter der Ausnahme.
Durch Vererbung können sogar Hierarchien von Ausnahmeklassen gebildet werden. Damit
ist es z.B. möglich, eine Ausnahmeklasse für mathematische Fehler“ noch in speziellere Klas-
”
sen für 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önnen. Dies kann auch wieder situationsabhängig
erfolgen.
Schlüsselwörter
Für die Ausnahmebehandlung wurden drei Schlüsselwörter eingeführt.
throw dient dazu, ein entsprechendes Ausnahmeobjekt in die Programmumgebung zu wer-
fen, wenn eine Ausnahmesituation auftritt. Damit wird vom normalen Datenfluss auf die Aus-
nahmebehandlung 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ültigkeitsbereich anzugeben, innerhalb dessen etwaige Ausnahmen
mit catch abgefangen und behandelt werden. Es wird versucht, die darin aufgeführten An-
weisungen fehlerfrei durchzuführen. Mit try wird also der Gültigkeitsbereich für eine Aus-
nahmebehandlung definiert.
3.6.3 Standard-Ausnahmeklassen
Wie schon erläutert, werden Ausnahmen durch Objekte repräsentiert. Die dazugehörigen Da-
tentypen müssen von den Klassen oder Bibliotheken definiert werden, die diese Ausnahmen
auslösen. In C++ wird dabei eine Reihe von Standard-Ausnahmeklassen definiert, die in Abbil-
dung 3.11 dargestellt werden.
Alle Ausnahmeklassen besitzen die gemeinsame Basisklasse std::exception. Dies ist ein
Datentyp, der stellvertretend für alle Standard-Ausnahmen verwendet werden kann. Für Objekte
dieser Klassen wird nur eine Operation definiert: Man kann mit what() eine implementierungs-
spezifische Meldung abfragen.
b a d _ a l l o c
d o m a i n _ e r r o r
b a d _ c a s t
i n v a l i d _ a r g u m e n t
b a d _ t y p e i d
l e n g t h _ e r r o r
l o g i c _ e r r o r
o u t _ o f _ r a n g e
e x c e p t i o n
i o s _ b a s e : : f a i l u r e
r a n g e _ e r r o r
r u n t i m 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
b a d _ e x c e p t i o n
// allg/ehbsp1.cpp
#include <iostream> // Headerdatei für I/O
#include <string> // Headerdatei für Strings
#include <cstdlib> // Headerdatei für EXIT_FAILURE
#include <exception> // Headerdatei für Ausnahmen
int main()
{
try {
// zwei Strings anlegen
std::string vorname("bjarne"); // kann std::bad_alloc auslösen
std::string nachname("stroustrup"); // kann std::bad_alloc auslösen
std::string name;
// Strings manipulieren
vorname.at(20) = 'B'; // löst std::out_of_range aus
nachname[30] = 'S'; // FEHLER: undefiniertes Verhalten
// Strings verketten
name = vorname + " " + nachname; // könnte std::bad_alloc auslösen
}
Sandini Bib
try {
// zwei Strings anlegen
std::string vorname("bjarne"); // könnte std::bad_alloc auslösen
std::string nachname("stroustrup"); // könnte std::bad_alloc auslösen
std::string name;
// Strings manipulieren
vorname.at(20) = 'B'; // löst std::out_of_range aus
nachname[30] = 'S'; // FEHLER: undefiniertes Verhalten
// Strings verketten
name = vorname + " " + nachname; // könnte std::bad_alloc auslösen
}
Tritt eine Ausnahme auf, wird der gesamte Try-Block sofort verlassen. Wird hier im Beispiel in
einer der Deklarationen eine Ausnahme ausgelöst, werden die nachfolgenden Anweisungen also
nicht mehr durchgeführt. In diesem Beispiel wird der Try-Block deshalb spätestens beim Aufruf
von
vorname.at(20) = 'B';
Sandini Bib
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üft at() bei Strings, ob der
Index korrekt ist, und löst gegebenenfalls eine entsprechende Ausnahme aus. Beim Indexope-
rator wird auf die Prüfung aus Performance-Gründen verzichtet (die nachfolgende Zeile würde
also keine Ausnahme auslösen, sondern zu einem Absturz oder einem anderen undefinierten
Verhalten führen).
Wie die Reaktion auf eine Ausnahme aussieht, wird durch die Catch-Blöcke definiert, die
dem Try-Block folgen. In diesem Fall gibt es drei Catch-Blöcke:
catch (const std::bad_alloc& e) {
// Spezielle Ausnahme: Speicherplatz alle
std::cerr << "Speicherplatz alle" << std::endl;
return EXIT_FAILURE; // main() mit Fehlerstatus beenden
}
catch (const std::exception& e) {
// sonstige Standard-Ausnahmen
std::cerr << "Standard-Exception: " << e.what() << std::endl;
return EXIT_FAILURE; // main() mit Fehlerstatus beenden
}
catch (...) {
// alle (bisher nicht behandelten) Ausnahmen
std::cerr << "unerwartete sonstige Exception" << std::endl;
return EXIT_FAILURE; // main() mit Fehlerstatus beenden
}
Damit werden nacheinander Reaktionen auf Ausnahmen vom Typ std::bad_alloc (sie kenn-
zeichnen 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öglichkeit, auf beliebige Ausnahmen zu reagieren:
catch (...) {
// alle (bisher nicht behandelten) Ausnahmen
...
...
}
Eine Folge von drei Punkten in einer Catch-Anweisung steht also für jede beliebige Ausnahme“.
”
Da man über die Art der Ausnahme in dem Fall keinerlei Aussage machen kann, kann man hier
nur ganz generell reagieren.
Diese Reihenfolge der Catch-Blöcke ist kein Zufall. Bei einer Ausnahme in einem Try-Block
werden die nachfolgenden Catch-Blöcke in der Reihenfolge, in der sie angegeben werden, nach
einer passenden Behandlung durchsucht. Die Anweisungen des ersten passenden Catch-Blocks
werden durchgeführt. Die Reihenfolge der Catch-Blöcke muss also so definiert sein, dass spe-
zielle vor allgemeinen Ausnahmeklassen stehen. Ein Catch-Block für beliebige Ausnahmen, der
durch
Sandini Bib
catch (...) {
...
}
definiert wird, sollte also immer der letzte Catch-Block sein.
Innerhalb eines Catch-Blocks können 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;
return EXIT_FAILURE; // main() mit Fehlerstatus beenden
Die in <cstdlib definierte Konstante EXIT_FAILURE dient dazu, als Rückgabewert von main()
einen fehlerhaften Programmablauf zu kennzeichnen (siehe Abschnitt 3.9.3).
Sofern ein Ausnahmeobjekt als Parameter definiert ist, kann insbesondere auf dieses Objekt
zugegriffen werden, um weitere Informationen zur Ausnahmesituation zu erhalten. Bei Standard-
Ausnahmen ist allerdings lediglich ein Aufruf von what() möglich:
catch (const std::exception& e) {
... e.what() ...
}
Der Rückgabewert von what() ist ein implementierungsspezifischer String. Da in diesem Bei-
spiel eine Ausnahme durch den Aufruf von at() ausgelöst 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önnte z.B. wie folgt aussehen:
Standard-Exception: pos >= length ()
Sofern die Funktion nicht im Catch-Block verlassen wird, wird das Programm nach der Behand-
lung der Ausnahme hinter dem letzten Catch-Block fortgesetzt. Ohne die Return-Anweisungen
würde in diesem Beispiel also bei einer Ausnahme noch die folgende Ausgabeanweisung aufge-
rufen werden.
Man beachte, dass die Ausnahmeobjekte im Catch-Block in der Form const typ&“ über-
”
geben werden sollten. Dabei handelt es sich um eine so genannte konstante Referenz, die si-
cherstellt, dass von dem Ausnahmeobjekt keine unnötige Kopie angelegt wird. In Abschnitt 4.4
werden die dazugehörigen Sprachmittel im Detail erläutert.
Wie schon angesprochen, werden alle zwischen Erkennung und Behandlung liegenden Blöcke
verlassen, und deren angelegten lokalen Objekte zerstört. Handelt es sich um Objekte von Klas-
sen, für die Operationen zum Aufräumen 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
void foo()
{
try {
std::string s("hallo"); // wird bei einer Ausnahme zerstört
f1(s,11); // löst eine Ausnahme aus
f2(); // wird bei einer Ausnahme in f1() nicht aufgerufen
}
catch (...) {
std::cerr << "Exception, wir machen aber weiter" << std::endl;
}
void processException()
{
try {
throw; // zu behandelnde Ausnahme nochmal auslösen, damit sie
// 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ösen
std::string nachname("stroustrup"); // kann std::bad_alloc auslösen
std::string name;
Sandini Bib
// Strings manipulieren
vorname.at(20) = 'B'; // löst std::out_of_range aus
nachname[30] = 'S'; // FEHLER: undefiniertes Verhalten
// Strings verketten
name = vorname + " " + nachname; // könnte std::bad_alloc auslösen
}
catch (...) {
// alle Ausnahmen in Hilfsfunktion behandeln
processException();
return EXIT_FAILURE; // main() mit Fehlerstatus beenden
}
3.6.7 Zusammenfassung
Ausnahmebehandlung (Exception-Handling) ist ein Sprachmittel zur Behandlung von Feh-
lern und Ausnahmen, die im Programmverlauf auftreten.
Sie bietet die Möglichkeit, den normalen Datenfluss von der Fehlerbehandlung zu trennen.
Da bei diesem Mechanismus keine Parameter oder Rückgabewerte von Funktionen verwen-
det werden, kann das Konzept insbesondere zur Fehlerbehandlung beim Erzeugen von Ob-
jekten eingesetzt werden.
Ausnahmen sind Objekte, deren Datentyp in entsprechenden Klassen definiert wird.
Sandini Bib
Wird eine Ausnahme ausgelöst, wird ein entsprechendes Objekt erzeugt, und es werden alle
übergeordneten Blöcke geordnet verlassen, bis das Objekt zur Ausnahmebehandlung abge-
fangen und damit die Ausnahme behandelt wird.
Wird eine Ausnahme nicht behandelt, ist das ein Programmfehler, der mit einem außer-
gewöhnlichen Abbruch durch std::terminate() und std::abort() behandelt wird.
In C++ werden diverse Standard-Ausnahmeklassen definiert. Für diese Ausnahmeobjekte lie-
fert what() einen implementierungsspezifischen String.
Mit throw kann man Ausnahmen, die gerade behandelt werden, erneut auslösen. 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ültig ist. Verwendet man at() zum Zugriff, wird bei einem ungültigen Index
eine Ausnahme ausgelöst.
Sandini Bib
3.7.1 Zeiger
Wesentlich für das Verständnis von C ist das Verständnis 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, wor-
auf der Zeiger zeigt, bzw. das, was an seiner Adresse steht.
Beispiel:
Nach den folgenden Deklarationen
int x = 42; // int
int* xp; // Zeiger auf int
existieren eine Variable x mit dem initialen Wert 42 und ein Zeiger xp mit undefiniertem
Wert:
x : x p :
4 2 ?
Sandini Bib
x : x p :
4 2
Damit steht der Ausdruck *xp für x, da er das liefert, worauf xp zeigt. Über diesen Zeiger
kann x zum Beispiel einen neuen Wert erhalten:
*xp = 7; // das, worauf xp zeigt, also x, erhält den Wert 7
Damit ergibt sich folgender Zustand:
x : x p :
7
Entsprechend könnte man den Wert von x mit Hilfe von xp ausgeben:
std::cout << *xp << std::endl; // das, worauf xp zeigt, ausgeben
3.7.2 Felder
Ein Feld (Array) ist eine Menge mehrerer Elemente gleichen Typs, die hintereinander angeord-
net 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
Der Index-Bereich geht immer von 0 bis größ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ünden nicht geprüft. Das Ergebnis eines unerlaubten Zugriffs ist nicht
definiert und hängt vom Zufall ab.
w e r t e :
Zeigerarithmetik
Die Analogie zwischen Feldern und Zeigern geht sogar so weit, dass man Zeiger über alle Ele-
mente eines Feldes wandern lassen kann. Dazu ist es möglich, für Zeiger arithmetische Opera-
tionen aufzurufen:
Addiert man zu einem Zeiger einen ganzzahligen Wert n, wird der Zeiger n Elemente weiter-
gesetzt. Entsprechend setzt der Operator ++ einen Zeiger ein Element weiter.
Bildet man die Summe aus einem Zeiger und einem ganzzahligen Wert n, erhält man einen
Zeiger auf den n-ten Wert hinter dem Zeiger.
Subtrahiert man zwei Zeiger, erhält 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
w e r t e : w e r t e + 1 0 :
. . .
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 Inde-
xoperator 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.
3.7.3 C-Strings
Die Standardtypen von C++ für Strings kapseln die von C ü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änge 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' abgeschlos-
sen werden. Die Stringkonstante
"hallo"
wird intern wie in Abbildung 3.14 dargestellt abgelegt.
" h a l l o " :
' h ' ' a ' ' l ' ' l ' ' o ' ' \ 0 '
Entsprechend ist der Datentyp eines String-Literals const char*, also ein Zeiger auf nicht
änderbare Zeichen. Als Konzession an die Tatsache, dass viele C-Funktionen const nicht ver-
wenden, darf man ein String-Literal auch einfach als char* verwenden. Ein Ändern der Zeichen
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 über diese Zeichen wandern lässt:
// 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
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 Anwei-
sungen im Schleifenkörper durchgeführt und der Zeiger wird mit ++p um ein Zeichen weiterge-
setzt.
s :
' h ' ' a ' ' l ' ' l ' ' o ' ' \ 0 '
t :
' W ' ' e ' ' l ' ' t ' ' \ 0 '
s :
' h ' ' a ' ' l ' ' l ' ' o ' ' \ 0 '
t :
' W ' ' e ' ' l ' ' t ' ' \ 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üssen die einzelnen Zeichen kopiert werden. Hin-
zu kommt, dass der C-Programmierer immer selbst darauf achten muss, dass genügend Spei-
cherplatz für die Strings vorhanden ist. Aus diesem Grund sehen C-Programme, die mit Strings
operieren, auch immer recht grausam aus. Zum Beispiel:
Sandini Bib
// allg/cstring.cpp
// C-Headerdatei für I/O
#include <stdio.h>
void f()
{
const char* k = "Eingabe: "; // String-Konstante
char text[81]; // String-Variable für 80 Zeichen
char s[81]; // String-Variable für die Eingabe (bis 80 Zeichen)
/* String s einlesen
* - aus Platzgründen nicht mehr als 80 Zeichen
*/
if (scanf ("%80s", s) != 1) {
// Einlesefehler
...
}
Die Probleme, die schon dieses kleine Programm aufzeigt, sind allen C-Programmierern bekannt:
Für eine Zuweisung muss die Funktion strcpy() verwendet werden. Dabei muss der Pro-
grammierer sicherstellen, dass der Speicherplatz des Strings, dem ein neuer Wert zugewiesen
wird, groß genug ist.
Zum Anhängen eines Strings an einen anderen dient die Funktion strcat(), wobei auch
hier für 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ür eine bestimmte Anzahl
von Zeichen Platz vorhanden ist.
Die String-Verarbeitung ist in C also nicht nur umständlich, sondern birgt auch erhebliche Ge-
fahren. Man muss für alle Operationen spezielle Funktionen aufrufen (die wichtigsten werden in
Tabelle 3.12 aufgelistet) und sich selbst um die Speicherverwaltung kümmern.
Funktion Bedeutung
strlen() liefert die Anzahl der Zeichen in einem C-String
strcpy() weist einen C-String einem anderen zu
strncpy() weist bis zu n Zeichen eines C-Strings einem anderen zu
strcat() hängt einen C-String an einen anderen an
strncat() hängt bis zu n Zeichen eines C-Strings an einen anderen an
strcmp() vergleicht zwei C-Strings
strncmp() vergleicht bis zu n Zeichen von zwei C-Strings
strchr() sucht in einem C-String ein bestimmtes Zeichen
All diese Probleme sind mit C++ behoben. Durch Verwendung des in Abschnitt 3.4 eingeführten
Datentyps string kann man Standardoperatoren verwenden und braucht sich über die Spei-
cherverwaltung keine Gedanken zu machen. Das obige C-Programm würde in C++ wie folgt
aussehen:
// allg/string2.cpp
#include <iostream> // C++-Headerdatei für I/O
#include <string> // C++-Headerdatei für Strings
int main ()
{
const std::string k = "Eingabe: "; // String-Konstante
std::string text; // String-Variable
std::string s; // String-Variable für Eingabe
// String s einlesen
if (! (std::cin >> s)) {
Sandini Bib
// Einlesefehler
...
}
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öße-1.
Der Datentyp einer Feld-Variablen entspricht einem Zeiger auf das erste Element.
Zeiger können durch arithmetische Operatoren über Felder wandern.
C-Strings sind Felder von Zeichen.
Die Problematik von C-Strings wird mit Hilfe des C++-Typs für Strings, string, beseitigt.
Sandini Bib
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ören 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ück. Sein Rückgabewert muss also nicht erst explizit in den richtigen Typ umgewandelt
werden.
Sofern mit new Objekte erzeugt werden, werden diese vollständig initialisiert. Bei der An-
forderung von Speicherplatz für fundamentale Datentypen gilt allerdings weiterhin wie bei
lokalen Variablen, dass der Speicherplatz nicht initialisiert ist.
Sandini Bib
Der Rückgabewert muss nicht unbedingt auf NULL getestet werden, da für den Fall, dass es
nicht möglich ist, Speicherplatz oder Objekte anzulegen, eine separate Fehlerbehandlung mit
Hilfe von Ausnahmen angestoßen wird.
Schließlich kann der Operator new für eigene Datentypen selbst implementiert werden. Da-
durch ist eine beliebige Optimierung bei der Speicherverwaltung möglich.
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ür ein Objekt man auf dem Freispeicher anlegen will,
und übergibt optional gleich die Parameter zur Initialisierung.
if (bedingung-die-vielleicht-erfüllt-ist) {
feld = new Person;
}
...
delete feld; // OK
Man beachte, dass lokale Zeiger ohne explizite Initialisierung keinen definierten Startwert besit-
zen. Ohne die explizite Initialisierung von feld mit NULL wäre das Beispiel also fehlerhaft, da
mit delete bei nicht erfüllter Bedingung für irgendeine in feld stehende Adresse aufgerufen
wird.
pen (chars, floats usw.) erhalten aber wie üblich einen undefinierten Startwert. Bei mit new
angelegten Feldern ist es nicht möglich, Argumente zur Initialisierung zu übergeben.
Für die Freigabe von Feldern gibt es eine eigene Syntax des Operators delete, die verlangt,
dass ebenfalls Feld-Klammern angegeben werden müssen:
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; // chars freigeben
delete [] strings; // Strings freigeben
delete [] werte; // 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önnen:
void freigeben (std::string* sp)
{
// Was ist gemeint?:
delete sp; // bei: sp = new std::string
delete [] sp; // bei: sp = new std::string[10]
}
Wird die falsche Syntax verwendet, so ist der Effekt nicht definiert.
Diese etwas unschöne Eigenschaft der Sprache liegt darin begründet, dass in C++ anhand
des Datentyps nicht zwischen einem Zeiger auf ein Objekt und einem Feld von Objekten un-
terschieden werden kann (eine Folge der Kompatibilität zu C). Die Unterscheidung ist aber we-
sentlich, da unter Umständen verschiedene Operationen aufgerufen werden. Hinter dem Aufruf
von delete bzw. delete[] stecken nämlich unterschiedliche Funktionen, die sogar durch ei-
gene Funktionen ersetzt werden können (darauf wird in Abschnitt 9.3.4 eingegangen). Da der
Compiler also weder anhand des Datentyps noch am Kontext entscheiden kann, ob ein einzel-
nes Objekt oder ein Feld von Objekten zerstört werden soll (new und delete können in völlig
verschiedenen Modulen übersetzt werden), muss dies vom Programmierer angegeben werden.
Mehrdimensionale Felder
Mit new können auch mehrdimensionale Felder angelegt werden: Auch hier wird beim Anlegen
ein Zeiger auf das erste Objekt zurückgeliefert:
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
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ückgeliefertyen Datentyps ist,
müssen alle bis auf die erste Dimension als konstante Werte übergeben werden:
new std::string[anz][7]; // OK
new std::string[10][anz] // FEHLER
Wegen der komplizierten Datentypen bei mehrdimensionalen Feldern verwendet man in der Re-
gel eindimensionale Felder und verwaltet die Dimensionen selbst.
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ührt.
Für Felder müssen 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ürfelt werden.
Kann mit new kein Speicherplatz angelegt werden, wird im Normalfall eine Ausnahme vom
Typ std::bad_alloc ausgelöst. Programme sollten Ausnahmen diesen Typs entsprechend
auswerten.
Sandini Bib
Im zweiten Fall werden in argv die Argumente aus dem Aufruf des Programms als Feld (Ar-
ray) von C-Strings übergeben. In argc steht die Anzahl der Elemente in diesem Feld. Das Feld
enthält als erstes Element immer den Programmnamen; die weiteren Elemente sind Programm-
Parameter.
Ein einfaches Beispiel für die Auswertung zeigt folgendes Programm:
// allg/argv.cpp
#include <iostream> // C++-Headerdatei für Ein-/Ausgaben
#include <string> // C++-Headerdatei für Strings
// 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 Aus-
gabe:
argv wurde ohne Parameter aufgerufen
Wird das Programm wie folgt aufgerufen:
argv hallo "zwei Worte" 42
lautet die Ausgabe:
argv hat 3 Parameter:
argv[1]: hallo
argv[2]: zwei Worte
argv[3]: 42
string path;
const char* path_cstr = std::getenv("PATH");
if (path_cstr == NULL) {
...
}
else {
path = path_cstr;
}
Zurückgeliefert 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ückgabewert geprüft werden, bevor er als C++-String verwendet wird.
Sandini Bib
Funktion Bedeutung
exit() bricht ein Programm geordnet“ ab
”
atexit() installiert eine Funktion, die beim Programmende aufgerufen wird
abort() 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äumarbeiten werden zwar noch durchgeführt (z.B. Ausgabepuffer geleert), jedoch können
z.B. angelegte temporäre Dateien erhalten bleiben. Genau genommen werden alle globalen Ob-
jekte aufgeräumt, lokale Objekte jedoch nicht. Dies kann zu unerwünschten Effekten führen,
weshalb man auch den Aufruf von exit() nach Möglichkeit vermeiden sollte.
exit() erhält als Parameter einen ganzzahligen Wert, der an die Aufrufumgebung durch-
gereicht wird und mit dem angezeigt werden kann, ob das Programm erfolgreich lief (der so
genannte Exit-Code). Diese Werte entsprechen den Rückgabewerten von main(). Wird 0 oder
die Konstante EXIT_SUCCESS übergeben, handelt es sich um ein erfolgreiches“ Programmende.
”
Die Konstante EXIT_FAILURE kennzeichnet ein nicht erfolgreiches“ Programmende:
”
#include <cstdlib>
if (massives-problem) {
std::exit(EXIT_FAILURE); // Abbruch mit teilweisem Aufräumen
}
EXIT_SUCCESS und EXIT_FAILURE können auch als Rückgabewerte von main() verwendet
werden. Vor allem die Verwendung von EXIT_FAILURE ist sinnvoll, da damit deutlich gekenn-
zeichnet wird, dass etwas nicht geklappt hat:
int main()
{
...
if (fehler) {
return EXIT_FAILURE; // Programm mit Fehlerstatus beenden
}
...
}
Sandini Bib
Mit atexit() kann eine Funktion definiert werden, die automatisch unmittelbar vor einem Pro-
grammende durch exit() aufgerufen wird. Dies wird typischerweise dazu genutzt, um ein Pro-
gramm im Fehlerfall geordnet zu beenden. In der installierten Funktion können z.B. geöffnete
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ären Ende von main() aufgerufen.
abort() dient dazu, ein Programm bei einem fatalen Fehler sofort ohne jedes weitere Auf-
räumen abzubrechen. Die genaue Reaktion ist systemspezifisch. Typisch ist das Erzeugen eines
Speicherabzugs (Core-Dumps). abort() wird ohne Parameter aufgerufen:
#include <cstdlib>
if (jede-weitere-operation-ist-nicht-mehr-sinnvoll) {
std::abort(); // Abbruch mit Speicherabzug
}
int status;
if (ist-unix-system) {
status = std::system("ls -l"); // Dateien unter UNIX auflisten
}
else {
status = std::system("dir"); // Dateien unter Windows auflisten
}
if (status == EXIT_SUCCESS) {
...
}
Zurückgeliefert 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önnen Argumente übergeben werden, die als Parameter von main()
ausgewertet werden können.
Mit getenv() können Werte von Umgebungsvariablen ausgewertet werden.
Sandini Bib
Mit exit() und abort() können Programme abgebrochen werden. Diese Funktionen soll-
ten im Normalfall vermieden werden.
Mit system() können 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 Pro-
grammierung mit Klassen in Verbindung mit dem Konzept der Datenkapselung.
Anhand von Beispielen werden die verschiedenen Sprachmittel und Programmiertechniken,
die in C++ für die Verwendung von Klassen benötigt werden, nach und nach eingeführt und
erläutert.
Als einführendes Beispiel dient dabei die Klasse Bruch. Diese Klasse beschreibt, wie der
Name schon sagt, Objekte, die Brüche repräsentieren. Es handelt sich bewusst um ein sehr einfa-
ches Objekt, damit bei der Einführung der verschiedenen Sprachmittel nicht das Beispiel selbst
zum Problem wird.
131
Sandini Bib
erzeugen
Bruch ausgeben
zerstören
erzeugen
Bruch:
zaehler ausgeben
nenner
zerstören
Terminologie
In objektorientierter Lesart sind Zähler und Nenner die Daten oder Attribute, aus denen sich
eine Instanz der Klasse Bruch zusammensetzt. Das Erzeugen, Ausgeben und Zerstören sind die
Methoden, die für die Klassen definiert sind.
In der Lesart von C++ bedeutet das, dass eine Klasse Bruch gebraucht wird, die sich aus den
Komponenten Zähler und Nenner sowie den Funktionen zum Erzeugen, Ausgeben und Zerstören
zusammensetzt. Die Komponenten einer Klasse werden auch Elemente oder Klassenelemente
genannt. Dies alles sind verschiedene Übersetzungen der englischen Bezeichnung member, die
mitunter auch im Deutschen verwendet wird (siehe dazu auch die Anmerkungen zur Namensge-
bung 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ür ist Memberfunktion.
Für die Komponenten, die keine Elementfunktionen sind, gibt es in C++ eigentlich gar kei-
ne richtige Bezeichnung. Man spricht im Englischen im Allgemeinen nur von member und dem
Spezialfall member function. Ich werde sie nachfolgend als Attribute oder Datenelemente be-
zeichnen.
Die Komponenten der Klasse Bruch unterteilen sich also in die Datenelemente Zähler und
Nenner und die Elementfunktionen zum Erzeugen, Ausgeben und Zerstören.
Datenkapselung
Brüche besitzen eine grundsätzliche Eigenschaft: Ihr Nenner darf nicht 0 sein, da durch 0 nicht
geteilt werden darf. Diese Randbedingung soll natürlich auch für die Klasse Bruch gelten.
Wenn aber für alle Anwender unbeschränkter Zugriff auf alle Komponenten der Klasse Bruch
besteht, ist es möglich, den Nenner eines Bruchs auf 0 zu setzen. Aus diesem Grund ist es sinn-
voll, bei der Verwendung eines Bruchs keinen direkten Zugriff auf den Nenner zu ermöglichen.
Sandini Bib
Solange der Zugriff nur über Funktionen möglich ist, kann der Versuch, dem Nenner 0 zuzuwei-
sen, mit einer entsprechenden Fehlermeldung quittiert werden.
Generell ist es eine gute Idee, den Zugriff auf Objekte nur über dafür definierte Operatio-
nen zuzulassen. Durch deren Implementierung können nicht nur Fehler gefunden, sondern auch
Inkonsistenzen vermieden werden. Wird für einen Bruch z.B. intern in einer Hilfskomponente
festgehalten, ob er kürzbar ist, kann bei einer Änderung des Sachverhalts durch eine Zuweisung
des Zählers von außen intern eine entsprechende Anpassung stattfinden.
Für Brüche ist es deshalb sinnvoll, den Zugriff auf die Komponenten Zähler und Nenner nur
über die zur Klasse Bruch gehörenden Funktionen zu erlauben. Damit wird auch ein anderer
Vorteil erreicht: Der Aufbau und das Verhalten eines Bruchs können intern modifiziert werden,
ohne dass sich die Schnittstelle nach außen ändert.
/* Klasse Bruch
*/
class Bruch {
/* öffentliche Schnittstelle
*/
Sandini Bib
public:
// Default-Konstruktor
Bruch();
// Ausgabe
void print();
};
#endif /* BRUCH_HPP */
Präprozessor-Anweisungen
Die komplette Headerdatei wird durch Präprozessor-Anweisungen eingeschlossen, mit deren
Hilfe vermieden wird, dass die darin befindlichen Deklarationen bei mehrfachem Einbinden der
Datei mehrfach durchgeführt werden:
#ifndef BRUCH_HPP // ist nur beim ersten Durchlauf erfüllt, denn
#define BRUCH_HPP // 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 Verar-
beitung definiert wird, werden die Anweisungen bei jedem weiteren Mal ignoriert.
Da es immer passieren kann, dass Headerdateien über unterschiedliche Wege von einer Quell-
datei mehrfach eingebunden werden, sollten Deklarationen in Headerdateien immer durch derar-
tige Anweisungen eingeschlossen werden.
class Bruch {
...
};
Wenn man eine Bibliothek definiert, die aus mehreren Klassen besteht, stellt jeder Klassenname
ein globales Symbol dar, das überall verwendet werden kann. Dies kann bei der Verwendung
unterschiedlicher Bibliotheken schnell zu Namenskonflikten führen. Aus diesem Grund wird die
Klasse Bruch innerhalb eines Namensbereiches (englisch: namespace) deklariert:
namespace Bsp { // Beginn Namensbereich Bsp
class Bruch {
...
};
Zugriffsschlüsselwörter
Innerhalb der Klassenstruktur befinden sich Zugriffsschlüsselwörter, die die einzelnen Kompo-
nenten 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üsselwort 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 Im-
plementierung 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ügung gestellt werden.
Sandini Bib
Zugriff hat der Anwender der Klasse Bruch nur auf die öffentlichen Komponenten. Diese wer-
den durch das Schlüsselwort 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 öffentlich und alle anderen Komponenten
privat sind. Bei größeren Klassen ist es durchaus nicht untypisch, dass es klasseninterne Hilfs-
funktionen gibt, die als private Elementfunktionen deklariert werden.
Genauso ist es prinzipiell möglich, Komponenten, die keine Funktionen sind, mit einem
öffentlichen Zugriff zu versehen. Dies ist aber im Allgemeinen nicht sinnvoll, da damit der Vor-
teil, die Objekte nur über eine wohldefinierte Schnittstelle manipulieren zu können, aufgehoben
wird.
Zugriffsdeklarationen können in einer Klassendeklaration beliebig oft auftreten, wodurch der
Zugriff auf die folgenden Komponenten mehrfach wechseln kann:
class Bruch {
private:
...
public:
...
private:
...
};
4.1.4 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ückliefert, hat sie den Rückga-
betyp void.
Man beachte dabei, dass der Bruch, der jeweils ausgegeben werden soll, nicht als Parameter
ü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ür Objekt x aufrufen
Die Funktion print() wird mit dem Punkt-Operator für 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ägt.
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 Va-
riablen der Fall. Nachdem für das Objekt der notwendige Speicherplatz angelegt wurde, werden
die Anweisungen im Konstruktor ausgeführt. Sie dienen typischerweise dazu, das angelegte Ob-
jekt 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 ();
Ein Bruch kann mit zwei ganzzahligen Argumenten erzeugt werden. Diese Parameter werden
zum Initialisieren von Zähler und Nenner verwendet.
Um einen solchen Konstruktor aufrufen zu können, wurde die gerade vorgestellte neue
Schreibweise zur Initialisierung eines Objekts eingeführt, denn sie erlaubt es, mehrere Argu-
mente zu übergeben:
Bsp::Bruch w(7,3); // Initialisierung mit int/int-Konstruktor
Konstruktoren haben noch eine ungewöhnliche Besonderheit: Sie besitzen keinen Rückgabetyp
(auch nicht void). Es sind also keine Funktionen oder Prozeduren im herkömmlichen Sinne.
Sobald Konstruktoren definiert werden, können Objekte nur noch ü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öglich. Auf diese Weise kann die Übergabe
von Werten zur Initialisierung erzwungen werden.
void f ()
{
Bsp::Bruch b;
...
// Aufruf
quadrat(b); // ruft Quadrat eines Bruchs auf
}
Beim Überladen von Funktionen sollte natürlich darauf geachtet werden, dass die überladenen
Funktionen im Prinzip das Gleiche machen. Da jede Funktion für sich implementiert wird, liegt
dies in der Hand des Programmierers.
Sandini Bib
Parameter-Prototypen
Damit eine Unterscheidung der Funktionen über die Parameter überhaupt möglich ist, müssen
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önnen 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üblich.
Falls eine Funktion eine variable Anzahl von Argumenten besitzt, kann dies in der Deklara-
tion 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 Auslas-
sung oder Unvollständigkeit übersetzen kann.1
1
In vielen Büchern wird der Begriff ellipsis“ auch als Ellipse“ übersetzt, womit offensichtlich nicht das
” ”
mathematische Objekt, sondern die sprachwissenschaftliche Bezeichnung für eine Ersparung von Redetei-
”
len“ gemeint ist (ein Duden ist manchmal eine wirklich interessante Sache).
Sandini Bib
da jede Art von Objekt für sich betrachtet wird (es gibt natürlich auch gute Gründe, von dieser
Regel abzuweichen, etwa wenn zwei Klassen logisch und technisch sehr eng zusammengehören).
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 <iostream>
#include <cstdlib>
/* Default-Konstruktor
*/
Bruch::Bruch ()
: zaehler(0), nenner(1) // Bruch mit 0 initialisieren
{
// keine weiteren Anweisungen
}
/* print
*/
void Bruch::print ()
{
std::cout << zaehler << '/' << nenner << std::endl;
}
2
Auf Unix-Systemen kann man diese Verzeichnisse meistens mit der Option -I definieren. Microsoft
Visual C++ bietet dazu die Möglichkeit der Angabe zusätzlicher Include-Verzeichnisse“.
”
Sandini Bib
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ällt zunächst auf, dass vor sämtlichen Funktionsnamen der
Ausdruck
Bruch::
steht. Der Operator :: ist der Bereichsoperator. Er ordnet einem nachfolgenden Symbol den
Gültigkeitsbereich der davorstehenden Klasse zu. Er besitzt höchste Priorität.
In diesem Fall wird damit zum Ausdruck gebracht, dass es sich jeweils um eine Elementfunk-
tion der Klasse Bruch handelt. Das bedeutet, dass die Funktion nur für Objekte dieser Klasse
aufgerufen werden kann. Damit ist aber gleichzeitig definiert, dass es innerhalb der Funktionen
immer einen Bruch gibt, für den die Funktion aufgerufen wurde und auf dessen Komponenten
zugegriffen werden kann.
Es gibt jedoch kleine Unterschiede in der Semantik dieser beiden Formen – so wie es Unter-
schiede 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 anlegen
x = 0; // 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öhnen.
Der zweite Konstruktor wird aufgerufen, wenn beim Erzeugen des Objekts ein Integer über-
geben wird. Dieser Parameter wird als ganze Zahl interpretiert. Entsprechend wird der Zähler
mit dem ü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ält zwei Parameter, die er zum Initialisieren von Zähler und Nenner
verwendet. Dieser Konstruktor wird immer dann aufgerufen, wenn beim Anlegen eines Bruchs
zwei Integer übergeben werden. Falls als Nenner allerdings 0 übergeben wird, wird das Pro-
gramm mit einer entsprechenden Fehlermeldung beendet:
Bruch::Bruch (int z, int n)
: zaehler(z), nenner(n) // Zähler und Nenner wie ü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);
}
}
Fehlerbehandlung in Konstruktoren
Die Tatsache, dass in diesem Beispiel bei einer fehlerhaften Initialisierung das Programm beendet
wird, ist sehr radikal. Es wäre besser, den Fehler zu melden und abhängig von der Programm-
situation darauf zu reagieren. Das Problem ist nur, dass Konstruktoren keine herkömmlichen
Funktionen sind, die explizit aufgerufen werden und einen Rückgabewert zurückliefern können.
Sie werden implizit durch Deklarationen aufgerufen – und Deklarationen haben keinen Rückga-
bewert.
Sandini Bib
Das Programm wird deshalb mangels besserer Alternativen abgebrochen. Um einen solchen Pro-
grammabbruch zu vermeiden, muss also in allen Programmen, in denen die Klasse verwendet
wird, darauf geachtet werden, dass als Nenner nicht 0 übergeben wird.
Es gibt natürlich noch andere Alternativen zum Abbruch. So könnte der Bruch auch einen
Default-Wert erhalten. Dies erscheint aber nicht sinnvoll, da damit ein offensichtlicher Fehler
(Nenner dürfen nicht 0 sein) nicht behandelt, sondern einfach nur ignoriert wird.
Es gibt auch die Möglichkeit, eine weitere Hilfskomponente für Brüche einzuführen, die
festhält, dass der Bruch noch nicht korrekt initialisiert wurde. Diese Komponente müsste dann
aber auch ausgewertet und bei jeder Operation beachtet werden.
Die beste Möglichkeit, mit solchen Fehlern umzugehen, stellt das C++-Konzept zur Aus-
nahmebehandlung dar, das bereits in Abschnitt 3.6 vorgestellt wurde. Dieses Konzept erlaubt
es, auch bei einer Deklaration eine Fehlerbehandlung auszulösen, die aber nicht unbedingt zum
Abbruch führen 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.
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 über die Komponenten eines Bruchs,
damit entsprechend viel Speicherplatz angelegt werden kann. Mit Hilfe der Klassendeklaration
wird auch geprüft, ob alle Zugriffe auf Objekte der Klasse Bruch zulässig sind.
Unter der Annahme, dass das Anwendungsprogramm btest.cpp heißt, ergibt sich das in
Abbildung 4.3 dargestellte Übersetzungsschema: Die Headerdatei bruch.hpp mit der Klassen-
deklaration wird von der Quelldatei mit der Klassenimplementierung und der Quelldatei mit
dem Anwendungsprogramm eingebunden. Die beiden Quelldateien werden vom C++-Compiler
zunächst in Objektdateien übersetzt (diese haben typischerweise die Endung .o oder .obj).
Von einem Linker werden die Objektdateien dann zum ausführbaren Programm btest (oder
btest.exe) zusammengebunden. Das Übersetzen und Binden ist auf den meisten Systemen
auch in einem Schritt möglich.
Klassen- C++
Bruch.cpp Bruch.o
Implementierung Compiler
Klassen-
Bruch.hpp Linker btest
Spezifikation
C++
Anwendung btest.cpp Compiler
btest.o
Das folgende Programm ist ein erstes Anwendungsbeispiel der ersten Version der Klasse Bruch
und zeigt, was mit Brüchen bereits alles gemacht werden kann:
// klassen/btest1.cpp
// Headerdateien für die verwendeten Klassen einbinden
#include "bruch.hpp"
int main()
{
Bsp::Bruch x; // Initialisierung durch Default-Konstruktor
Bsp::Bruch w(7,3); // Initialisierung durch int/int-Konstruktor
// Bruch w ausgeben
Sandini Bib
w.print();
// x und w ausgeben
x.print();
w.print();
}
Die Ausgabe des Programms lautet:
7/3
7/3
1000/1
Deklarationen
Im Programm werden zunächst die Variablen x und w deklariert:
Bsp::Bruch x; // Initialisierung durch Default-Konstruktor
Bsp::Bruch w(7,3); // 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 an-
gelegt.
Konkret wird sowohl für x als auch für w Speicherplatz angelegt und der entsprechende Kon-
struktor aufgerufen. Dies geschieht für x in folgenden Schritten:
Zunächst wird der Speicherplatz für das Objekt angelegt. Dessen Zustand ist zunächst nicht
definiert:
x: zaehler: ?
nenner: ?
Da x ohne einen Wert zur Initialisierung deklariert wird, wird anschließend der Default-
Konstruktor aufgerufen:
Bruch::Bruch ()
: zaehler(0), nenner(1) // Bruch mit 0 initialisieren
Sandini Bib
{
// keine weiteren Anweisungen
}
Durch dessen Initialisierungsliste wird der Bruch x also mit 0 ( 01 ) initialisiert:
x: zaehler: 0
nenner: 1
w: zaehler: ?
nenner: ?
Da w mit zwei Argumenten initialisiert wird, wird der Konstruktor aufgerufen, der für zwei
Integer als Parameter definiert ist:
/* Konstruktor aus Zähler und Nenner
*/
Bruch::Bruch (int z, int n)
: zaehler(z), nenner(n) // Zähler und Nenner wie ü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);
}
}
Mit den übergebenen Parametern initialisiert der Konstruktor den Zähler und den Nenner und
legt somit den Bruch 73 an:
w: zaehler: 7
nenner: 3
int main ()
{
...
}
Da Konstruktoren beliebige Hilfsfunktionen aufrufen können, können auf diese Weise bei kom-
plexen Klassen vor dem Aufruf von main() bereits erhebliche Dinge geschehen. Ein Beispiel
dafür wäre eine Klasse für Objekte, die geöffnete Dateien repräsentieren. Wird ein globales Ob-
Sandini Bib
jekt dieser Klasse deklariert, wird durch den Aufruf des Konstruktors noch vor dem Aufruf von
main() eine entsprechende Datei geöffnet.
Felder von Objekten
Konstruktoren werden für jedes erzeugte Objekt einer Klasse aufgerufen. Wenn ein Feld (Array)
von zehn Brüchen deklariert wird, wird also auch zehnmal der Default-Konstruktor aufgerufen:
Zuweisungen
Mit der Anweisung
x = w;
wird dem Bruch x der Bruch w zugewiesen.
Sandini Bib
Eine solche Zuweisung ist möglich, obwohl sie in der Klassenspezifikation nicht deklariert wor-
den ist. Für jede Klasse ist nämlich automatisch ein Default-Zuweisungsoperator vordefiniert. Er
ist so implementiert, dass er komponentenweise zuweist. Das bedeutet, dass dem Zähler von x
der Zähler von w und dem Nenner von x entsprechend der Nenner von w zugewiesen wird.
Der Zuweisungsoperator kann für eine Klasse auch selbst definiert werden. Dies ist vor allem
dann notwendig, wenn ein komponentenweises Zuweisen nicht korrekt wäre. Typischerweise ist
das dann der Fall, wenn Zeiger als Komponenten verwendet werden. In Abschnitt 6.1.5 wird
anhand der Klasse String ausführlich darauf eingegangen.
4.1.10 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 be-
finden sich, jeweils durch horizontale Linien getrennt, der Klassenname, die Attribute und die
Operationen. Führende Minuszeichen bei Attributen und Operationen bedeutet, dass diese privat
Sandini Bib
B s p : : B r u c h
- z a e h l e r : i n t
- n e n n e r : 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 ( )
sind. Führende Pluszeichen stehen für öffentlichen Zugriff. Falls eine Operation einen Rückga-
betyp besitzt, wird dieser durch einen Doppelpunkt getrennt, hinter der Operation angegeben.
Je nach Stand der Modellierung können bei der UML-Notation Details entfallen. So können
z.B. der Paketname (der Namensbereich), die führenden Zeichen zur Sichtbarkeit, die Datenty-
pen der Attribute oder die Parameter der Operationen entfallen. Auch kann ganz auf die Angabe
von Attributen und Operationen verzichtet werden. Die kürzeste Form einer UML-Notation für
die Klasse Bruch wäre also ein Rechteck, in dem nur das Wort Bruch steht.
4.1.11 Zusammenfassung
Klassen werden durch Klassenstrukturen mit dem Schlüsselwort class deklariert.
Klassen sollten wie alle anderen globalen Symbole in einem Namensbereich deklariert wer-
den.
Die Komponenten einer Klasse besitzen Zugriffsrechte, die durch die Schlüsselwörter public
und private vergeben werden. Der Default-Zugriff ist private.
Eine Struktur ist in C++ das gleiche wie eine Klasse, nur dass statt class das Schlüsselwort
struct verwendet wird und der Default-Zugriff public ist.
Die Komponenten der Klassen können auch Funktionen (so genannte Elementfunktionen)
sein. Sie bilden typischerweise die öffentliche Schnittstelle zu den Komponenten, die den
Zustand des Objekts definieren (es kann aber auch private Hilfsfunktionen geben). Element-
funktionen haben Zugriff auf alle Komponenten ihrer Klasse.
Für jede Klasse ist ein Default-Zuweisungsoperator vordefiniert, der komponentenweise zu-
weist.
Sandini Bib
Konstruktoren sind spezielle Elementfunktionen, die beim Erzeugen von Objekten einer Klas-
se aufgerufen werden und dazu dienen, diese zu initialisieren. Sie tragen als Funktionsnamen
den Namen der Klasse und besitzen keinen Rückgabetyp, auch nicht void.
Alle Funktionen müssen mit Parameter-Prototypen deklariert werden.
Funktionen können überladen werden. Das bedeutet, dass der gleiche Funktionsname mehr-
fach vergeben werden darf, solange sich die Parameter in ihrer Anzahl oder ihren Typen
unterscheiden. Eine Unterscheidung nur durch den Rückgabetyp ist nicht erlaubt.
Der Bereichsoperator ::“ ordnet einem Symbol den Gültigkeitsbereich einer bestimmten
”
Klasse zu. Er besitzt höchste Priorität.
Zur Modellierung von Klassen wird standardmäßig die UML-Notation verwendet.
Sandini Bib
/* Klasse Bruch
*/
class Bruch {
/* privat: kein Zugriff von außen
*/
private:
int zaehler;
int nenner;
Sandini Bib
/* öffentliche Schnittstelle
*/
public:
// Default-Konstruktor
Bruch ();
// Ausgabe
void print ();
#endif // BRUCH_HPP
Hinzugekommen sind die Deklarationen für die Operatoren. Diese werden mit Hilfe des Schlüs-
selworts operator deklariert.
Man beachte, dass bei der Deklaration der Operatoren jeweils nur ein Operand als Parameter
angegeben ist, obwohl es sich in allen Fällen um zweistellige Operatoren handelt. Denn auch
bei Operatorfunktionen, die für eine Klasse definiert werden, existiert ein Objekt, für 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 übergeben. Der übergebene Parameter
ist also jeweils der zweite Operand.
Die Deklaration
class Bruch {
...
Bruch operator * (Bruch);
...
};
Sandini Bib
ist also so zu verstehen, dass der Operator * für 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ückgeliefert.
Dies ist die konsequente Anwendung des objektorientierten Ansatzes, dass jede Operation
eigentlich nur eine Nachricht an einen Empfänger ist, die eventuell Parameter besitzt. In objekt-
orientierter Lesart wird nicht global die Multiplikation zweier Brüche sondern an einen Bruch
die Nachricht bilde Produkt mit übergebenem Bruch“ gesendet. Die Nachricht ist in C++ die
”
Funktion, und der Empfänger der Nachricht ist das Objekt, für das die Funktion aufgerufen wird.
Entsprechend bedeutet die Deklaration
class Bruch {
...
bool operator < (Bruch);
...
};
dass der Operator < für den Fall deklariert wird, dass der erste Operand und der zweite Parame-
ter Brüche sind (Deklaration innerhalb der Klasse Bruch und Bruch als Parametertyp) und ein
Boolescher Wert zurückgeliefert wird.
Da C++ eine formatfreie Sprache ist, können die Leerzeichen bei der Deklaration von Ope-
ratorfunktionen auch wegfallen:
class Bruch {
...
Bruch operator*(Bruch);
bool operator<(Bruch);
...
};
// Standard-Headerdateien einbinden
#include <iostream>
#include <cstdlib>
/* Default-Konstruktor
*/
Bruch::Bruch ()
: zaehler(0), nenner(1) // Bruch mit 0 initialisieren
{
// keine weiteren Anweisungen
}
else {
zaehler = z;
nenner = n;
}
/* print
*/
void Bruch::print ()
{
std::cout << zaehler << '/' << nenner << std::endl;
}
/* neu: Operator *
*/
Bruch Bruch::operator * (Bruch b)
{
/* Zähler und Nenner einfach multiplizieren
* - das Kürzen 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ür das die Operation aufgerufen wurde (erster Operand), zurückliefern
return *this;
}
Sandini Bib
// Ergebnis zurückliefern
return ergebnis;
}
Diese Implementierung hat jedoch einen Laufzeitnachteil: Es wird unnötigerweise ein zusätzli-
ches lokales Bruch-Objekt ergebnis verwendet. Dieses Objekt muss angelegt und wieder frei-
gegeben werden. Beim Anlegen wird auch noch der Default-Konstruktor aufgerufen, der das
Objekt zunächst fehlerhaft“ mit 01 initialisiert. Anschließend werden erst die richtigen“ Werte
” ”
zugewiesen.
Bei der tatsächlichen Implementierung
Bruch Bruch::operator * (Bruch b)
{
return Bruch (zaehler * b.zaehler, nenner * b.nenner);
}
entfällt die falsche“ Initialisierung des lokalen Objekts. Stattdessen wird ein temporärer neuer
”
Bruch erzeugt, der auch gleich als Rückgabewert zurückgeliefert wird.
Indem man auf diese Weise das unnötige Erzeugen von Objekten vermeidet, kann zum Teil
erheblich Zeit gespart werden. Vor allem bei komplexen Klassen mit zeitaufwändigen Initialisie-
rungen macht sich dies bemerkbar.
Deshalb müsste die Implementierung der Operation eigentlich eine entsprechende Fallunter-
scheidung 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ässt sich aber vermeiden, indem sichergestellt wird, dass der Nen-
ner eines Bruchs nicht negativ sein kann. Da nicht nur sämtliche Operationen für existierende
Brüche, sondern auch das Initialisieren neuer Brüche selbst definiert wird, ist dies möglich.
Die einzige Stelle, an der ein Bruch mit negativem Nenner entstehen kann (nämlich im
int/int-Konstruktor), wird deshalb so umgeschrieben, dass ein eventuell vorhandenes negatives
Vorzeichen des Nenners in den Zähler gezogen wird:
Bruch::Bruch (int z, int n)
{
if (n < 0) {
zaehler = -z;
nenner = -n;
}
else {
zaehler = z;
nenner = n;
}
...
}
Damit kann das Umdrehen des Vergleichs bei negativem Nenner entfallen und der Vergleichs-
operator folgendermaßen implementiert werden:
bool Bruch::operator < (Bruch b)
{
return zaehler * b.nenner < b.zaehler * nenner;
}
Dies ist ein gutes Beispiel für den Vorteil der Datenkapselung: Solange die Schnittstelle nicht
verändert wird, kann die Verwaltung eines Bruchs intern in der Klasse beliebig implementiert
Sandini Bib
und unter verschiedenen Gesichtspunkten optimiert werden. Hätte der Anwender eines Bruchs
direkten Zugriff auf die Komponenten zaehler und nenner, wäre dies nicht möglich.
*this wird außerdem in einer Return-Anweisung am Ende der Operation verwendet. Das bedeu-
tet, die Operation *= liefert das Objekt, dem der neue Wert zugewiesen wurde, als Rückgabewert
zurück. Ein Aufruf wie
x *= y
ist also nicht unbedingt eine abgeschlossene Anweisung, sondern liefert auch etwas und kann
somit Teil eines größeren Ausdrucks sein. So könnte man z.B. eine Bedingung
if ((x *= 2) < 10) // Falls x nach Verdoppelung kleiner als 10 ist
formulieren.
Die Tatsache, dass das Objekt, für das eine Zuweisung aufgerufen wurde, zurückgeliefert
wird, ist eine Eigenschaft, die in C++ wie in C grundsätzlich für alle fundamentalen Datentypen
gilt. Verwendet wird dies vor allem beim Zuweisungsoperator, um Ausdrücke wie
x = y = 10; // 10 an y und dann y (also 10) an x zuweisen
oder
// Rückgabewert von fopen() an fp zuweisen und dann auf NULL testen
if ((fp = fopen(...)) != NULL) {
...
}
zu ermöglichen.
Auch die Default-Zuweisungsoperatoren von Klassen liefern das Objekt, dem etwas zuge-
wiesen wurde, zurück. Dies kann man ausnutzen, um den Operator *= noch prägnanter zu for-
mulieren:
Bruch Bruch::operator *= (Bruch b)
{
// ”x *= y” ==> ”x = x * y” und x zurückliefern
return *this = *this * b;
}
Man kann über die Lesbarkeit solcher Ausdrücke sicherlich streiten. Für die einen ist das die
Eleganz, für die anderen das Chaotische an C.
Der Programmierer hat in C++ den Vorteil, dass er für seine eigenen Klassen selbst definieren
kann, ob die Zuweisungsoperatoren das Objekt zurückliefern sollen oder nicht.
Wer dies nicht will, kann die multiplikative Zuweisung einfach so definieren, dass kein Rück-
gabewert zurückgeliefert wird:
void Bruch::operator *= (Bruch b)
{
// ”x *= y” ==> ”x = x * y”
return *this = *this * b;
}
Man beachte aber, dass dies in C++ eher unüblich ist. Anwender der Klasse werden üblicherweise
davon ausgehen, dass Zuweisungsoperatoren ein Teil eines größeren Ausdrucks sein können.
Sandini Bib
int main()
{
Bsp::Bruch x; // Bruch x deklarieren
Bsp::Bruch w(7,3); // Bruch w deklarieren
Sandini Bib
// Bruch w ausgeben
w.print();
// 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ächst wird die Operation * mit zwei Brüchen als Operanden (jeweils w) aufgerufen. Die
Operation liefert, wie in der Klasse deklariert, als Ergebnis einen neuen temporären Bruch
zurück.
Dieser zurückgelieferte 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 dekla-
rierte Operatorfunktion aufzurufen.
Eigentlich findet folgender Aufruf statt:
x.operator=(w.operator*(w)); // entspricht: x = w * w;
Für w wird die Elementfunktion operator* mit dem Parameter w aufgerufen. Dies verdeutlicht
erneut den Sachverhalt, dass in der objektorientierten Betrachtungsweise der erste Operand je-
weils 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 Wer-
ten, sondern wenden uns an den ersten Operanden mit der Bitte, das Produkt von sich selbst mit
einem zweiten übergebenen Operanden auszurechnen und zurückzuliefern. Das Ergebnis wird
als Parameter verwendet, der beim Aufruf von operator= für das Objekt x übergeben wird. Wir
wenden uns also an x mit der Bitte, den Wert des als Parameter übergebenen zweiten Operanden
zu ü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öhnt sind, mit Operatoren so umzugehen, und es auch als übersichtlicher empfinden.
Sandini Bib
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ür einen Bruch definiert wird.
Da auch diese Operation nicht zur Klasse gehört, besteht natürlich auch hier kein Zugriff auf
private Komponenten des Operanden. Es kann also nicht einfach der Zähler von b mit -1 mul-
tipliziert werden. Deshalb wird der ganze Bruch mit Hilfe der für Brüche öffentlich definierten
Multiplikation mit einer in einen Bruch umgewandelten -1 multipliziert.
Anwendbar wären 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.
Die Operatoren für die fundamentalen Datentypen (int, char, Zeiger etc.) sind festgelegt
und können weder umdefiniert noch erweitert werden. Allerdings kann die Verknüpfung ei-
nes abstrakten Datentyps mit einem fundamentalen Datentyp als globale Funktion oder als
Elementfunktion mit den hier angegebenen Einschränkungen frei definiert werden.
Folgende Operatoren können nicht für eigene Datentypen überladen werden:
.
::
sizeof
.*
?:
Ein Überladen ist nicht erlaubt, da die Operatoren bereits eine vordefinierte Bedeutung für
alle Objekte besitzen oder, wie im Fall des Operators ?:, die Möglichkeit des Überladens
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.
Der Zuweisungsoperator =
Für jede Klasse ist ein Default-Zuweisungsoperator definiert. Er weist komponentenweise zu und
liefert das Objekt zurück, dem der neue Wert zugewiesen wurde. Falls das nicht sinnvoll ist, kann
der Zuweisungsoperator für eine Klasse auch selbst definiert werden.
Soll keine Zuweisung möglich sein, muss der Zuweisungsoperator einfach nur privat dekla-
riert werden. Die folgende Deklaration würde z.B. eine Zuweisung zweier Brüche unmöglich
machen:3
class Bruch {
...
private:
// Zuweisung verboten, da privat
Bruch operator = (Bruch);
};
Da eine komponentenweise Zuweisung bei Klassen mit Zeigern als Komponenten meistens feh-
lerhaft ist, muss der Zuweisungsoperator für 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
Der Indexoperator [ ]
Auch der Indexoperator4 [ ], mit dem über einen Index auf ein bestimmtes Element innerhalb ei-
nes Feldes zugegriffen werden kann, kann für 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 übergebene Index der zweite
Operand ist. Bei Elementfunktionen wird der Index also als Parameter übergeben.
4
Er wird auch als Indizierungsoperator oder Subskriptionsoperator bezeichnet.
Sandini Bib
Ein typisches Beispiel dafür sind Klassen für 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ür 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ückliefern
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;
Der Aufruf-Operator ()
Auch der Funktionsaufruf ist ein Operator, der für eigene Klassen definiert werden kann. Diese
sicherlich etwas ü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öglich:
void f()
{
X a;
4.2.7 Zusammenfassung
Für die Objekte einer Klasse können Operatoren definiert werden. Sie werden mit dem
Schlüsselwort operator als Operatorfunktionen deklariert.
Operatoren können für Klassen überladen werden.
Die Menge möglicher Operatoren, ihre Priorität, Syntax und Auswertungsreihenfolge ist fest
definiert und kann nicht geändert werden.
Operatoren sollten immer so implementiert werden, dass sie tun, was man grundsätzlich von
ihnen erwartet. Das entspricht in der Regel dem, was mit ihnen bei fundamentalen Datentypen
verbunden wird.
Wenn Operatoren für eigene Klassen definiert werden, sollten z.B. folgende Beziehungen
weiterhin gelten:
x + y verändert weder x noch y
x - y entspricht x + (-y)
x op= y entspricht x = x op y
x = x + 1 entspricht x += 1 entspricht ++x
p->k entspricht (*p).k entspricht p[0]
In einer Elementfunktion bezeichnet this einen Zeiger auf das Objekt, für das die Funktion
aufgerufen wurde. *this ist also immer das Objekt, für 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
Headerdatei
Die Headerdatei der Klasse Bruch erhält mit der Einführung der neuen Sprachmittel nun insge-
samt folgenden Aufbau:
// klassen/bruch3.hpp
#ifndef BRUCH_HPP
#define BRUCH_HPP
// Standard-Headerdateien einbinden
#include <iostream>
Sandini Bib
/* Klasse Bruch
*/
class Bruch {
private:
int zaehler;
int nenner;
public:
/* neu: Default-Konstruktor, Konstruktor aus Zähler und
* Konstruktor aus Zähler 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ähler und Nenner einfach multiplizieren
* - das Kürzen sparen wir uns
Sandini Bib
*/
return Bruch (zaehler * b.zaehler, nenner * b.nenner);
}
#endif // BRUCH_HPP
Bevor auf die Änderungen im Einzelnen eingegangen wird, soll noch die dazugehörige neue
Quelldatei vorgestellt werden.
Quelldatei
In der Quelldatei der Klasse Bruch wird entsprechend den Änderungen in der Headerdatei nur
noch ein Konstruktor definiert. Die Definition der beiden inline definierten Operatoren entfällt:
// klassen/bruch3.cpp
// Headerdatei der Klasse einbinden
#include "bruch.hpp"
// Standard-Headerdateien einbinden
#include <cstdlib>
nenner = -n;
}
else {
zaehler = z;
nenner = n;
}
}
/* Operator *=
*/
Bruch Bruch::operator *= (Bruch b)
{
// ”x *= y” ==> ”x = x * y”
*this = *this * b;
/* Operator <
*/
bool Bruch::operator < (Bruch b)
{
// da die Nenner nicht negativ sein können, reicht:
return zaehler * b.nenner < b.zaehler * nenner;
}
4.3.2 Default-Argumente
In der neuen Version der Klasse Bruch werden die bisher verwendeten drei Konstruktoren zu
einer Funktion zusammengefasst: Für den Fall, dass der zweite Parameter (der Nenner) nicht
übergeben wird, wird der Default-Wert 1 verwendet. Wird auch der erste Parameter (der Zähler)
nicht übergeben, wird dafür 0 angenommen:
class Bruch {
...
Bruch (int = 0, int = 1);
...
};
Sandini Bib
Die angegebenen Default-Werte werden vom Compiler automatisch eingesetzt, wenn zu wenig
Parameter übergeben werden. Da dies bereits beim Aufruf geschieht, ist es für die Implementie-
rung 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öglich ist, dürfen Default-Werte nur am Ende einer Parame-
terliste definiert werden. Beim Aufruf werden die ü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
Übergabe von nur einem Argument immer um den ersten Parameter handelt. Selbstverständlich
können auch globale Funktionen Default-Werte besitzen.
Die folgende globale Funktion max() liefert z.B. das Maximum von zwei bis vier vorzei-
chenfreien ganzen Zahlen:
unsigned max (unsigned, unsigned, unsigned = 0, unsigned = 0);
Beim Aufruf der Funktion mit zwei Argumenten wird für 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 entspre-
chende Fehlermeldung ausgegeben:
void f (char*= "hallo"); // FEHLER
Es ist nicht erlaubt, Default-Argumente in einer zweiten Deklaration neu zu definieren. Für 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üsste als Zeiger auf eine Funk-
tion deklariert werden, der vier unsigneds übergeben werden:
unsigned (* funkzeiger) (unsigned, unsigned, unsigned, unsigned);
Um auch hier wieder Default-Werte verwenden zu können, müssen diese wiederum definiert
werden:
unsigned (* funkzeiger) (unsigned, unsigned,
unsigned = 0, unsigned = 0);
Wenn eine Funktion auch über einen Funktionszeiger aufgerufen werden kann, können zwar
unterschiedliche Default-Werte verwendet werden – man sollte dies aber nie tun. Ein direkter
Aufruf von max() mit zwei Parametern führt sonst zu einem anderen Ergebnis als der Aufruf
über den Funktionszeiger, was sehr verwirrend sein kann.
Sandini Bib
4.3.3 Inline-Funktionen
Funktionen können inline deklariert werden. Das Wort inline“ bedeutet dabei, dass ein Funk-
”
tionsaufruf durch die Anweisungen der aufgerufenen Funktion ersetzt werden kann. Damit dies
möglich ist, werden bei der Deklaration in der Headerdatei auch gleich die entsprechenden An-
weisungen definiert.
Inline-Funktionen können auf zweierlei Arten definiert werden:
Entweder wird die Funktion mit dem Schlüsselwort inline in der Headerdatei definiert:
class Bruch {
...
bool operator < (Bruch);
...
};
Sandini Bib
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ällen wird damit dem Compiler die Möglichkeit gegeben, statt eines Funktionsaufrufs
Code zu generieren, der die Anweisungen in der Funktion direkt ausführt. Ein Aufruf wie
b.print()
kann der Compiler also als
std::cout << b.zaehler << '/' << b.nenner << std::endl;
übersetzen, da er weiß, dass dies semantisch den gleichen Effekt hat.
Inline-Funktionen ersetzen damit die in C üblichen Makros und haben einen wichtigen Vor-
teil: Inline-Funktionen sind kein blinder“ Textersatz, sondern besitzen semantisch betrachtet
”
keinen Unterschied zu normalen Funktionen. So findet genauso wie bei Funktionen eine Typ-
überprüfung statt, und es existieren genau die gleichen Gültigkeitsbereiche. Auch sind keine
Seiteneffekte durch eine fehlende Klammerung oder durch doppelten Ersatz eines Parameters
wie n++ möglich. Für 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ür einen Funktionsaufruf Code zu generieren, der den Aufruf
tatsächlich durchführt oder die Anweisungen der Funktion direkt ausführt. Oft kann dies auch
über Compiler-Optionen beeinflusst werden.
Um eine Ersetzung vornehmen zu können, muss allerdings die Implementierung der Funk-
tion bereits beim Kompilieren des Funktionsaufrufs bekannt sein. Aus diesem Grund müssen
Inline-Funktionen, die in mehr als einer Quelldatei verwendet werden sollen, in der Headerdatei
implementiert werden. Damit erklärt sich auch, weshalb das Schlüsselwort inline überhaupt
angegeben werden muss: Es verhindert die Fehlermeldung, dass eine Funktion aufgrund mehr-
facher 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öglichkeit zur Manipulation der Implementierung ei-
ner Inline-Funktion. Diese Möglichkeit besteht bei einer Implementierung in einer kompilierten
Quelldatei nicht. Hier zeigt sich bereits, dass in C++ ein Laufzeitvorteil zu Lasten streng ob-
Sandini Bib
int main()
{
using namespace Bsp; // neu: alle Symbole des Namensbereichs std sind global
4.3.5 Using-Direktiven
Namensbereiche wurden eingeführt, um Namenskonflikte zu vermeiden und es deutlich zu ma-
chen, wenn mehrere Symbole zusammengehören. Dabei können diese Symbole auch auf mehrere
Dateien verteilt werden. Mit Hilfe des Schlüsselworts using kann man allerdings die mitunter
ermüdende immer wiederkehrende Angabe des Namensbereiches vermeiden. Dabei gibt es zwei
Möglichkeiten:
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ültigkeitsbereich zu einem lokalen Synonym für Bsp::Bruch.
Derartige Deklarationen entsprechen einer Deklaration lokaler Variablen und können auch
globale Variablen überdecken. Beispiel:
namespace X { // Namensbereich X
int i, j, k;
}
int k; // globales k
void usingDeklarationen()
{
int j = 0;
using X::i; // Name i aus Namensbereich X lokal zugreifbar
using X::j; // FEHLER: j ist in f2() zweimal deklariert
using X::k; // überdeckt globales k
++i; // X::i
++k; // X::k
}
Using-Direktiven
Eine Using-Direktive ermöglicht 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ösen kann. Durch die Direktive
using namespace Bsp;
werden alle Symbole des Namensbereichs Bsp im aktuellen Gültigkeitsbereich zu globalen Va-
riablen.
Sandini Bib
Befindet sich im globalen Gültigkeitsbereich bereits ein Symbol, das sich auch im global gewor-
denen Namensbereich befindet, wird bei dessen Verwendung eine Fehlermeldung ausgegeben.
Beispiel:
namespace X { // Namensbereich X
int i, j, k;
}
int k; // globales k
void usingDirektive()
{
int j = 0;
using namespace X; // Namensbereich X global zugreifbar
++i; // X::i
++j; // lokales j
++k; // 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ühren, dass
sich die Sichtbarkeit anderer Symbole ändert, was zu Mehrdeutigkeiten und sogar zu geänder-
tem Verhalten führen kann (ein Funktionsaufruf findet plötzlich 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 Opera-
tion auch in allen Namensbereichen der übergebenen Argumente gesucht. Diese Regel wird als
Koenig-Lookup bezeichnet. Beispiel:
#include "bruch.hpp"
Bsp::Bruch x;
...
hilfsfunktion(x); // OK, Bsp::hilfsfunktion() wird gefunden
Sandini Bib
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ählte nämlich
nicht zu dem Block, der den Schleifenkörper enthält. Eine zweite For-Schleife im gleichen Block
konnte somit nicht genauso implementiert werden:
for (int i=0; i<anz; i++) {
...
}
...
for (int i=0; i<anz; i++) { // FEHLER: i zum zweiten Mal deklariert
...
}
Diese sicherlich unschöne Eigenschaft wurde im Rahmen der Standardisierung von C++ inzwi-
schen korrigiert. In Bedingungen von If-, Switch-, While- und For-Anweisungen dürfen Deklara-
tionen erscheinen, die dann aber nur für den Gültigkeitsbereich dieser Anweisung gelten. Für die
gesamte Anweisung wird sozusagen ein eigener Block eröffnet. Aufgrund der relativen späten
Änderung der Sprache gibt es aber leider immer noch Systeme, für die i auch nach der Schlei-
fe noch bekannt ist, was bei der Implementierung portablen Codes unter Umständen beachtet
werden muss.
4.3.7 Copy-Konstruktoren
Im Beispielprogramm wird der Bruch x bei seiner Deklaration auch gleich mit dem temporären
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ären 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ält.
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ür 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 übergebenen Objekts initialisiert. Ist eine solche Komponente
selbst ein Objekt einer Klasse, so wird auch dafür der Copy-Konstruktor aufgerufen. In unserem
Sandini Bib
Beispiel werden also der Zähler und der Nenner von x mit dem Zähler 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ür bestimmte Zwecke manchmal selbst
definiert werden. In Abschnitt 6.1.3 wird darauf eingegangen.
4.3.8 Zusammenfassung
Für die Parameter von Funktionen können Default-Argumente definiert werden. Diese wer-
den verwendet, wenn die entsprechenden Parameter beim Funktionsaufruf nicht übergeben
werden.
Funktionen und Operatorfunktionen können inline deklariert werden. Damit erhält der Com-
piler die Möglichkeit, einen Funktionsaufruf durch die Anweisungen der Funktion zu erset-
zen. Dies ist für die Semantik eines Programms völlig unerheblich und dient nur dazu, die
Laufzeit zu verkürzen.
Deklarationen müssen nicht unbedingt am Blockanfang, sondern können an beliebiger Stelle
erfolgen. Ihr Gültigkeitsbereich erstreckt sich von der Deklaration bis zum Blockende.
Sandini Bib
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 Na-
mensbereichs entfallen.
Eine Funktion wird automatisch auch in allen Namensbereichen der übergebenen Argumente
gesucht.
Using-Direktiven sollten nie in Headerdateien verwendet werden.
Sandini Bib
return *this;
// Kopie vom ersten Operanden wird zurückgeliefert
}
Genauso wird von der Funktion eine Kopie von *this (in unserem Beispiel also eine Kopie von
x) zurückgeliefert. Diese Kopie wird ebenfalls mit Hilfe des Copy-Konstruktors als temporäres
Objekt erzeugt.
Sandini Bib
Ein Anlegen von Kopien ist für einfache Datentypen wie ints, floats und Zeiger zunächst
akzeptabel. Bei Objekten mit vielen mehr oder weniger komplexen Komponenten kann dies aber
zu einem erheblichen Laufzeitnachteil führen, da mit jeder Parameterübergabe eine Kopie des
Objekts angelegt werden muss.
Die unter C übliche Alternative wäre es, Zeiger zu übergeben. Die Parameter sind dann
Adressen der zu verändernden Objekte. Von diesen werden zwar ebenfalls Kopien angelegt, doch
zeigt auch eine Kopie einer Adresse auf dasselbe Objekt und ermöglicht es so, auf dessen Wert
zuzugreifen. Zeiger emulieren damit das Gegenstück zu call-by-value, nämlich call-by-reference.
Wenn die Laufzeitnachteile, die durch das Anlegen von Kopien bei der Parameterübergabe
entstehen, nur mit Zeigern umgangen werden könnten, wäre es nicht möglich, mit eigenen Daten-
typen wie mit fundamentalen Datentypen rechnen zu können. Eine Multiplikation von Brüchen
müsste dann mit einem Zeiger als zweitem Operanden deklariert und aufgerufen werden. Im
Anwendungsprogramm hätte eine Multiplikation dann die Form:
x * &a // x mit a (ohne Kopie) multiplizieren
Aus diesem Grund wurden Referenzen eingeführt. Sie ermöglichen es, Argumente zu übergeben,
ohne dass Kopien angelegt oder Zeiger verwendet werden müssen.
Es gibt nun aber zwei Probleme:
Da die Möglichkeit besteht, dass das Objekt, das als Parameter übergeben wurde, verändert
wird, kann keine Konstante mehr übergeben werden. Dies betrifft z.B. temporäre Objekte,
die grundsätzlich konstant sind.
Es droht die Gefahr, dass Objekte, die zur Laufzeitersparnis als Referenz übergeben werden,
in der aufgerufenen Funktion versehentlich geändert werden.
Hier kommt uns jedoch die Möglichkeit zugute, Objekte als Konstanten deklarieren zu können.
Damit wird per Deklaration ausgeschlossen, dass der Wert eines übergebenen Parameters verän-
dert werden kann.
4.4.2 Referenzen
Eine als Referenz deklarierte Variable ist nichts weiter als ein zweiter Name, der für ein exi-
stierendes Objekt vergeben wird. Deklariert wird eine Referenz durch ein zusätzliches &. Die
folgende Anweisung deklariert z.B. r als Referenz (zweiter Name) für a:
int& r = x; // r ist eine Referenz (ein zweiter Name) für x
Obwohl die Schreibweise es C-Programmierern suggeriert, sollte man sich unbedingt von der
Vorstellung lösen, dass es sich um einen Zeiger handelt. Eine Referenz ist vom Typ her kein
Zeiger, sondern hat den gleichen Typ wie das Objekt, für das sie steht. Ihre Anwendung unter-
scheidet sich in keiner Weise von der für das Objekt, für das sie einen zweiten Namen definiert.
Nach obiger Deklaration ist es völlig unerheblich, ob im Folgenden r oder x verwendet wird.
Die Deklaration einer Referenz erzeugt also kein neues Objekt, sondern definiert für ein exi-
stierendes Objekt eine alternative Bezeichnung. Das Objekt, für das sie steht, muss bei der De-
klaration angegeben werden und kann nicht gewechselt werden.
Sandini Bib
x 7 y 13
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ält:
x 13 y 13
tmp = a;
a = b;
b = tmp;
}
5
Pascal-Programmierer kennen Referenzen als Parameter bereits. Sie werden dort durch das Schlüsselwort
var bei der Parameterdeklaration realisiert.
Sandini Bib
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; // x: 7 y: 13
cout << "x: " << x << " y: " << y << endl; // x: 13 y: 7
}
a wird beim Aufruf von swap() ein zweiter Name für x und b ein zweiter Name für y. Eine
Zuweisung an a ändert also x und eine Zuweisung an b ändert y.
Ein derartiges Sprachmittel hat Vor- und Nachteile, und für C-Programmierer, denen das
Sprachmittel der Referenzen nicht zur Verfügung steht, kann dies je nach Sichtweise die Erfül-
lung eines Traums oder das Wahrwerden eines Alptraums sein:
Der Vorteil von Referenzen ist, dass zum Verändern von Argumenten keine Zeiger mehr über-
geben werden müssen. Für die obige Funktion reicht der Aufruf swap(x,y)“, und die Werte
”
werden vertauscht. In C muss man für 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 erken-
nen kann, ob die übergebenen Argumente verändert werden können. Bei einem Aufruf von
swap(x,y)“ ist in C garantiert, dass beide Argumente nicht modifiziert werden können,
”
da auf jeden Fall Kopien angelegt werden. Um in C++ zu wissen, ob ein Argument inner-
halb 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, wirk-
lich einzig und allein von der Deklaration der Parameter abhängt. Die Anweisungen in der Funk-
tion verwenden a und b, als wären sie als einfache Integer definiert.
4.4.3 Konstanten
Referenzen als Konstanten
Bei der Operatorfunktion *= gibt es nun allerdings noch ein Problem: Da es per Deklaration
möglich ist, dass der Wert des übergebenen Parameters geändert wird, muss es sich um ein Objekt
handeln, dem etwas zugewiesen werden darf. Damit aber scheiden sowohl Konstanten als auch
temporäre Objekte als Parameter aus. Ein Aufruf wie
x *= Bsp::Bruch(2,3);
oder
x *= w * w;
wäre nicht möglich.
Aus diesem Grund sollte ein als Referenz übergebener Parameter, wenn er nicht verändert
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;
Konstanten allgemein
Jedes Symbol kann in C++ als Konstante definiert werden. Bei seiner Deklaration muss einfach
zusätzlich das Schlüsselwort const angegeben werden. Compiler testen in diesem Fall, ob der
Wert auch wirklich nicht verändert wird.
Beispiele:
const int MAX = 3;
const double pi = 3.141592654;
Konstanten ersetzen die in C übliche Praxis, für den Programm-Code geltende Konstanten über
die Präprozessor-Anweisung #define zu definieren. Die blinde“ Ersetzung durch den Präpro-
”
zessor wird durch eine Deklaration eines Symbols ersetzt, für das genau wie bei Variablen eine
strenge Typprüfung durchgeführt wird und ein Gültigkeitsbereich vorgegeben ist. Der einzige
aber entscheidende Unterschied zu Variablen ist, dass ihr Wert nicht verändert werden darf. Dar-
aus folgt, dass ihr Wert bei der Deklaration festgelegt werden muss. Jede Konstante muss also
bei der Deklaration initialisiert werden.
Sandini Bib
Auf diese Weise lässt sich auch ein Bruch als Konstante deklarieren:
const Bsp::Bruch mwst(16,100);
Wenn für die Operation *= als zweiter Operand eine Konstante zugelassen ist, ist dafür auch der
Aufruf x *= mwst erlaubt.
4.4.4 Konstanten-Elementfunktionen
Was ist aber mit dem ersten Operanden? Bei der Operation *= wird dieser verändert, 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ür den der Ope-
rator aufgerufen wird, eine Konstante sein kann. Er wird dadurch schließlich nicht verändert.
Damit dies möglich ist, muss eine Elementfunktion, die für konstante Objekte aufgerufen
werden kann, als solche gekennzeichnet werden. Eine solche Konstanten-Elementfunktion (eng-
lisch: constant member function) wird durch das Schlüsselwort const zwischen der Parameter-
liste und dem Funktionskörper 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ür den diese Operation aufgerufen
wird (also der erste Operand), nicht verändert wird. Jeder Versuch, zaehler oder nenner zu
manipulieren, wird deshalb zu einem Syntaxfehler führen.
Man beachte, dass bei der Multiplikation keine Referenz zurückgeliefert werden darf, da es
sich beim Rückgabewert um ein lokales Objekt handelt. Ansonsten wird ein zweiter Name für
etwas zurückgeliefert, das mit Beendigung der Funktion gar nicht mehr existiert, nämlich der
durch den Ausdruck
Bruch (zaehler*b.zaehler, nenner*b.nenner)
erzeugte temporäre Bruch. Die meisten Compiler erkennen einen derartigen Fehler und geben
eine entsprechende Warnung aus.
Da eine Kopie und keine Referenz zurückgeliefert wird, muss der Rückgabewert nicht mit
const deklariert werden. Temporäre Objekte sind grundsätzlich konstant.
Headerdatei
Die Headerdatei deklariert nun die Funktionen, soweit es sinnvoll ist, mit Referenzen und Kon-
stanten und hat folgenden Aufbau:
Sandini Bib
// klassen/bruch4.hpp
#ifndef BRUCH_HPP
#define BRUCH_HPP
// Standard-Headerdateien einbinden
#include <iostream>
class Bruch {
private:
int zaehler;
int nenner;
public:
/* Default-Konstruktor, Konstruktor aus Zähler und
* Konstruktor aus Zähler 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;
};
/* Operator *
* - inline definiert
*/
Sandini Bib
#endif // BRUCH_HPP
Die Elementfunktionen print(), operator*() und operator<() verändern das Objekt, für
das sie aufgerufen werden (bzw. den ersten Operanden), nicht und werden deshalb als Konstan-
ten-Elementfunktionen deklariert. Bei allen drei Operatorfunktionen wird der Parameter jetzt
außerdem als Referenz übergeben. Durch die Deklaration als Konstante kann es sich dabei eben-
falls um Konstanten oder temporäre Objekte handeln.
Quelldatei
In der Quelldatei werden die Deklarationen entsprechend angepasst. Die Implementierung der
Funktionen musste aber nirgendwo geändert werden:
// klassen/bruch4.cpp
// Headerdatei der Klasse einbinden
#include "bruch.hpp"
// Standard-Headerdateien einbinden
#include <cstdlib>
if (n == 0) {
// Programm mit Fehlermeldung beenden
std::cerr << "Fehler: Nenner ist 0" << std::endl;
std::exit(EXIT_FAILURE);
}
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;
/* Operator <
*/
bool Bruch::operator < (const Bruch& b) const
{
// da die Nenner nicht negativ sein können, reicht:
return zaehler * b.nenner < b.zaehler * nenner;
}
Anwendung
Die geänderten Deklarationen haben auf das Anwendungsprogramm überhaupt keinen Einfluss.
Allerdings können auch hier nun, soweit es sinnvoll ist, konstante Objekte verwendet werden:
Sandini Bib
// klassen/btest4.cpp
// Headerdateien für die verwendeten Klassen einbinden
#include "bruch.hpp"
int main()
{
using namespace Bsp; // alle Symbole des Namensbereichs std sind global
4.4.7 Zusammenfassung
Durch die Angabe von & im Typ einer Deklaration wird eine Referenz deklariert. Eine Refe-
renz ist ein (temporärer) zweiter Name“ für ein existierendes Objekt.
”
Durch Verwendung von Referenzen bei der Deklaration von Parametern und Rückgabewerten
kann das Anlegen von Kopien verhindert werden (call-by-reference statt call-by-value).
Referenzen müssen bei der Deklaration initialisiert werden und können das Objekt, für das
sie stehen, nicht wechseln.
Durch das Schlüsselwort const können Konstanten deklariert werden.
Ein const hinter der Parameterliste einer Elementfunktion definiert eine so genannte Kons-
tanten-Elementfunktion. In ihr darf das Objekt, für das sie aufgerufen wurde, nicht verändert
werden. Damit kann dieses Objekt auch eine Konstante sein.
const sollte immer verwendet werden, wenn etwas nicht verändert werden soll oder kann.
Damit wird insbesondere sichergestellt, dass man auch temporäre Objekte als Argumente
verwenden kann.
Sandini Bib
4.5.1 Streams
Die Ein- und Ausgabe wird in C++ mit Hilfe von Streams durchgeführt. Ein Stream ist sozusa-
gen ein Datenstrom“, in dem Zeichenfolgen entlangfließen“. Als konsequente Anwendung der
” ”
objektorientierten Idee sind Streams Objekte, deren Eigenschaften in Klassen definiert werden.
Für die Standardkanäle 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 ver-
schiedene Klassen dafür. Die beiden wichtigsten sind:
istream
Die Klasse istream ist ein Eingabestrom“ (input stream), von dem Daten gelesen wer-
”
den können.
ostream
Die Klasse ostream ist ein Ausgabestrom“ (output stream), auf den Daten ausgegeben
”
werden können.
Beide Klassen werden, wie alle Elemente der Standardbibliothek im Namensbereich std defi-
niert. Dies kann man sich in etwa wie folgt vorstellen:
namespace std {
class istream {
...
};
class ostream {
...
};
...
}
Sandini Bib
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ächlichen
Klassen im Einzelnen vorgestellt und ihre Anwendungsmöglichkeiten 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 Re-
gel die Eingaben einliest. Er entspricht der C-Variablen stdin und wird von den Betriebssy-
stemen 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 Be-
triebssystemen 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öglich,
”
diese in der Betriebssystemumgebung, durch die das Programm aufgerufen wird, unterschiedlich
zu behandeln. Dies resultiert aus dem UNIX-Konzept der Ein- und Ausgabeumlenkung, für das
C ursprünglich geschrieben wurde. Die normalen Ausgaben eines Programms können 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 ver-
einfacht 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ür Protokollausgaben vorgesehen und gibt seine Ausgaben ebenfalls auf dem
Standard-Fehlerausgabekanal, allerdings gepuffert, aus.
// io/iobsp.cpp
// Headerdatei für I/O mit Streams
#include <iostream>
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;
}
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änkt und somit universell einsetz-
bar.
Sandini Bib
Mit dem Operator << können mehrere Objekte in einer Anweisung ausgegeben werden. Die Aus-
gabeoperation liefert jeweils den ersten Operanden (also den Stream) zur weiteren Verwendung
zurück. Dies ermöglicht 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ächst
std::cout << x
ausgeführt. Da dieser Ausdruck wieder std::cout zurückliefert, wird anschließend
cout << " durch "
ausgeführt. Entsprechend werden anschließend der Integer y, gefolgt von der Stringkonstante
" ergibt " und dem Ergebnis der Operation x / y (der Divisionsoperator hat eine höhere
Priorität) ausgegeben.
Besitzen x und y z.B. die Werte 91 und 7, wird Folgendes ausgegeben:
91 durch 7 ergibt 13
Wir könnten uns mit dem jetzigen Wissen bereits die dazu notwendige Implementierung der
Klasse ostream vorstellen. Für Objekte dieses Typs wird der Operator << mit allen fundamen-
talen Datentypen als zweiten Operanden überladen:
namespace std {
class ostream {
public:
ostream& operator<< (char); // Zeichen ausgeben
ostream& operator<< (int); // ganze Zahl ausgeben
ostream& operator<< (long); // ganze Zahl ausgeben
ostream& operator<< (double); // Gleitkommazahl ausgeben
ostream& operator<< (const char*); // C-String ausgeben
...
};
}
Beim Aufruf dieser Operatoren wird jeweils der erste Operand (also der Stream selbst) zurück-
geliefert:
namespace std {
ostream& ostream::operator<< (char) {
... // Low-level-Funktion zum Ausgeben des Zeichens
return *this; // Stream zur Verkettung zurück
}
...
}
Sandini Bib
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 Manipu-
lation am Stream durchführt. Damit können z.B. Ausgabeformate definiert oder Puffer geleert
werden. Es wird damit also nicht unbedingt etwas ausgegeben.
Der Manipulator endl steht für 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.
Was Manipulatoren genau sind, welche es noch gibt und wie man eigene definieren kann, wird
in Abschnitt 8.1.5 erläutert.
Man beachte, dass beim Einleseoperator der zweite Operand ein Parameter ist, dessen Wert
verändert 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>> (char&); // Zeichen einlesen
istream& operator>> (int&); // ganze Zahl einlesen
istream& operator>> (long&); // ganze Zahl einlesen
istream& operator>> (double&); // Gleitkommazahl einlesen
...
};
}
Für Stream-Objekte ist also der Operator ! so definiert, dass er jeweils einen Booleschen Wert
zurückliefert, 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
...
};
}
Über einen ähnlichen Trick ist auch der positive Test möglich: Boolesche Bedingungen in If-
oder While-Anweisungen verlangen in C++ nämlich entweder einen integralen Typ (sämtliche
Formen von int oder char) oder eine eindeutige Typumwandlung eines Objekts einer Klasse in
einen arithmetischen Typ oder einen Zeiger.
Da für Streams eine solche Konvertierungsfunktion definiert wird, ist auch der positive Test
möglich:
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öglich. Was hier genau passiert, wird in den Abschnitten über
Konvertierungsfunktionen (siehe Seite 234) und deren Anwendung bei Klassen mit dynamischen
Komponenten (siehe Seite 382) erläutert.
Ein typisches Anwendungsbeispiel für die Möglichkeit, 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üst von C für C++-Objekte. Dabei muss aber beachtet werden,
dass der Operator >> führende Trennzeichen überliest. Die Version mit char als obj muss des-
halb auf eine andere Art implementiert werden (siehe Seite 483).
So schön die Anwendung dieser speziellen Operatoren in Booleschen Ausdrücken auch ist,
eines muss beachtet werden: Die doppelte Verneinung hebt sich hier nicht auf:
Sandini Bib
”
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ürdig betrachtet werden kann). Der Ausdruck in der If-Bedingung ist zunächst nicht das,
was normalerweise erwartet wird, nämlich ein Boolescher Wert. Erst eine implizite Typumwand-
lung, deren Vorhandensein und Bedeutung zum Verständnis 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ück, ob ein Stream in einem fehlerfreien Zustand ist) das Programm (noch) lesbarer
machen würde:
std::cin >> x;
if (! std::cin.good()) {
...
}
Bitkonstante Bedeutung
goodbit alles in Ordnung
eofbit End-Of-File (Ende der Daten)
failbit Fehler: letzter Vorgang nicht korrekt abgeschlossen
badbit fataler Fehler: Zustand nicht definiert
Der Unterschied zwischen failbit und badbit besteht im Wesentlichen darin, dass badbit
fataler ist:
failbit wird gesetzt, wenn ein Vorgang nicht korrekt durchgeführt 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örigen Elementfunktionen good(), eof(), fail() und
bad() ermittelt werden. Zurückgeliefert 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
Elementfunktion Bedeutung
good() alles in Ordnung (ios::goodbit gesetzt)
eof() End-Of-File (ios::eofbit gesetzt)
fail() Fehler (ios::failbit oder ios::badbit gesetzt)
bad() fataler Fehler (ios::badbit gesetzt)
rdstate() liefert die aktuell gesetzten Flags
clear() löscht oder setzt einzelne Flags
Beim Aufruf von clear() ohne Parameter werden alle Fehlerflags (auch ios::eofbit) gelöscht
(daher kommt auch der Name clear“):
”
// alle Fehlerflags (einschließlich eofbit) zurücksetzen
strm.clear();
Wird aber ein Parameter übergeben, werden die darin übergebenen Flags gesetzt und die anderen
zurückgesetzt. Das folgende Beispiel testet für den Stream strm, ob das Failbit-Flag gesetzt ist,
und löscht es gegebenenfalls:
if (strm.rdstate() & std::ios::failbit) {
std::cout << "Failbit war gesetzt" << std::endl;
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üpft. In diesem Fall ist dann auch wieder der erste Operand ein Parameter.
Entsprechendes gilt für einen Aufruf wie:
std::cout << x
x muss entweder einen Datentyp haben, für den innerhalb der Klasse std::ostream der Ope-
rator << 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 << ver-
knüpft:
// beide Operanden sind Parameter:
std::ostream& operator<< (std::ostream&, typ);
Das Erstere gilt für alle fundamentalen Datentypen. Die zweite Möglichkeit 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örigen Klasse auf.
Sandini Bib
Wie dies genau aussieht, wird nun anhand der folgenden neuen Version der Klasse Bruch ver-
deutlicht.
// Standard-Headerdateien einbinden
#include <iostream>
class Bruch {
private:
int zaehler;
int nenner;
public:
/* Default-Konstruktor, Konstruktor aus Zähler und
* Konstruktor aus Zähler 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;
/* Operator *
* - inline definiert
*/
inline Bruch Bruch::operator * (const Bruch& b) const
{
/* Zähler und Nenner einfach multiplizieren
* - das Kürzen sparen wir uns
*/
return Bruch (zaehler * b.zaehler, nenner * b.nenner);
}
/* neu: Standard-Ausgabeoperator
* - global überladen und inline definiert
*/
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
b.printOn(strm); // Elementfunktion zur Ausgabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
/* neu: Standard-Eingabeoperator
* - global ü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ückliefern
}
#endif // BRUCH_HPP
Damit Brüche mit dem Standard-Stream-Mechanismus verwendet werden können, werden die
Ein- bzw. Ausgabeoperatoren (<< bzw. >>) global überladen:
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
b.printOn(strm); // Elementfunktion zur Ausgabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
Sandini Bib
inline
std::istream& operator >> (std::istream& strm, Bruch& b)
{
b.scanFrom(strm); // Elementfunktion zur Eingabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
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ätzlicher Parameter der Stream, auf den der Bruch ausgegeben werden muss, zu ü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ürde nämlich nicht nur Zeit kos-
ten, sondern auch zu Fehlern führen: Der Stream wird durch die Operation manipuliert (Puffer
ändern sich, der Status kann in den Fehlerzustand wechseln usw.). Diese Manipulationen würden
in einer Kopie aber verloren gehen und können zu Inkonsistenzen führen. Wenn in einem späteren
Ausdruck wieder das Original verwendet wird, ist dessen Zustand nicht geändert und entspricht
somit nicht der Realität. Aus diesem Grund müssen Stream-Parameter und -Rückgabewerte im-
mer als Referenzen deklariert werden.
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() er-
setzt werden. Sie gibt Zähler und Nenner des Bruchs auf dem übergebenen Ausgabe-Stream
strm in der Form zähler/nenner“ aus:
”
void Bruch::printOn (std::ostream& strm) const
{
strm << zaehler << '/' << nenner;
}
Schließlich kommt die Elementfunktion scanFrom() hinzu, die den Bruch vom übergebenen
Eingabe-Stream strm einliest, indem nacheinander Zähler 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ähler einlesen
strm >> z;
// Lesefehler?
if (! strm) {
Sandini Bib
return;
}
// Nenner == 0?
if (n == 0) {
// Fail-Bit setzen
strm.clear (strm.rdstate() | std::ios::failbit);
return;
}
beides nicht sinnvoll sein. Durch die Möglichkeit 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 äquivalent zum Einlesen einer ganzen Zahl. Der Vorteil dabei ist, dass das An-
wendungsprogramm die Umstände kennt, unter denen die Funktion aufgerufen wurde, und
entsprechend reagieren kann. Der Nachteil ist, dass, wenn das Anwendungsprogramm diesen
Test nicht durchführt, der Lesefehler zunächst unbemerkt bleibt.
Mit dem Konzept der Ausnahmebehandlung wird ein besserer Mechanismus für die Feh-
lerbehandlung eingeführt. 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 Feh-
lerflag 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 <cstdlib>
6
Der Bitoperator | verknüpft die Bits mit der ODER-Funktion und liefert damit alle Bits, die in beiden
Operanden gesetzt sind.
Sandini Bib
* - Default für n: 1
*/
Bruch::Bruch (int z, int n)
{
/* Zähler und Nenner wie übergeben initialisieren
* - 0 als Nenner ist allerdings nicht erlaubt
* - ein negatives Vorzeichen des Nenners kommt in den Zähler
*/
if (n == 0) {
// neu:
std::cerr << "Fehler: Nenner ist 0" << std::endl;
std::exit(EXIT_FAILURE);
}
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;
/* Operator <
*/
bool Bruch::operator < (const Bruch& b) const
{
// da die Nenner nicht negativ sein können, reicht:
return zaehler * b.nenner < b.zaehler * nenner;
}
Sandini Bib
/* 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ähler einlesen
strm >> z;
// Lesefehler ?
if (! strm) {
return;
}
// Nenner == 0 ?
if (n == 0) {
// Fail-Bit setzen
strm.clear (strm.rdstate() | std::ios::failbit);
return;
}
*/
if (n < 0) {
zaehler = -z;
nenner = -n;
}
else {
zaehler = z;
nenner = n;
}
}
int main()
{
const Bsp::Bruch a(7,3); // Bruch-Konstante a deklarieren
Bsp::Bruch x; // Bruch-Variable x deklarieren
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ücksetzen
* - alles bis zum Zeilenende auslesen und
Sandini Bib
4.5.5 Zusammenfassung
Die Ein- und Ausgabe (I/O) ist nicht Teil der Sprache C++, sondern wird durch eine Standard-
Klassenbibliothek realisiert.
Es existieren verschiedene Stream-Klassen. Die wichtigsten sind istream für Objekte, von
denen gelesen werden kann, und ostream für Objekte, auf die etwas ausgegeben werden
kann.
Streams besitzen einen Zustand, der durch Ein-/Ausgabeoperationen geändert und abgefragt
werden kann.
Die globalen Objekte cin, cout und cerr sind als Standard-Ein/Ausgabekanäle vordefiniert.
Zur Ein- und Ausgabe werden typischerweise die Operatoren >> und << verwendet. Sie
können verkettet aufgerufen werden.
Manipulatoren erlauben es, Streams in einer Ein- oder Ausgabeoperation zu manipulieren.
Durch globales Überladen der I/O-Operatoren kann das Ein/Ausgabe-Konzept auch auf eige-
ne Datentypen übertragen werden.
Streams sollten immer als Referenzen übergeben werden.
Sandini Bib
int main()
{
const Bsp::Bruch a(7,3); // Bruch-Konstante a deklarieren
Bsp::Bruch x; // Bruch-Variable x deklarieren
// 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;
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öglich, da jeder Konstruktor, der mit genau einem Argument aufgerufen werden kann,
automatisch eine entsprechende automatische Typumwandlung (implizite Typumwandlung) defi-
niert. Dazu zählen auch Konstruktoren mit mehr als einem Parameter, die aber jeweils Default-
Argumente besitzen.
Der Konstruktor der Klasse Bruch, dem man mit einem int als Parameter aufrufen kann,
ermöglicht 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ür andere Datentypen zu ermöglichen, müssen
also nur entsprechende Konstruktoren definiert werden. So wäre es z.B. denkbar, für Brüche eine
automatische Typumwandlung für Gleitkommazahlen zu definieren:
namespace Bsp {
class Bruch {
...
// Konstruktor für automatische Typumwandlung von float nach Bsp::Bruch
Bruch (float);
};
}
int main()
{
Sandini Bib
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 überladene Funktion, bei der die Parameter-
typen genau passen, hat eine höhere Priorität als eine Version, für die eine Typumwandlung not-
wendig 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önnen, reicht:
return zaehler < i * nenner;
}
}
Hier muss wieder zwischen dem Implementierungsaufwand und dem Laufzeitnachteil abgewo-
gen werden.
int main()
{
Bsp::Bruch x;
...
if (x < 3.7) { // FEHLER: keine automatische Typumwandlung möglich
...
}
Sandini Bib
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öglich.
4.6.3 Friend-Funktionen
Bei der automatischen Typumwandlung für Elementfunktionen gibt es allerdings ein Problem:
Der Vergleich x < 1000 ist zwar möglich, 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) // FEHLER
...
}
Der Vergleich
x < 1000
Sandini Bib
wird als
x.operator< (1000)
interpretiert. Da es sich bei 1000 um einen Parameter handelt und es eine eindeutige Typum-
wandlung gibt, wird daraus automatisch:
x.operator< (Bsp::Bruch(1000))
Für 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ür Parameter
möglich ist. Da der erste Operand bei der Implementierung der Operation als Elementfunktion
kein Parameter ist, ist sie für diesen also nicht möglich. Eine explizite Typumwandlung durch
den Aufruf
Bsp::Bruch(1000) < x // OK
ist aber möglich.
Es gibt allerdings auch hier die Möglichkeit, den Ausdruck
1000 < x
auf eine andere Weise zu interpretieren (so wie dies schon auf Seite 212 zur Definition des
Ausgabeoperators für eigene Datentypen ausgenutzt wurde). Man kann den Ausdruck auch als
globale Verknüpfung zweier Operanden betrachten, die beide als Parameter ü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 Hilfselement-
funktion 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
Es gibt aber auch noch eine andere Möglichkeit: Man kann die Operation mit Hilfe des Schlüssel-
worts friend als Freund“ der Klasse deklarieren. Alle Freunde“ einer Klasse besitzen auch
” ”
Zugriff auf die privaten Komponenten ( vor Freunden gibt es keine Geheimnisse“).
”
Über den Umweg einer Deklaration als globale Friend-Funktion ist eine automatische Typ-
umwandlung also sowohl für den ersten als auch für den zweiten Parameter möglich:
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 !
...
}
// Standard-Headerdateien einbinden
#include <iostream>
class Bruch {
private:
int zaehler;
int nenner;
Sandini Bib
public:
/* Default-Konstruktor, Konstruktor aus Zähler und
* Konstruktor aus Zähler und Nenner
*/
Bruch (int = 0, int = 1);
/* Multiplikation
* - neu: globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich 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öglich ist
*/
friend bool operator < (const Bruch&, const Bruch&);
/* Operator *
* - neu: globale Friend-Funktion
* - inline definiert
*/
inline Bruch operator * (const Bruch& a, const Bruch& b)
{
/* Zähler und Nenner einfach multiplizieren
* - das Kürzen sparen wir uns
*/
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
}
/* Standard-Ausgabeoperator
Sandini Bib
/* Standard-Eingabeoperator
* - global ü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ückliefern
}
#endif // BRUCH_HPP
Hier wird nicht nur der Operator <, sondern auch der Operator * als globale Friend-Funktion de-
klariert. Dem liegt die entsprechende Überlegung zugrunde, dass, wenn der Ausdruck x * 1000
durch eine automatische Typumwandlung für den zweiten Operanden möglich ist, auch 1000 * x
möglich sein sollte.
Dies bedeutet nun aber nicht, dass alle Operatoren als globale Friend-Funktionen definiert
werden sollten. Kandidaten für Friend-Funktionen sind vielmehr im Allgemeinen nur zweistelli-
ge Operatoren, bei denen der erste Operand nicht verändert wird:
Für 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ür zweistellige Operatoren, deren Aufruf den ersten Operanden verändert (dies sind im All-
gemeinen die Zuweisungsoperatoren), soll das originale Objekt und nicht ein durch eine au-
tomatische Typumwandlung temporär erzeugtes Objekt manipuliert werden.
Auch hier werden jetzt beide Operanden als Parameter übergeben, deren Komponenten jeweils
nur über den Namen des Parameters angesprochen werden können.
Man beachte, dass man an der Implementierung einer Funktion nicht erkennen kann, ob und
– wenn ja – für welche Klassen sie eine Friend-Funktion ist. Entscheidend ist einzig und allein
die entsprechende Deklaration in der Klasse.
Sandini Bib
int main()
{
Bsp::Bruch x, y;
...
y = kehrwert(x); // Aufruf als globale Funktion
Spätestens hier sollte aber darauf hingewiesen werden, dass eine automatische Typumwandlung
aus Sicht der objektorientierten Programmierung als fragwürdig betrachtet werden kann. Objekte
erhalten dadurch Fähigkeiten, die außerhalb der eigentlichen Klassenstruktur liegen. Stellt man
nicht sicher, dass diese Funktionen zumindest logisch zur Klasse gehören und mit ihr zusammen
übersetzt werden, kann dies zu einem nicht mehr nachvollziehbarem Verhalten führen. Hinzu
kommt, dass Friend-Funktionen bei der Vererbung zu einem Problem werden können. 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öglichkeit, auf eine derartige Deklaration einer Friend-
Funktion zu verzichten. Wie bei den Ein-/Ausgabefunktionen kann man einfach globale Funk-
tionen definieren, die öffentliche Hilfsfunktionen aufrufen. So könnte man z.B. auch die Multi-
plikation 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 öffentliche
Elementfunktionen auf (Copy-Konstruktor und Operator *=). Ein eventuell vorhandener Lauf-
zeitnachteil kann durch eine Implementierung als Inline-Funktion weitgehend vermieden wer-
den.
4.6.4 Konvertierungsfunktionen
Konstruktoren, die mit einem Parameter aufgerufen werden, definieren, wie ein Objekt der da-
zugehörigen Klasse aus einem Objekt eines fremden Typs erzeugt werden kann. Es gibt auch
die Möglichkeit 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üsselwort operator, ge-
folgt von dem Typ, in den umgewandelt werden soll.
Bei der Klasse Bruch ist es z.B. möglich, eine Typumwandlung eines Bruchs in einen Gleit-
kommawert zu definieren, indem als operator double () eine Funktion definiert wird, die
den Wert des Bruchs in den Typ double umwandelt und diesen zurückliefert:7
namespace Bsp {
class Bruch {
...
// automatische Typumwandlung nach double
7
Man beachte, dass bei der Bildung des Quotienten ohne die expliziten Typumwandlungen von Zähler
und Nenner in den Datentyp double die ganzzahlige Division aufgerufen würde. Deshalb ist die explizite
Typumwandlung von zaehler und nenner in den Datentyp double notwendig.
Sandini Bib
int main()
{
Bsp::Bruch x(1,4);
...
std::cout << std::sqrt(x) << std::endl; // Wurzel aus x als double ausgeben
}
Das Anwendungsprogramm, das am Anfang dieses Abschnitts vorgestellt wurde, kann damit
nämlich nicht mehr kompiliert werden:8
// klassen/btest6.cpp
// Standard-Headerdateien einbinden
#include <iostream>
#include <cstdlib>
int main()
{
const Bsp::Bruch a(7,3); // Bruch-Konstante a deklarieren
Bsp::Bruch x; // Bruch-Variable x deklarieren
// 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;
8
Die Aussage, dass das Programm nicht mehr kompiliert werden kann, bezieht sich auf korrekte Compiler.
In der Praxis ist das Programm mitunter trotzdem übersetzbar. In dem Fall handelt es sich um einen Fehler
des Compilers.
Sandini Bib
Beide Interpretationen sind möglich und gleichwertig. Dies liegt daran, dass jede Interpretati-
on über selbst definierte Konvertierungen (sei es über Konstruktoren oder über Konvertierungs-
funktionen) unabhängig von weiteren Standard-Konvertierungen wie von int nach double die
gleiche Priorität besitzt. Wenn es zwei verschiedene gleichwertige Interpretationsmöglichkeiten
gibt, ist der Ausdruck immer mehrdeutig.
Die genauen Regeln zur automatischen Typumwandlung gehören zu den schwierigsten The-
men in C++ und werden in Abschnitt 10.3 erläutert.
// klassen/sqrt2.cpp
#include <iostream>
#include <cmath>
#include "bruch.hpp"
int main()
{
Bsp::Bruch x(1,4);
...
std::cout << std::sqrt(x.toDouble()) // Wurzel aus x als double ausgeben
<< std::endl;
}
class X {
friend Y;
...
};
erhalten alle Funktionen der Klasse Y Zugriff auf die Klasse X. Das überträgt 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ären wird mitunter dazu verwendet, um zwei Klassen, die
sehr eng zusammengehören, effektiver implementieren zu können (ein typisches Beispiel wären
die Klassen Matrix und Vektor).
Sandini Bib
4.6.8 Zusammenfassung
Ein Konstruktor, der mit nur einem Parameter aufgerufen werden kann, definiert damit die
Möglichkeit einer automatischen oder expliziten Typumwandlung von dem Typ des Para-
meters in ein Objekt der Klasse.
Mit explicit kann die Definition einer automatischen Typumwandlung durch einen Kon-
struktor verhindert werden.
Konvertierungsfunktionen bieten eine Möglichkeit, ein Objekt einer Klasse implizit oder ex-
plizit in einen anderen Typ umzuwandeln. Sie werden durch operator typ() deklariert und
besitzen eine Return-Anweisung, werden aber ohne Rückgabetyp deklariert.
Bei Funktionen zur automatischen Typumwandlung drohen Mehrdeutigkeiten. Konvertie-
rungsfunktionen sollten deshalb vermieden und Konstruktoren mit nur einem Parameter mit
Vorsicht eingesetzt werden.
Durch das Schlüsselwort friend können einzelne Operationen oder ganze Klassen Zugriff
auf alle privaten Komponenten einer Klasse erhalten.
Um eine automatische Typumwandlung für den ersten Operanden einer als Elementfunk-
tion implementierten Operatorfunktion zu ermöglichen, 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
bung zurückgemeldet werden, da Rückgabewerte entweder gar nicht definiert sind oder anderen
Zwecken dienen.
Hier hilft das Konzept der Ausnahmebehandlung: An beliebiger Stelle im Code können Feh-
ler 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ührt zu
einem (definierbaren) geregelten Programmende und nicht zu einem Programmabbruch.
// Standard-Headerdateien einbinden
#include <iostream>
class Bruch {
private:
int zaehler;
int nenner;
public:
/* neu: Fehlerklasse
*/
class NennerIstNull {
};
*/
Bruch (int = 0, int = 1);
/* Multiplikation
* - globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich 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öglich ist
*/
friend bool operator < (const Bruch&, const Bruch&);
/ * Operator *
* - globale Friend-Funktion
* - inline definiert
*/
inline Bruch operator * (const Bruch& a, const Bruch& b)
{
/* Zähler und Nenner einfach multiplizieren
* - das Kürzen sparen wir uns
*/
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
}
/* Standard-Ausgabeoperator
* - global überladen und inline definiert
*/
Sandini Bib
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
b.printOn(strm); // Elementfunktion zur Ausgabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
/* Standard-Eingabeoperator
* - global ü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ückliefern
}
#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ürzeste Deklaration, die für Klassen überhaupt möglich ist: Es wer-
den ü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"
/* Operator *=
*/
const Bruch& Bruch::operator *= (const Bruch& b)
{
// ”x *= y” ==> ”x = x * y”
*this = *this * b;
/* Operator <
* - globale Friend-Funktion
*/
bool operator < (const Bruch& a, const Bruch& b)
{
Sandini Bib
/* 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ähler einlesen
strm >> z;
// Lesefehler?
if (! strm) {
return;
}
// Nenner == 0?
if (n == 0) {
// neu: Ausnahme mit Fehlerobjekt für 0 als Nenner auslösen
throw NennerIstNull();
}
Sandini Bib
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ähler und Nenner wie übergeben initialisieren
* - 0 als Nenner ist allerdings nicht erlaubt
*/
if (n == 0) {
// neu: Ausnahme mit Fehlerobjekt für 0 als Nenner auslösen
throw NennerIstNull();
}
...
}
Die Anweisung throw entspricht im Programmablauf einer Folge von Return-Anweisungen, die
in allen Blöcken, in denen sich das Programm zur Zeit befindet, unmittelbar aufgerufen werden.
Sämtliche Gültigkeitsbereiche, 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
Es handelt sich allerdings um keinen Sprung zum nächsten übergeordneten catch, das den Feh-
ler behandelt, sondern um einen geordneten Rückzug“. In allen Blöcken werden für die darin
”
angelegten lokalen Objekte die Destruktoren aufgerufen. Objekte, die explizit mit new ange-
legt wurden, bleiben aber erhalten und müssen im Catch-Bereich gegebenenfalls explizit zerstört
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ührt, wird das Pro-
gramm also nicht einfach verlassen, sondern geordnet beendet. Im Gegensatz zu exit() werden
alle Destruktoren aufgerufen (exit() ruft nur die Destruktoren der statischen Objekte auf). An-
gesichts der Tatsache, dass ein lokales Objekt eine geöffnete Datei oder eine laufende Datenban-
kanfrage repräsentieren 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 <iostream>
#include <cstdlib>
int main()
{
Bsp::Bruch x; // Bruch-Variable
*/
std::cerr << "Eingabefehler: Nenner darf nicht Null sein"
<< std::endl;
return EXIT_FAILURE;
}
4.7.3 Fehlerklassen
Fehlerklassen sind Klassen wie alle anderen auch. Das bedeutet, sie können Komponenten und
Elementfunktionen besitzen. Prinzipiell kann jeder Datentyp (Klassen und fundamentale Daten-
typen) als Datentyp für 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ösen 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. Da-
mit können Ausnahmen bzw. Fehler parametrisiert werden. Selbstverständlich werden dann auch
Konstruktoren benötigt, um diese Komponenten zu initialisieren. Werden dynamische Kompo-
nenten verwendet, kann auch ein Destruktor definiert werden. Dieser wird nach der Fehlerbe-
handlung bei der Zerstörung des Fehlerobjekts aufgerufen.
Die Verwendung eines unerlaubten Index in einem Feld (Array) ist ein klassisches Beispiel
für die Verwendung von Parametern in Fehlerobjekten. Wenn auf einen String mit einem un-
erlaubten Index zugegriffen wird, ist für die Fehlerbehandlung im Allgemeinen von Interesse,
welcher Wert fälschlicherweise als Index verwendet wurde. Im Abschnitt 6.2.7 wird ein entspre-
chendes Beispiel vorgestellt und erläutert.
Sandini Bib
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;
}
Unerwartete Ausnahmen
Die Ausnahmenspezifikation beschreibt nur, welche Ausnahmen nach außen gereicht werden
können. Intern können andere Ausnahmen auftreten und behandelt werden. Tritt innerhalb der
Funktion eine Ausnahme auf, die weder behandelt wird noch in der Liste nach außen gereich-
ter Ausnahmen enthalten ist, wird die Funktion std::unexpected() aufgerufen. Diese Funk-
tion 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 defi-
niert werden, die weder Parameter noch Rückgabewerte besitzen darf. Die bisherige Unexpected-
Funktion wird jeweils zurückgeliefert.
Klasse std::bad_exception
Falls die Klasse std::bad_exception Teil einer Ausnahmenspezifikation ist, löst die Funk-
tion 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öst
}
Wenn eine Ausnahmenspezifikation also die Klasse std::bad_exception umfasst, wird jede
Ausnahme, die nicht zu einem der sonst aufgelisteten Typen gehört 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
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
Unter der allgemeinen Fehlerart Bruchfehler gibt es die Spezialfälle NennerIstNull und
Lesefehler. Entsprechend werden drei Klassen deklariert, wobei die Spezialfälle von dem all-
gemeinen Fall abgeleitet werden.
Die Deklaration der Klasse Bruch erhält dazu folgenden Aufbau:
// klassen/bruch10.hpp
class Bruch {
private:
int zaehler;
int nenner;
public:
/* Fehlerklassen:
* - neu: allgemeine Basisklasse mit zwei abgeleiteten Klassen
*/
Sandini Bib
class Bruchfehler {
};
class NennerIstNull: public Bruchfehler {
};
class Lesefehler : public Bruchfehler {
};
zaehler = z;
nenner = n;
}
}
/* scanFrom
* - Bruch von Stream strm einlesen
*/
void Bruch::scanFrom (istream& strm)
{
int z, n;
// Zähler einlesen
strm >> z;
// Lesefehler?
if (! strm) {
return;
}
// Nenner == 0?
if (n == 0) {
// Ausnahme mit Fehlerobjekt für 0 als Nenner auslösen
throw NennerIstNull();
}
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 er-
neuten Einlesen behandelt werden:
// klassen/btest10b.cpp
Bsp::Bruch liesBruch ()
{
Bsp::Bruch x; // Bruch-Variable
bool fehler; // Fehler aufgetreten?
do {
fehler = false; // zunächst mal kein Fehler
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 I O 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 L e s e f e h l e r S c h r e i b f e h l e r
Zum einen ergibt sich daraus der Vorteil, dass verschiedene Fehler mit nur einer Catch-Anweisung
abgefangen werden können. Zum anderen müssen in einer Ausnahmenspezifikation einer Funk-
tion, die mehrere Ausnahmen zurückliefern 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ür mehrere Klassen mit der Ausnahmebehandlung eine gemeinsame Fehlerbehandlung im-
plementiert werden, empfiehlt es sich ebenfalls, dafür eine gemeinsame Basisklasse für alle Aus-
nahmen zu definieren.
Sandini Bib
String ist der String, der dann bei Auswertung der Ausnahme von what() geliefert wird. Bei-
spiel:
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ähigkeit unterstützen, sind die Klassen logic_error
und runtime_error sowie die davon abgeleiteten Klassen. Alle anderen Standard-Ausnahme-
klassen sind dafür nicht vorgesehen.
4.7.10 Zusammenfassung
Klassen sollten im Fehlerfall Ausnahmen auslösen. Als Datentyp für Ausnahmen sollten (in-
terne) Hilfsklassen dienen.
Destruktoren sollten keine Ausnahmen auslösen.
Mit Hilfe von Ausnahmenspezifikationen kann für Funktionen deklariert werden, welche Aus-
nahmen sie auslösen können.
Fehlerklassen für die Ausnahmebehandlung können hierarchisch angeordnet werden, um all-
gemeine Fehlerarten durch spezielle Fehlerarten zu verfeinern oder verschiedene Fehlerarten
zusammenfassen zu können.
Klassen können auch Standard-Ausnahmen auslösen.
Sandini Bib
Sandini Bib
Kapitel 5
Vererbung und Polymorphie
Dieses Kapitel stellt nach der Programmierung mit Klassen und der Datenkapselung zwei wei-
tere wesentliche Merkmale der objektorientierten Programmierung vor: die Vererbung und die
Polymorphie.
Die Vererbung ermöglicht es, Eigenschaften einer Klasse von einer anderen Klasse abzulei-
ten. Das bedeutet, dass für neue Klassen nur noch Neuerungen implementiert werden müssen.
Eigenschaften, die schon einmal in einer anderen Klasse implementiert wurden, können über-
nommen werden und müssen nicht noch einmal implementiert werden. Dies führt neben einer
Einsparung an Code zu einem Konsistenzvorteil, da gemeinsame Eigenschaften nicht an mehre-
ren Stellen stehen und somit nur einmal geprüft oder geändert werden müssen.
Die Polymorphie ermöglicht es, mit Oberbegriffen zu arbeiten (ein Abstraktionsmittel, das
im täglichen Leben ständig verwendet wird). Verschiedenartige Objekte können zeitweise unter
einem Oberbegriff zusammengefasst werden, ohne dass ihre unterschiedlichen Eigenschaften
verloren gehen.
Vererbung ist eine Voraussetzung für Polymorphie, da für den jeweiligen Oberbegriff eine
Klasse definiert werden muss, von der die Klassen, die sich unter diesem Oberbegriff zusam-
menfassen lassen, jeweils abgeleitet werden. Wird ein Objekt unter seinem Oberbegriff verwaltet,
nimmt es zeitweise den Typ der dafür definierten Klasse an. Die Eigenschaft, dass es in Wirk-
lichkeit aber ein spezielles Objekt ist, geht dabei nicht verloren. Bei einem Funktionsaufruf wird
zur Laufzeit automatisch festgestellt, welche Klasse ein Objekt tatsächlich hat und die jeweils
dafür definierte Funktion aufgerufen.
261
Sandini Bib
Die Klasse, von der geerbt wird, nennt man Basisklasse (andere Bezeichnungen: Oberklasse,
Elternklasse). Die Klasse, die erbt, nennt man abgeleitete Klasse (andere Bezeichnungen: Un-
terklasse, Kind-Klasse).
Da eine abgeleitete Klasse selbst eine Basisklasse sein kann, kann eine ganze Klassenhierar-
chie entstehen, wie das Beispiel in Abbildung 5.1 zeigt.
F a h r z e u g
A u t o B o o t L a s t e r
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 S e g e l s c h i f f
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önnen
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ür Vererbung ein gleichseitiges Drei-
eck mit der Spitze zur Basisklasse verwendet.
// Standard-Headerdateien einbinden
#include <iostream>
class Bruch {
protected:
int zaehler;
int nenner;
Sandini Bib
public:
/* Fehlerklasse
*/
class NennerIstNull {
};
/* Multiplikation
* - globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich 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öglich ist
*/
friend bool operator < (const Bruch&, const Bruch&);
/ * Operator *
* - globale Friend-Funktion
* - inline definiert
*/
inline Bruch operator * (const Bruch& a, const Bruch& b)
{
/* Zähler und Nenner einfach multiplizieren
Sandini Bib
/* Standard-Ausgabeoperator
* - global überladen und inline definiert
*/
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
b.printOn(strm); // Elementfunktion zur Ausgabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
/* Standard-Eingabeoperator
* - global ü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ückliefern
}
#endif // BRUCH_HPP
Durch protected geschützte Komponenten sind wie bei private vor Zugriffen durch den An-
wender der Klasse geschützt. Abgeleitete Klassen haben jedoch darauf Zugriff. Bei privaten
Komponenten ist selbst das nicht möglich.
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 un-
terstützen. Da man, um kürzen zu können, Zugriff auf diese Komponenten braucht, wäre ein
Ableiten in diesem Fall sonst nicht möglich. Man hätte nur neue Eigenschaften, für die kein
Zugriff auf die geerbten Komponenten notwendig ist, hinzufügen können.
In der Praxis sollte es relativ selten notwendig sein, Komponenten auch vor dem Zugriff durch
die Vererbung zu schützen. In C++ gibt es in der Praxis ohnehin keinen absoluten Schutz vor
unvorhergesehenem Zugriff, da öffentliche Zeiger auf private Komponenten zeigen oder auch
Headerdateien manipuliert werden können (C++ spezifiziert dazu nur, dass das Verhalten der
Sprache nicht definiert ist, wenn für eine Klasse verschiedene Headerdateien verwendet werden).
Insofern schützt C++ nicht vor unerlaubtem Zugriff durch böswillige Manipulation (dafür gibt
es Sprachen wie Ada).
B r u c h
K B r u c h
Neue Komponenten
Neben den Komponenten zaehler und nenner, die durch die Vererbung von der Basisklasse
Bruch übernommen werden, bietet es sich an, die neue Boolesche Komponente kuerzbar ein-
zuführen. Sie hält für ein Objekt jeweils fest, ob der Bruch noch kürzbar oder bereits gekürzt
ist. Man könnte dies auch jedes Mal, wenn diese Eigenschaft von Interesse ist, neu ausrechnen.
Dadurch dass die Eigenschaft nur einmal bei einer Wertänderung ausgerechnet wird, kann aber
Zeit gespart werden.
Insgesamt ergibt sich für Objekte der Klasse KBruch der in Abbildung 5.3 dargestellte Auf-
bau.
KBruch:
Datenelemente: Elementfunktionen:
geerbt zaehler operator * ()
von Bruch: operator < ()
nenner
printOn()
asDouble()
neu: kuerzbar KBruch()
istKuerzbar()
ggt()
operator *= ()
scanFrom()
// vererb/kbruch1.hpp
#ifndef KBRUCH_HPP
#define KBRUCH_HPP
/* Klasse KBruch
* - abgeleitet von Bruch
* - der Zugriff auf geerbte Komponenten wird nicht
* eingeschränkt (public bleibt public)
*/
class KBruch : public Bruch {
protected:
bool kuerzbar; // true: Bruch ist kürzbar
public:
/* Default-Konstruktor, Konstruktor aus Zähler
* und Konstruktor aus Zähler und Nenner
* - Parameter werden an Bruch-Konstruktor durchgereicht
*/
KBruch (int z = 0, int n = 1) : Bruch(z,n) {
kuerzbar = (ggt() > 1);
}
// Bruch kürzen
void kuerzen();
// Kürzbarkeit testen
Sandini Bib
#endif // KBRUCH_HPP
Zunächst muss die Headerdatei der Basisklasse eingebunden werden, da die Deklaration der
abgeleiteten Klasse darauf aufbaut:
#include "bruch.hpp"
Entscheidend für 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üsselwort 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üsselwort gibt an, ob und inwiefern der Zugriff auf geerbte Kompo-
nenten weiter eingeschränkt wird:
Bei public gibt es keine weiteren Einschränkungen: Öffentliche Komponenten der Basis-
klasse sind auch in der abgeleiteten Klasse öffentlich, protected bleibt protected, und
private bleibt private.
Bei protected gibt es die Einschränkung, dass alle öffentlichen Komponenten der Basis-
klasse zu Komponenten werden, auf die der Anwender keinen Zugriff mehr hat;, davon ab-
geleitete 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ür den Zugriff ist private, sollte aber der besseren Lesbarkeit halber trotz-
dem immer explizit angegeben werden (manche Compiler geben sogar eine Warnung aus, wenn
bei der Basisklasse kein Zugriffsschlüsselwort angegeben ist).
1
Wie wir in Abschnitt 5.4.1 noch sehen werden, können auch mehrere Basisklassen angegeben werden.
Sandini Bib
Die Klasse KBruch schränkt den Zugriff auf geerbte Komponenten der Klasse Bruch nicht wei-
ter ein. Das bedeutet, dass die geerbten öffentlichen Elementfunktionen wie printOn(), die
Multiplikation und der Vergleich mit dem Operator < auch bei der Anwendung eines KBruchs
aufgerufen werden können:
namespace Bsp {
class Bruch {
...
public:
void printOn (std::ostream&) const;
...
};
void f()
{
Bsp::KBruch kb;
...
kb.printOn(std::cout); // OK: printOn() ist auch für KBruch public
}
dass Parameter für den Konstruktor der abgeleiteten Klasse eine ganz andere Bedeutung haben
können, als sie bei der Basisklasse besitzen. Es kann sich auch um eine andere Anzahl oder
andere Typen handeln.
Auch hier können wieder Initialisierungslisten verwendet werden. Sie bieten die Möglich-
keit, Argumente an den indirekt aufgerufenen Konstruktor der Basisklasse zu ü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önnen also ein Zähler und ein Nenner als Integer übergeben werden. Erfolgt dies nicht, wer-
den 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-Kon-
struktor ausgeführt. In diesem Fall wird in kuerzbar festgehalten, ob der KBruch kürzbar ist.
x: zaehler: ?
nenner: ?
kuerzbar: ?
Anhand der Initialisierungsliste des Konstruktors wird festgestellt, dass der int/int-Kon-
struktor der Klasse Bruch aufgerufen werden muss:
Sandini Bib
Also wird der int/int-Konstruktor der Klasse Bruch mit den Parametern 91 und 39 aufge-
rufen, der den von Bruch geerbten Teil des Objekts initialisiert:
x: zaehler: 91
nenner: 39
kuerzbar: ?
Schließlich werden die Anweisungen des KBruch-Konstruktors ausgeführt, die die Kompo-
nente kuerzbar durch Aufruf von ggt() initialisieren:
x: zaehler: 91
nenner: 39
kuerzbar: true
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öglichkeit, die Initialisierung zu korrigieren, indem z.B. Sonderfälle anders behandelt
werden. Dies darf aber nicht dazu führen, 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ächst der Konstruktor ihrer Basisklasse aufgerufen. Die Initialisierungsliste der Basis-
klasse gibt dabei an, welche Parameter an den Konstruktor der Basisklasse durchgereicht werden.
Eine Initialisierungsliste kann Argumente jeweils nur an ihre direkte Basisklasse übergeben.
Wenn Konstruktoren sowohl für Basisklassen als auch für Komponenten aufgerufen werden
müssen, werden grundsätzlich zuerst die Konstruktoren der Basisklassen und dann die Konstruk-
toren für die Komponenten aufgerufen.
Sandini Bib
/* ggt()
* - größter gemeinsamer Teiler von Zähler und Nenner
*/
unsigned KBruch::ggt () const
{
if (zaehler == 0) {
return nenner;
}
/* kuerzen()
*/
void KBruch::kuerzen ()
{
// falls kürzbar, Zähler und Nenner durch GGT teilen
if (kuerzbar) {
int teiler = ggt();
zaehler /= teiler;
nenner /= teiler;
/* Operator *=
* - neu implementiert
*/
const KBruch& KBruch::operator*= (const KBruch& b)
{
// wie bei der Basisklasse:
zaehler *= b.zaehler;
nenner *= b.nenner;
// weiterhin gekürzt?
if (!kuerzbar) {
kuerzbar = (ggt() > 1);
}
return *this;
}
/* scanFrom()
*/
void KBruch::scanFrom (std::istream& strm)
{
Bruch::scanFrom (strm); // scanFrom() der Basisklasse aufrufen
Zunächst wird die neu eingeführte Elementfunktion zur Berechnung des größten gemeinsa-
men Teilers implementiert. Falls der Zähler 0 ist, ist der GGT der Nenner ( 70 hat also den
GGT 7). Ansonsten wird in einer Schleife, absteigend vom vorzeichenfreien Minimum von
Zähler und Nenner, die erste Zahl gesucht, die sowohl den Zähler als auch den Nenner oh-
ne Rest teilt (der Modulo-Operator % liefert den Rest nach Division). Spätestens bei 1 ist das
der Fall. Für den Startwert der Schleife wird die in <algorithm> definierte Standardfunktion
std::min(), die das Minimums zweier Werte liefert, und die in <cstdlib> definierte Stan-
dardfunktion std::abs(), die den Absolutwert (Wert ohne Vorzeichen) eines Integers liefert,
verwendet.2
Die Funktion kuerzen() teilt, sofern der Bruch nicht schon gekürzt ist, Zähler und Nenner
durch den berechneten GGT.
2
Es gibt zweifellos bessere Algorithmen zur Berechnung eines GGTs.
Sandini Bib
int main()
{
// kürzbaren Bruch deklarieren
Bsp::KBruch x(91,39);
// x ausgeben
std::cout << x;
std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)")
<< std::endl;
// x kürzen
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
Ansonsten müsste der Parameter des Operators *= als Bruch deklariert werden:
class KBruch : public Bruch {
...
const KBruch& KBruch::operator*= (const Bruch&);
...
};
5.1.8 Zusammenfassung
Klassen können in einer Vererbungsbeziehung stehen.
Eine abgeleitete Klasse übernimmt (erbt) alle Eigenschaften der Basisklasse und ergänzt die-
se typischerweise um neue Eigenschaften.
Objekte abgeleiteter Klassen besitzen die Komponenten der Basisklasse und die neu hinzu-
gekommenen Komponenten.
Kennzeichen der Vererbung ist die is-a-Beziehung: Ein Objekt einer abgeleiteten Klasse ist
ein Objekt der Basisklasse (mit zusätzlichen Eigenschaften).
Ein Objekt einer abgeleiteten Klasse darf jederzeit als Objekt der Basisklasse verwendet wer-
den. 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önnen den Konstruktoren einer Basisklasse Parameter übergeben
werden. Erfolgt dies nicht, wird jeweils der Default-Konstruktor der Basisklasse aufgerufen.
Sandini Bib
int main()
{
// KBruch deklarieren
Bsp::KBruch x(7,3);
// x ausgeben
std::cout << x;
std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)")
<< std::endl;
// x ausgeben
std::cout << x;
std::cout << (x.istKuerzbar() ? " (kuerzbar)" : " (unkuerzbar)")
<< std::endl;
}
Zwei Anweisungen des Programms sind problematisch. In beiden Fällen steckt der Mechanismus
dahinter, dass Objekte einer abgeleiteten Klasse auch als Objekte der Basisklasse verwendet
werden können.
Bsp::KBruch x(7,3);
Bsp::Bruch b(3,7);
Bsp::Bruch* xp = &x;
Da b aber ein zweiter Name für 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ür Brüche nicht bekannt ist. Der KBruch behält in der
Komponente kuerzbar deshalb unabhängig 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 über den Einlese-Operator >> gar nicht erst aufgerufen. Nur ein direkter Aufruf von
scanFrom() würde funktionieren.
3
Der Begriff dynamisches Binden wird auch für den Mechanismus von Shared Libraries verwendet, hat
damit aber nichts zu tun.
Sandini Bib
tatsächlich aufgerufen wird. In Abhängigkeit von der Klasse, die das Objekt besitzt, wird die
dazugehörige Funktion aufgerufen.
Aus diesem Grund müssen 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 über-
schrieben werden können, als virtuelle Funktionen deklariert. Nur so ist eine Klasse generell zur
Vererbung geeignet.
Werden die Funktionen der Basisklasse nicht als virtuelle Funktionen deklariert, können die
vorher aufgezeigten Probleme auftreten. Aus diesem Grund sollte man sich bei der Vererbung an
folgende Regeln halten:
Nichtvirtuelle Funktionen dürfen nicht überschrieben werden.
Wenn dies zur Implementierung einer abgeleiteten Klasse notwendig ist, sollte nicht abgelei-
tet 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 <iostream>
class Bruch {
protected:
int zaehler;
int nenner;
public:
/* Fehlerklasse
*/
class NennerIstNull {
};
/* Multiplikation
* - globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich 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öglich ist
*/
friend bool operator < (const Bruch&, const Bruch&);
/* Operator *
* - globale Friend-Funktion
* - inline definiert
*/
inline Bruch operator * (const Bruch& a, const Bruch& b)
{
/* Zähler und Nenner einfach multiplizieren
* - das Kürzen sparen wir uns
*/
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
}
Sandini Bib
/* Standard-Ausgabeoperator
* - global überladen und inline definiert
*/
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
b.printOn(strm); // Elementfunktion zur Ausgabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
/* Standard-Eingabeoperator
* - global ü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ückliefern
}
#endif // BRUCH_HPP
Alle Funktionen, die aus welchen Gründen auch immer in abgeleiteten Klassen neu implemen-
tiert werden könnten, werden als virtuell deklariert. Ausgenommen davon sind nur Konstruktoren
und Friend-Funktionen, die grundsätzlich nicht virtuell sein können.
inline
Sandini Bib
int main()
{
Bsp::KBruch x (7,3);
...
std::cin >> x; // OK: ruft indirekt KBruch::scanFrom() auf
...
}
Wenn die für Brüche überladene Operatorfunktion >> für ihren Parameter b die Elementfunk-
tion scanFrom() aufruft, wird, da b eine Referenz ist und scanFrom() in der Klasse virtuell
deklariert wird, erst zur Laufzeit entschieden, welche Klasse b tatsächlich 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.
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ür die Implementierung der Basisklasse, sondern nur eine Ergänzung.
Für Objekte der abgeleiteten Klasse wird zwar die neue Implementierung aufgerufen, für
Zeiger und Referenzen der Klasse Bruch existiert aber nach wie vor nur die Implementierung
der Basisklasse. Sie wurde in der abgeleiteten Klasse nicht überschrieben. Es wird zwar erst zur
Laufzeit entschieden, welche Funktion aufgerufen werden muss; da es aber keine neue Imple-
mentierung der in der Basisklasse definierten Operation gibt, wird auch für KBrüche die Imple-
mentierung der Basisklasse Bruch verwendet.
Sandini Bib
Beim Überschreiben von geerbten Funktionen ist deshalb auch folgende Regel zu beachten:
Eine virtuelle Funktion der Basisklasse wird in einer abgeleiteten Klasse nur dann wirklich
ü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ückgabetyp deklariert werden:
namespace Bsp {
class KBruch : public Bruch {
...
public:
virtual const Bruch& operator*= (const Bruch&);
...
};
}
Wie grundsätzlich beim Überladen von Funktionen ist auch hier eine Unterscheidung nur durch
den Rückgabetyp unzulässig.
Zu dieser Regel gibt es allerdings ein Ausnahme: Liefert eine virtuelle Funktion einer Basis-
klasse ein Objekt dieser Basisklasse zurück, darf bei einer Neuimplementierung in einer abgelei-
teten Klasse statt auch ein Objekt dieser abgeleiteten Klasse zurückgeliefert werden. Ein Zeiger
muss aber ein Zeiger und eine Referenz muss eine Referenz bleiben.
Damit ist auch die folgende Neuimplementierung möglich:
namespace Bsp {
class KBruch : public Bruch {
...
public:
virtual const KBruch& operator*= (const Bruch&);
...
};
}
Der Parameter b ist nämlich ein Objekt einer anderen Klasse als der Klasse KBruch, für die hier
eine Elementfunktion implementiert wird. Dabei spielt es keine Rolle, ob die Klasse eine Basis-
klasse 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 öffentliche
Komponenten Zugriff.
Es gilt also noch eine Regel:
Für als Parameter übergebene Objekte von Basisklassen besteht in abgeleiteten Klassen nur
Zugriff auf öffentliche Komponenten.
Aus diesem Grund kann der Zugriff auf die Komponenten des Parameters nur über dessen öffent-
liche Schnittstelle durchgeführt werden. Da keine Elementfunktionen zum Abfragen der Werte
von Zähler und Nenner existieren (kommerzielle Klassen hätten diese sicherlich), muss in die-
sem Fall die Implementierung der Basisklasse aufgerufen werden, die den KBruch zunächst mit
dem übergebenen Parameter ausmultipliziert. Anschließend können eventuell vorhandene Inkon-
sistenzen beseitigt werden:
const Bruch& KBruch::operator*= (const Bruch& b)
{
/* neu: Implementierung der Basisklasse aufrufen
* - auf nichtöffentliche Komponenten von b besteht kein Zugriff
*/
Bruch::operator*= (b);
// weiterhin gekürzt?
if (!kuerzbar) {
kuerzbar = (ggt() > 1);
}
return *this;
}
Bsp::Bruch* bp;
// vererb/bruch93.hpp
#ifndef BRUCH_HPP
#define BRUCH_HPP
// Standard-Headerdateien einbinden
#include <iostream>
class Bruch {
protected:
int zaehler;
int nenner;
public:
/* Fehlerklasse
*/
class NennerIstNull {
};
/* Multiplikation
* - globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich 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öglich ist
Sandini Bib
*/
friend bool operator < (const Bruch&, const Bruch&);
/* Operator *
* - globale Friend-Funktion
* - inline definiert
*/
inline Bruch operator * (const Bruch& a, const Bruch& b)
{
/* Zähler und Nenner einfach multiplizieren
* - das Kürzen sparen wir uns
*/
return Bruch (a.zaehler * b.zaehler, a.nenner * b.nenner);
}
/* Standard-Ausgabeoperator
* - global überladen und inline definiert
*/
inline
std::ostream& operator << (std::ostream& strm, const Bruch& b)
{
b.printOn(strm); // Elementfunktion zur Ausgabe aufrufen
return strm; // Stream zur Verkettung zurückliefern
}
Sandini Bib
/* Standard-Eingabeoperator
* - global ü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ückliefern
}
#endif // BRUCH_HPP
Die dazugehörige Implementierung entspricht der Version auf Seite 244.
/* Klasse KBruch
* - abgeleitet von Bruch
* - der Zugriff auf geerbte Komponenten wird nicht
* eingeschränkt (public bleibt public)
* - zur Weiter-Vererbung geeignet
*/
Sandini Bib
public:
/* Default-Konstruktor, Konstruktor aus Zähler
* und Konstruktor aus Zähler und Nenner
* - Parameter werden an Bruch-Konstruktor durchgereicht
*/
KBruch (int z = 0, int n = 1) : Bruch(z,n) {
kuerzbar = (ggt() > 1);
}
// Bruch kürzen
virtual void kuerzen();
// Kürzbarkeit testen
virtual bool istKuerzbar() const {
return kuerzbar;
}
};
#endif // KBRUCH_HPP
// vererb/kbruch3.cpp
// Headerdatei für min() und abs()
#include <algorithm>
#include <cstdlib>
/* ggt()
* - größter gemeinsamer Teiler von Zähler und Nenner
*/
unsigned KBruch::ggt () const
{
if (zaehler == 0) {
return nenner;
}
/* kuerzen()
*/
void KBruch::kuerzen ()
{
// falls kürzbar, Zähler und Nenner durch GGT teilen
if (kuerzbar) {
int teiler = ggt();
zaehler /= teiler;
nenner /= teiler;
Sandini Bib
/* Operator *=
* - zum Überschreiben mit Typen der Basisklasse neu implementiert
*/
const KBruch& KBruch::operator*= (const Bruch& b)
{
/* Implementierung der Basisklasse aufrufen
* - auf nichtöffentliche Komponenten von b besteht kein Zugriff
*/
Bruch::operator*= (b);
// weiterhin gekürzt ?
if (!kuerzbar) {
kuerzbar = (ggt() > 1);
}
return *this;
}
/* scanFrom()
*/
void KBruch::scanFrom (std::istream& strm)
{
Bruch::scanFrom (strm); // scanFrom() der Basisklasse aufrufen
Unterschiedliche Default-Argumente
Die Default-Argumente von Funktionen werden beim Kompilieren eingesetzt. Sie sind damit
immer statisch.
Sandini Bib
Wenn nun virtuelle Funktionen in der Basisklasse einen anderen Default-Wert besitzen als in
der überschriebenen Implementierung der abgeleiteten Klasse, wird zwar die richtige Funktion
aufgerufen, aber der falsche Default-Wert übergeben.
Nehmen wir z.B. an, für Brüche sei eine Funktion init() definiert, die als Default-Argument
0 besitzt. Die abgeleitete Klasse KBruch überschreibt die Funktion, vergibt als Default-Argument
aber 1:
class Bruch {
...
virtual void init (int = 0);
};
Bsp::Bruch* xp = &x;
xp->init(); // ruft für x auf: KBruch::init(0)
Um dies zu vermeiden, gilt die Regel:
Überschriebene Funktionen sollten die gleichen Default-Argumente wie ihre Basisklassen
besitzen.
// vererb/kbruchtest4.cpp
// Headerdateien
#include <iostream>
#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ühren, dass Inkonsistenzen entstehen (der Bruch x ist
nach der Multiplikation kürzbar, trägt aber die Information, er wäre nicht kürzbar).
Ein solcher Aufruf ist möglich, da bei der Deklaration der Klasse angegeben wurde, dass
öffentliche Funktionen der Basisklasse weiterhin öffentlich bleiben. Dies gilt selbst dann, wenn
sie ü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ücklich wünscht, sollte sich auch über die
Konsequenzen im Klaren sein. Andererseits werden Klassen unter anderem dazu eingeführt, um
durch eine wohldefinierte Schnittstelle zu Objekten keine Inkonsistenzen erzeugen zu können.
Das Problem kann vermieden werden, indem durch Verwendung der Zugriffsschlüsselwörter
private oder protected nichtöffentlich abgeleitet wird:
class KBruch : protected Bruch {
...
};
Dies hat zur Folge, dass alle Elementfunktionen der Basisklasse nichtöffentlich werden. Sie
können vom Anwender der Klasse damit nicht mehr aufgerufen werden oder müssen erneut
implementiert werden.
Im Beispiel der Klasse KBruch würde das z.B. bedeuten, dass unter anderem die I/O-Ope-
rationen noch einmal öffentlich deklariert und damit auch noch einmal implementiert werden
müssten. In diesem Fall würde 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önnen.
Sandini Bib
Um dieses Dilemma zu lösen, gibt es die Möglichkeit, Komponenten mit unterschiedlichen Ein-
schränkungen zu erben. Eine Klasse muss dazu grundsätzlich protected oder private geerbt
werden, diese Einschränkung kann dann aber für einzelne Komponenten wieder aufgehoben wer-
den. 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
/* Klasse KBruch
* - abgeleitet von Bruch
* - neu: der Zugriff auf geerbte Komponenten wird eingeschränkt
* (public wird protected)
* - damit gilt die is-a-Beziehung nicht mehr
*/
class KBruch : protected Bruch {
protected:
bool kuerzbar; // true: Bruch ist kürzbar
public:
/* Default-Konstruktor, Konstruktor aus Zähler
* und Konstruktor aus Zähler und Nenner
* - Parameter werden an Bruch-Konstruktor durchgereicht
*/
KBruch (int z = 0, int n = 1) : Bruch(z,n) {
kuerzbar = (ggt() > 1);
}
// multiplikative Zuweisung
virtual const KBruch& operator*= (const Bruch&);
// Bruch kürzen
virtual void kuerzen();
// Kürzbarkeit testen
virtual bool istKuerzbar() const {
return kuerzbar;
}
};
#endif // KBRUCH_HPP
Mit dieser Implementierung kann die multiplikative Zuweisung der Basisklasse nicht mehr für
Objekte der abgeleiteten Klasse aufgerufen werden. I/O-Operationen und die Konvertierung in
einen Gleitkommawert sind durch die reine Zugriffsdeklaration aber weiterhin ohne Neuimple-
mentierung möglich:
namespace Bsp {
class KBruch : protected Bruch {
public:
// neu: reine Zugriffsdeklaration für Operationen, die public bleiben
Bruch::printOn;
Bruch::asDouble;
...
};
}
Aus dem gleichen Grund können auch weder die Friend-Funktionen noch die Standard-I/O-
Operatoren der Klasse Bruch verwendet werden. Sie müssen für die Klasse KBruch alle neu
definiert werden. Ohne Neuimplementierung des Ausgabeoperators für KBrüche, 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önnen auf einfache Weise
Komponenten und Elementfunktionen ü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 öffentliche Vererbung implementiert werden.
5.2.9 Zusammenfassung
Sofern nicht anders angegeben, werden Funktionsaufrufe statisch gebunden. Es wird die Ele-
mentfunktion aufgerufen, die für die Klasse definiert ist, mit der das Objekt deklariert wurde.
Mit dem Schlüsselwort virtual kann das statische Binden für Zeiger und Referenzen auf-
gehoben werden. Es wird Code generiert, durch den erst zur Laufzeit in Abhängigkeit von
der Klasse, die ein Objekt tatsächlich hat, die richtige Funktion aufgerufen wird.
Durch virtuelle Funktionen ist es möglich, Funktionen von Basisklassen zu überschreiben,
ohne dass durch die Verwendung von Zeigern oder Referenzen der Basisklasse Probleme
auftreten können.
Eine Klasse, die uneingeschränkt zur Vererbung geeignet ist, sollte folgende Voraussetzungen
erfüllen:
– Alle Funktionen, die von abgeleiteten Klassen überschrieben werden könnten, müssen als
virtuelle Funktionen deklariert werden.
– Es muss ein virtueller Destruktor definiert werden.
Bei der Implementierung einer abgeleiteten Klasse sollte Folgendes beachtet werden:
– Das Überschreiben von Funktionen, die in der Basisklasse nicht virtuell sind, kann sehr
problematisch sein.
– Funktionen der Basisklasse gelten nur dann als überschrieben, wenn die Typen bei der
Deklaration gleich sind.
– Überschriebene Funktionen sollten die gleichen Default-Argumente besitzen.
– Mit dem Bereichsoperator können überschriebene virtuelle Funktionen aufgerufen wer-
den.
– Auf als Parameter übergebene Objekte einer Basisklasse besteht nur ö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
5.3 Polymorphie
In diesem Abschnitt wird das Konzept der Polymorphie und seine Realisierung in C++ vorge-
stellt.
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ährt (die Elementfunktion fahren() aufgerufen wird), wird automatisch
in Abhängigkeit vom tatsächlichen Typ des Objekts die dazugehörige Operation aufgerufen.
Ist das Fahrzeug ein Laster, wird das für Laster definierte Fahren durchgeführt (fahren()
der Klasse Laster aufgerufen). Ist es ein Auto, wird automatisch das für Autos definierte
Fahren durchgeführt.
Soll ein grafisches Objekt ausgegeben werden, wird automatisch in Abhängigkeit von dessen
Art die entsprechende Operation aufgerufen. Handelt es sich um einen Kreis, wird die Opera-
tion zum Ausgeben eines Kreises aufgerufen, handelt es sich dagegen um ein Rechteck, wird
die dafür definierte Ausgabeoperation verwendet.
Bei der Implementierung der Anwendungssoftware für den Oberbegriff muss noch nicht einmal
bekannt sein, welche Typen unter dem Oberbegriff zusammengefasst werden:
Wenn Boote als neue Fahrzeugart eingeführt werden, kann die Funktion für Fahrzeuge auto-
matisch auch Boote fahren lassen.
Wenn Dreiecke oder Textelemente als neue Art geometrische Objekte eingeführt werden,
kann der grafische Editor automatisch auch diese verwalten.
Die Polymorphie ermöglicht es also, dass verschiedenartige Objekte in inhomogenen Mengen
zumindest zeitweise unter einem gemeinsamen Oberbegriff verarbeitet werden. Dabei wird in
Abhängigkeit vom tatsächlichen Typ des Objekts automatisch die jeweils richtige Funktion auf-
gerufen.
4
Korrekterweise muss es in objektorientierter Lesart heißen, dass Objekten in Smalltalk jeweils eine Nach-
”
richt“ geschickt wird, die von einer dafür definierten Methode“ behandelt wird.
”
Sandini Bib
Wenn ein Objekt einer Klasse angelegt wird, das unter dem Oberbegriff verwaltet werden kann,
wird geprüft, ob auch für alle unter dem Oberbegriff definierten Operationen eine Implementie-
rung existiert. Damit stellt bereits der Compiler sicher, dass jeder Funktionsaufruf zur Laufzeit
möglich ist. Im Gegensatz zu Smalltalk ist C++ also keine interpretierende Sprache. Laufzeit-
Fehlermeldungen über nicht vorhandene Funktionen entfallen.
Eine derartige Polymorphie kann man in C++ mit zwei verschiedenen Sprachmitteln im-
plementieren: mit Vererbung und mit Templates. In weiteren Verlauf dieses Abschnitts wird
zunächst die übliche Form von Polymorphie mit Vererbung vorgestellt. In Abschnitt 7.5.3 wird
dann auf die Implementierung von Polymorphie mit Templates eingegangen.
G e o : : G e o O b j G e o : : K o o r d
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 G e o : : L i n i e
r a d i u s : u n s i g n e d p 2 : K o o r d
d r a w ( ) m o v e ( K o o r d )
d r a w ( )
Hilfsklasse Koord
Als Hilfsklasse wird eine Klasse Koord verwendet. Sie betrachtet einfach nur zwei zusammen-
gehörige X- und Y-Koordinaten als gemeinsames Objekt. Es handelt sich um kein geometrisches
Objekt (das entsprechende geometrische Objekt wäre 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
// vererb/koord1.hpp
#ifndef KOORD_HPP
#define KOORD_HPP
namespace Geo {
/* Klasse Koord
* - Hilfsklasse für geometrische Objekte
* - nicht zur Vererbung geeignet
*/
class Koord {
private:
int x; // X-Koordinate
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) {
}
/* 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
*/
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ür 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ärend sein. Bemerkenswert ist vielleicht
die Tatsache, dass zum Anlegen nur kein oder zwei Parameter übergeben werden können. Wird
kein Parameter übergeben, wird ein Objekt mit den Werten 0 als X- und Y-Koordinate initialisiert.
Sandini Bib
namespace Geo {
public:
// GeoObj um relativen Offset verschieben
virtual void move (const Koord& offset) {
Sandini Bib
refpunkt += offset;
}
/* GeoObj ausgeben
* - rein virtuelle Funktion
*/
virtual void draw () const = 0;
// virtueller Destruktor
virtual ~GeoObj () {
}
};
} // namespace Geo
#endif // GEOOBJ_HPP
Zunächst werden die Komponente für den Referenzpunkt und der Konstruktor der Klasse nicht-
öffentlich definiert:
class GeoObj {
protected:
Koord refpunkt;
}
...
};
Die Implementierung verschiebt einfach den Referenzpunkt, indem der übergebene Offset aufad-
diert 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üssen diese Implementierung überschreiben.
5
Die Übersetzungen von pure virtual function variieren. Ich habe z.B. auch schon reine virtuelle Funktion
oder pure virtuelle Funktion gefunden.
Sandini Bib
Kreis:
Datenelemente: Elementfunktionen:
geerbt refpunkt move()
von GeoObj:
neu: radius Kreis()
draw()
Auch die Implementierung der Klasse Kreis befindet sich vollständig in der Headerdatei. Sie
hat folgenden Aufbau:
// vererb/kreis1.hpp
#ifndef KREIS_HPP
#define KREIS_HPP
namespace Geo {
/* Klasse Kreis
* - abgeleitet von GeoObj
* - ein Kreis besteht aus:
* - Mittelpunkt (Referenzpunkt, geerbt)
* - Radius (neu)
*/
class Kreis : public GeoObj {
protected:
unsigned radius; // Radius
Sandini Bib
public:
// Konstruktor für Mittelpunkt und Radius
Kreis (const Koord& m, unsigned r)
: GeoObj(m), radius(r) {
}
// 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 über die Initialisierungsliste an den Konstruktor der Basisklasse GeoObj
übergeben, wo er zur Initialisierung des Referenzpunktes verwendet wird. Mit dem als zweiten
Parameter ü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 übernommen, da nur der Mittelpunkt die
Position des Kreises definiert und ein Verschieben dieses Punktes ausreicht.
Linie:
Datenelemente: Elementfunktionen:
geerbt refpunkt
von GeoObj:
neu: p2 Linie()
move()
draw()
Auch die Implementierung der Klasse Linie befindet sich vollständig in der Headerdatei. Sie
hat folgenden Aufbau:
// vererb/linie1.hpp
#ifndef LINIE_HPP
#define LINIE_HPP
namespace Geo {
/* Klasse Linie
* - abgeleitet von GeoObj
* - ein Linie besteht aus:
* - einem Anfangspunkt (Referenzpunkt, geerbt)
* - einem Endpunkt (neu)
*/
class Linie : public GeoObj {
protected:
Koord p2; // zweiter Punkt, Endpunkt
public:
// Konstruktor für Anfangs- und Endpunkt
Linie (const Koord& a, const Koord& b)
: GeoObj(a), p2(b) {
}
Sandini Bib
// 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)
{
refpunkt += offset; // entspricht GeoObj::move(offset);
p2 += offset;
}
} // namespace Geo
#endif // LINIE_HPP
Der Konstruktor verlangt in diesem Fall zwei Koordinaten als Parameter zur Initialisierung. Die
erste Koordinate wird über die Initialisierungsliste an den Konstruktor der Basisklasse GeoObj
übergeben, wo sie zur Initialisierung des Referenzpunktes verwendet wird. Mit der zweiten Ko-
ordinate wird die Komponente für den neu hinzugekommenen zweiten Punkt initialisiert.
Auch diese Klasse implementiert die Ausgabefunktion draw() derart, dass ein entsprechen-
der 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ätte den zweiten Punkt ge-
nausogut als Offset auf den ersten Punkt implementieren können, wodurch die Funktion move()
Sandini Bib
von der Basisklasse übernommen werden könnte. Dann müssten 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 be-
trachtet und manipuliert. Beim Verschieben und Ausgeben werden aber immer die Funktionen
aufgerufen, die für die tatsächliche Klasse der Objekte definiert sind.
Das Programm hat folgenden Aufbau:
// vererb/geotest1.cpp
// Headerdateien für verwendete Klassen
#include "linie.hpp"
#include "kreis.hpp"
#include "geoobj.hpp"
// Vorwärtsdeklaration
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);
geoObjAusgeben(l1);
geoObjAusgeben(k);
geoObjAusgeben(l2);
}
G e o : : G e o O b j G e o : : K o o r d
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 G e o : : L i n i e G e o : : G e o G r u p p e
r a d i u s : u n s i g n e d p 2 : K o o r d e l e m s : v e c t o r < G e o O b j * >
d r a w ( ) m o v e ( K o o r d ) d r a w ( )
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
Auch die Klasse GeoGruppe implementiert die Funktion draw() und erbt die Funktion move().
Hinzu kommen ein Vektor (siehe Abschnitt 3.5.1) für die darin verwalteten Elemente und Funk-
tionen zum Einfügen und Löschen eines Elements. Natürlich gehören in der Praxis weitere Funk-
tionen dazu, hier geht es nur um das Prinzip.
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:
std::vector<GeoObj*> elems; // Menge von Zeigern auf GeoObjs
public:
// Konstruktor mit optionalem Referenzpunkt
GeoGruppe (const Koord& p = Koord(0,0)) : GeoObj(p) {
}
// Element einfügen
virtual void add (GeoObj&);
// Element entfernen
virtual bool remove (GeoObj&);
// virtueller Destruktor
virtual ~GeoGruppe () {
}
};
} // namespace Geo
#endif // GEOGRUPPE_HPP
Sandini Bib
namespace Geo {
/* add
* - Element einfügen
*/
void GeoGruppe::add (GeoObj& obj)
{
// Adresse vom geometrischen Objekt eintragen
Sandini Bib
elems.push_back(&obj);
}
/* remove
* - Element löschen
*/
bool GeoGruppe::remove (GeoObj& obj)
{
// erstes Element mit dieser Adresse finden
std::vector<GeoObj*>::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ücksichtigung des Referenzpunktes ausgeben
*/
void GeoGruppe::draw () const
{
for (unsigned i=0; i<elems.size(); ++i) {
elems[i]->move(refpunkt); // Offset für den Referenzpunkt addieren
elems[i]->draw(); // Element ausgeben
elems[i]->move(-refpunkt); // Offset wieder entfernen
}
}
} // namespace Geo
Die Funktion zum Einfügen trägt einfach nur die Adresse des eingefügten Elements in die interne
Menge ein:
void GeoGruppe::add (GeoObj& obj)
{
// Adresse vom geometrischen Objekt eintragen
elems.push_back(&obj);
}
Sandini Bib
Da für das übergebene geometrische Objekt nur Referenzen und Zeiger verwendet werden, bleibt
dessen Klassenzugehörigkeit 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ür 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ür diese unter
anderem die Funktion draw() aufgerufen wird. Durch die Verwendung der virtuellen Funktio-
nen 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 geome-
trischer Objekte (die ja selbst als geometrisches Objekte wiederum in einer Gruppe sein kann),
wird GeoGruppe::draw() aufgerufen, das dann für alle darin befindlichen Elemente wiederum
automatisch das richtige draw() aufruft.
Vor und nach dem Ausgeben mit draw() wird das Objekt zur Berücksichtigung des Off-
sets in der Gruppe noch entsprechend verschoben bzw. zurückgeschoben. In der Praxis würde
man stattdessen die Funktion draw() sicherlich mit einem Offset als Parameter versehen. Aber
auch hier wird je nach tatsächlichem 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<GeoObj*>::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 übergebenen geometri-
schen Objekts gesucht. Man beachte, dass die Adressen verglichen werden, was bedeutet, dass
Sandini Bib
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ückgeliefert. Wird das Objekt nicht
gefunden, liefert die Funktion false.
Anwendung
Das erste Anwendungsprogramm für geometrische Objekte von Seite 317 kann nun für die Ver-
wendung einer Gruppe von geometrischen Objekten umgeschrieben werden:
// vererb/geotest2.cpp
// Headerdatei für I/O
#include <iostream>
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(k); // - Kreis k
g.add(l2); // - Linie l2
Der Aufruf
g.draw();
ruft jeweils die Funktion draw() der Klasse GeoGruppe auf, wodurch die Schleife aufgerufen
wird, die für 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)
Die Tatsache, dass der Datentyp eine Referenz sein muss, soll verdeutlichen, dass hier kein neues
Objekt, sondern nur eine temporäre zweite Betrachtung des Objekts zurückgeliefert wird. Eine
derartige Betrachtung ist nur möglich, wenn es sich bei dem ü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öst. 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ückgeliefert:
void sonderbehandlung (Geo::GeoObj* objptr)
{
Geo::GeoGruppe* p = dynamic_cast<GeoGruppe*>(objptr);
if (p != NULL) {
// OK: *objptr ist GeoGruppe
p->add(...);
}
else {
// *objptr ist keine GeoGruppe
}
}
Diese Form kann man auch dazu verwenden, den tatsächlichen Datentyp von Objekten, die keine
Zeiger sind, zur Laufzeit auszuwerten:
void sonderbehandlung (const Geo::GeoObj& obj)
{
Geo::GeoGruppe* p = dynamic_cast<GeoGruppe*>(&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<GeoGruppe*>(&obj) != NULL) {
// obj ist GeoGruppe
...
}
Sandini Bib
else {
// obj ist keine GeoGruppe
...
}
}
// 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 <typeinfo> definiert. Für 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 ü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ür typeid um einen Zeiger und hat dieser Zeiger den Wert
0 bzw. NULL, dann wird eine Ausnahme vom Typ std::bad_typeid ausgelöst. Sie kann wie in
Abschnitt 3.6 beschrieben behandelt werden.
class GeoObj {
public:
virtual int berechneFlaeche () const {
return 0;
}
...
};
Um zu verhindern, dass man vergisst, für abgeleitete Klassen diese Funktion auch zu implemen-
tieren, 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
...
};
...
leistung abrufen. Ist der Vertrag einmal als abstrakte Basisklasse definiert, können beide Seiten
unabhängig voneinander entwickelt und modifiziert werden.6
Eine derartige Schnittstelle könnte z.B. wie folgt lauten:
class Druckbar {
public:
virtual void drucken() = 0;
};
Man kann nun einerseits beliebige Klassen implementieren, die diese Schnittstelle implementie-
ren:
class XYZ : public Druckbar {
...
public:
virtual void drucken() {
...
}
};
Andererseits kann man Funktionen schreiben, die für alle Objekte, die diesen Vertrag erfüllen
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 ge-
nannt (abstract base classes). Es ist mitunter eine gute Richtlinie, Basisklassen grundsätzlich nur
als derartige abstrakte Basisklassen zu implementieren.
5.3.9 Zusammenfassung
Polymorphie bezeichnet die Fähigkeit, dass die gleichen Operationen bei unterschiedlichen
Objekten zu unterschiedlichen Reaktionen führen.
C++ unterstützt Polymorphie durch
– gleichnamige Komponenten bei verschiedenen Klassen
– das Überladen von Funktionen
– virtuelle Funktionen
6
Java-Programmierer kennen ein derartiges Konzept als spezielles Sprachmittel. Dort wird es mit Hil-
fe der Schlüsselwörter interface und implements angeboten. In C++ gibt es dazu keine speziellen
Schlüsselwörter.
Sandini Bib
5.4 Mehrfachvererbung
In diesem Abschnitt wird auf die Möglichkeit 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 abgelei-
teten Klasse ist zum einen dies und zum anderen das.
Die Mehrfachvererbung führt zu einigen Problemen, die in der Praxis berücksichtigt wer-
den müssen (z.B. zu Namenskonflikten). In diesem Abschnitt werden die Probleme erläutert und
Lösungsmöglichkeiten vorgestellt. Generell kann schon hier gesagt werden, dass die Mehrfach-
vererbung nur mit Vorsicht und nach sorgfältigen Design-Überlegungen eingesetzt werden sollte.
A u t o B o o t
A m p h
Anhand dieses Beispiels werden wir im Folgenden die Probleme betrachten, die speziell bei der
Mehrfachvererbung entstehen können. Dies sind im Wesentlichen Namenskonflikte, da Kompo-
nenten der verschiedenen Basisklassen die gleichen Bezeichnungen besitzen können und somit
keine eindeutige Zuordnung in der abgeleiteten Klasse mehr möglich ist.
// vererb/auto1.hpp
#ifndef AUTO_HPP
#define AUTO_HPP
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
}
} // namespace Bsp
#endif // AUTO_HPP
Für die Klasse Boot gilt im Prinzip genau das Gleiche, nur dass statt Kilometer Seemeilen ver-
waltet werden:
Sandini Bib
// vererb/boot1.hpp
#ifndef BOOT_HPP
#define BOOT_HPP
namespace Bsp {
/* Klasse Boot
* - zur Vererbung geeignet
*/
class Boot {
protected:
int sm; // gefahrene Seemeilen
public:
// Default- und int-Konstruktor
Boot (int d = 0) {
sm = d; // gefahrene Seemeilen initialisieren
}
} // namespace Bsp
#endif // BOOT_HPP
Sandini Bib
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
}
} // namespace Bsp
#endif // AMPH_HPP
Sandini Bib
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ür jede Klasse einzeln angegeben werden, inwiefern der Zugriff auf die geerbten
Komponenten eingeschränkt wird (siehe Abschnitt 5.1.3).
int main ()
{
/ * Amphibienfahrzeug anlegen und mit
* 7 Kilometer und 42 Seemeilen initialisieren
*/
Bsp::Amph a(7,42);
a: km: ?
sm: ?
Dann wird der int-Konstruktor der Klasse Auto aufgerufen, der den von Auto geerbten Teil
des Objekts initialisiert:
a: km: 7
sm: ?
Anschließend wird der int-Konstruktor der Klasse Boot aufgerufen, der den von Boot ge-
erbten Teil des Objekts initialisiert:
a: 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ändig initialisiert.
Wenn sowohl für Basisklassen als auch für Komponenten Konstruktoren aufgerufen werden
müssen, werden zuerst die Konstruktoren der Basisklassen und dann die Konstruktoren für 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ür Amphibienfahrzeuge dagegen nicht möglich. Diese Element-
funktion wird sowohl von der Klasse Auto als auch von der Klasse Boot geerbt und kann somit
nicht eindeutig zugeordnet werden:
Sandini Bib
Bsp::Amph a(7,42);
...
a.fahre(77); // FEHLER: mehrdeutig
Auch hier kann wieder der Bereichsoperator eine eindeutige Zuordnung ermöglichen:
Bsp::Amph a(7,42);
...
a.Auto::fahre(77); // OK: fahre als Auto
a.Boot::fahre(23); // OK: fahre als Boot
Um die Verwendung des Bereichsoperators zu vermeiden, könnte 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ürlich ist es auch möglich, die Funktionen direkt zu implementieren. Dies kann aber bei
Änderungen an der Implementierung der Basisklasse zu Inkonsistenzen fü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öglich:
Bsp::Amph a(7,42);
...
Bsp::Boot& b(a); // Amphibienfahrzeug wird als Boot betrachtet
b.fahre(23); // OK: fahre als Boot
F a h r z e u g
A u t o B o o t
A m p h
zwei Baujahre besitzen. Wenn wir allerdings andererseits annehmen, dass die Klasse Fahrzeug
eine Komponente maxspeed für die Höchstgeschwindigkeit 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öglichkeiten: mehrfach geerbte Komponenten können gemeinsam
verwendet oder getrennt geerbt werden.
Nichtvirtuelle Basisklassen
Ohne spezielle Angabe sind über verschiedene Klassen geerbte Komponenten aus gemeinsamen
Basisklassen in C++ mehrfach vorhanden. Bei der folgenden Definition besitzt ein Amphibien-
fahrzeug deshalb zweimal die Komponente maxspeed:
class Fahrzeug {
protected:
int maxspeed; // Höchstgeschwindigkeit
...
};
Da maxspeed sowohl von Auto als auch von Boot geerbt wird und somit zweimal vorhanden
ist, kann darauf für Amphibienfahrzeuge nur über den Bereichsoperator zugegriffen werden:
void Amph::f()
{
maxspeed = 100; // FEHLER: mehrdeutig
Auto::maxspeed = 100; // OK: maxspeed von Auto
Boot::maxspeed = 70; // OK: maxspeed von Boot
Fahrzeug::maxspeed = 100; // FEHLER: mehrdeutig
}
Virtuelle Basisklassen
Um zu erreichen, dass eine Komponente, die über verschiedene Klassen von einer Basisklasse
geerbt wird, nicht mehrfach vorhanden ist, müssen die direkt abgeleiteten Klassen der gemein-
samen Basisklasse als virtuelle Basisklassen definiert werden. Dies geschieht, indem bei der
Angabe der Basisklasse zusätzlich das Schlüsselwort virtual verwendet wird:
class Fahrzeug {
protected:
int baujahr; // Baujahr
...
};
Auto::baujahr = 1983; // OK
Boot::baujahr = 1983; // OK
Fahrzeug::baujahr = 1983; // OK
}
Entscheidend für die gemeinsame Verwendung der Komponenten einer Klasse ist, dass die direkt
abgeleiteten Klassen jeweils die Basisklasse virtuell deklarieren. Das Schlüsselwort virtual
muss also, wie hier geschehen, bei Auto und Boot angegeben werden.
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
Bei der einfachen Vererbung werden die Komponenten der abgeleiteten Klasse, die am Ende
des Objekts liegen, einfach nicht mehr berücksichtigt. 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 hin-
terer 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ür Boote
”
richtigen Aufbau hat. Die Objektteile darin sind aber eigentlich nur interne Verweise auf das Ori-
ginalobjekt (siehe Abbildung 5.13). Entscheidend ist, dass das Objekt damit eine zweite Adresse
erhält, unter der es dann angesprochen wird.
Sandini Bib
als Auto
Anteil von Amph
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 ein-
mal 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<const void*>(&a) << std::endl;
}
int main ()
{
Bsp::Amph a;
fAuto(a);
fBoot(a);
}
Die mit Hilfe von f1() über zwei verschiedene Wege aufgerufene Funktion fFahrzeug() wird
für das Objekt a mit ziemlicher Sicherheit zwei unterschiedliche Adressen ausgeben.
Statt mit Referenzen können 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;
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öglichkeit: 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.
Dieser Sachverhalt kann aber unter Umständen sinnvoll eingesetzt werden. Ich habe allerdings
noch kein schlüssiges Beispiel dafür gesehen. Es würde 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ächst einmal
prüfen, 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, soll-
ten Sie zwei Dinge tun:
Mir schreiben, damit ich Ihr Beispiel in der nächsten Auflage als sinnvolles Beispiel verwen-
den kann,7 und
Dummy-Klassen einführen, da bei gleichnamigen Basisklassen keine eindeutige Zuordnung
durch den Klassennamen möglich ist (siehe Abbildung 5.15).
7
Das einzige halbwegs sinnvolle Beispiel, das mir einfällt, ist eine von der Klasse Person zweimal abge-
leitete Klasse GespaltenePersoenlichkeit.
Sandini Bib
A 1 A 2
5.4.5 Zusammenfassung
C++ erlaubt die Mehrfachvererbung. Abgeleitete Klassen können mehrere Basisklassen be-
sitzen.
Entstehen dadurch Namenskonflikte für den Zugriff auf Komponenten, kann nur mit dem
Bereichsoperator darauf zugegriffen werden.
Basisklassen können ü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 Adres-
sen besitzen. Dies ist sogar möglich, wenn es unter dem gleichen Typ betrachtet wird. Um
die Identität eines Objekts immer zweifelsfrei sicherstellen zu können, müssen deshalb ent-
sprechende Komponenten eingeführt werden.
Sandini Bib
Verwendung Vererbung
Beziehung has_a is_a
besteht_aus ist_ein
part_of kind_of
containment inheritance
Aggregation Generalisierung
Sprachmittel Komponente, Verweis abgeleitete Klasse
Der Design-Fehler ist der, dass an die geerbten Komponenten eine spezialisierende Bedingung
geknüpft wird, nämlich, dass Breite und Höhe, durch die ein Rechteck gekennzeichnet ist, immer
gleich sein müssen.
Eine Regel zur Vererbung lautet also:
Alle Zustände, die ein Objekt einer Basisklasse annehmen kann, muss auch ein Objekt der
abgeleiteten Klasse annehmen können.
Dummerweise wird die Vererbungsbeziehung häufig auch als Spezialisierung bezeichnet. Dies
ist aber nicht im einschränkenden Sinne, sondern im konkretisierenden Sinne gemeint. Ich be-
zeichne die Vererbungsbeziehung deshalb lieber als Konkretisierung.
Da ein Quadrat also kein Rechteck und ein Rechteck ohnehin kein Quadrat ist, gibt es über-
haupt keine Vererbungsbeziehung. Wer die zweifellos vorhandenen gemeinsamen Eigen-
schaften aber nur einmal implementieren will, kann eine gemeinsame abstrakte Basisklasse
wie Viereck implementieren.
Bruch: BruchMitGanzerZahl:
zaehler: 13 zaehler: 1
nenner: 4 nenner: 4
zahl: 3
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
das Objekt auf die Komponenten, die in der Basisklasse schon existieren. Also würde 3 41 als 14
verwendet, was zu entsprechend unsinnigen Ergebnissen führen kann. So würde letztlich jede
Zuweisung an einen Bruch aus dem initialisierten Wert 13 1
4 den Wert 4 machen.
Der Design-Fehler besteht darin, dass die geerbten Komponenten um neue Komponenten
ergänzt werden, durch die sie für den gleichen Informationsgehalt neue Werte erhalten.
Eine weitere Regel zur Vererbung lautet also:
Der Zustand der Komponenten, die ein Objekt einer Basisklasse für einen bestimmten Wert
annimmt, darf durch neue Komponenten einer abgeleiteten Klasse nicht verändert werden.
Auch in diesem Fall sollte nicht abgeleitet werden, um ein mögliches Auftreten des Fehlers gar
nicht erst zu ermöglichen.
Es handelt sich hier nämlich eindeutig um eine has-a-Beziehung. Schon der Name der Klasse,
BruchMitGanzerZahl, drückt ja bereits aus, dass zwei Objekte, eine Bruch und eine ganze Zahl,
als gemeinsames Objekt betrachtet werden. Die Deklaration der Klasse sähe also besser so aus:
class BruchMitGanzerZahl {
private:
Bruch bruch;
int zahl;
...
};
In diesem Beispiel gibt es allerdings noch eine andere Möglichkeit der Implementierung. Es
kann nämlich geerbt werden, ohne dass die Werte der geerbten Komponenten für den gleichen
Informationsgehalt geändert werden.
Dazu müsste die Implementierung dahingehend geändert werden, dass der Wert 13 4 auch bei
der Klasse BruchMitGanzerZahl dazu führt, dass die Komponente zaehler mit 13 und die
Komponente nenner mit 4 initialisiert wird. Der einzige Unterschied würde darin bestehen, dass
ein Bruch als Bruch mit ganzer Zahl ausgegeben wird. Es würde also nur die Ausgabeoperation
umgeschrieben werden.
Der Informationsgehalt eines Bruchs wird hier nämlich um eine Information ergänzt, die die
Bedeutung der Wertbelegung ändern kann, was ohne diese Komponente nicht möglich wäre.
Dies liegt daran, dass der potenzielle Wertebereich damit verdoppelt wird (alle Bruch-Werte
positiv und negativ).
Wenn nun aber ein negativer Bruch einem Bruch zugewiesen wird, wird aus 13 4 plötzlich
13 , was zumindest sicherlich problematisch ist. Ich hör schon die Leser, die sagen, dass dies doch
4
ein nettes Feature“ wäre. 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änzt werden, durch die ihre Werte unterschiedliche Bedeutungen erhalten.
Eine weitere Regel zur Vererbung lautet also:
Die Wertebelegung, die ein Objekt einer Basisklasse für einen bestimmten Informationsge-
halt annehmen kann, darf durch neue Komponenten einer abgeleiteten Klasse keine neue
Bedeutung erhalten.
Dieses Design-Problem ist auch daran zu erkennen, dass nicht für 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änkende Vererbung entlarvt haben.
Auch hier ist die einzig sinnvolle Lösung die Implementierung als eigenständige 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.
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änken oder verändern. Objekte abgeleiteter Klassen sollten ohne Einschränkung jeder-
zeit als Objekte der Basisklasse verwendet werden können. 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 einfache-
re Implementierung eines Sachverhalts durch implizite Wiederverwendung von Code.
Auch dies sollte man vermeiden. Auf lange Sicht lohnt sich robustes Design mit möglichst
wenig Abhängigkeiten immer.
5.5.6 Zusammenfassung
Nicht jede im Sprachgebrauch vorhandene is-a-Beziehung ist ein Kennzeichen für die Verer-
bung. Insbesondere ist (öffentliche) Vererbung nicht korrekt,
– wenn nicht mehr alle Operationen möglich sind,
– wenn Operationen nicht mehr die gleiche semantische Bedeutung haben,
– wenn die Bedeutung von Komponenten der Basisklasse durch die Vererbung verändert
wird oder
– wenn die Eigenschaften von Komponenten der Basisklasse eingeschränkt werden.
Für alle möglichen Objekte einer abgeleiteten Klasse muss es sinnvoll sein, sie einem Ob-
jekt einer Basisklasse zuzuweisen, wobei die neuen Komponenten einfach wegfallen bzw.
ignoriert werden.
Vermeide Vererbung.
8
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äumen einmischen.
Dieses Kapitel behandelt dieses Thema unter dem Stichwort dynamische Komponenten“.
”
Außerdem werden in diesem Kapitel statische Komponenten erläutert. Dabei handelt es sich
um Komponenten, die nur einmal im Programm enthalten sind und von allen Objekten/Instanzen
gemeinsam verwendet werden.
Als Beispielklassen für dieses Kapitel dienen eine eigene Implementierung einer String-
Klasse und eine einfache Klasse zur Personenverwaltung.
355
Sandini Bib
class String {
private:
char* buffer; // Zeichenfolge als dynamisches Array
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröße von buffer
public:
// Default- und char*-Konstruktor
String (const char* = "");
Sandini Bib
private:
/* Konstruktor aus Länge und Buffer
* - intern für Operator +
*/
String (unsigned, char*);
};
// Standard-Ausgabeoperator
inline std::ostream& operator << (std::ostream& strm, const String& s)
{
s.printOn(strm); // String auf Stream ausgeben
return strm; // Stream zurückliefern
}
// Standard-Eingabeoperator
inline std::istream& operator >> (std::istream& strm, String& s)
{
s.scanFrom(strm); // String von Stream einlesen
Sandini Bib
/* Operator !=
* - als Umsetzung auf Operator == inline implementiert
*/
inline bool operator!= (const String& s1, const String& s2) {
return !(s1==s2);
}
#endif // STRING_HPP
Betrachten wir zunächst die Komponenten, die den Aufbau eines Strings beschreiben:
class String {
private:
char* buffer; // Zeichenfolge als dynamisches Feld (Array)
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröß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öße des dazugehörigen Speicherplatzes verwalten.
Die Zeichenfolge selbst gehört nicht direkt zum Objekt, sondern wird als dynamischer Spei-
cherplatz vom Objekt verwaltet. Abbildung 6.1 verdeutlicht diesen Sachverhalt: Der String s
enthält dort im aktuellen Zustand die Zeichenfolge hallo mit fünf Zeichen. Diese Zeichen be-
finden sich in einem separaten Speicherplatz für acht Zeichen, auf den buffer verweist.
s :
b u f f e r : ' h ' ' a ' ' l ' ' l ' ' o ' ? ? ?
l e n : 5
s i z e : 8
Man könnte alternativ ein Feld mit einer festen Größe deklarieren. Dies hat jedoch den Nachteil,
dass die Anzahl der Zeichen im String entweder merklich eingeschränkt ist und/oder unverant-
wortlich viel Speicherplatz für kleine Strings verschwendet wird.
Sandini Bib
Der über buffer verwaltete dynamische Speicherplatz gehört nicht automatisch zum Objekt,
sondern muss explizit beim Erzeugen des Objekts angelegt werden. Entsprechend muss der Spei-
cherplatz bei Änderungen gegebenenfalls angepasst und beim Zerstören des Objekts wieder frei-
gegeben werden. Dies ist möglich, da die Implementierung sämtlicher Operationen in der Hand
des Programmierers liegt.
Jeder Konstruktor muss also explizit Speicherplatz anlegen, um diesen für die Zeichenfolge
verwenden zu können. Dazu gehört auch der Copy-Konstruktor (Copy-Konstruktoren wurden
in Abschnitt 4.3.7 eingeführt), der aus diesem Grund selbst implementiert werden muss. Der
Default-Copy-Konstruktor würde, da er komponentenweise kopiert, nämlich nur den Zeiger ko-
pieren, 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ürde 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ört wird. Aus diesem Grund muss das Gegenstück zu
den Konstruktoren, der Destruktor, definiert werden. Ein Destruktor wird aufgerufen, wenn ein
Objekt zerstört wird, und bietet die Möglichkeit, vorher noch aufzuräumen“. Man erkennt ihn
”
daran, dass er als Funktionsnamen den Klassennamen mit dem vorangestellten Zeichen ~ trägt:
class String {
public:
...
~String(); // Destruktor
};
/* Copy-Konstruktor
*/
String::String (const String& s)
{
len = s.len; // Anzahl an Zeichen übernehmen
size = len; // Zeichenanzahl bestimmt Speicherplatzgröß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ückliefern
}
/* Operator ==
* - vergleicht zwei Strings
* - globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich 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ängt zwei Strings hintereinander
* - globale Friend-Funktion, damit eine automatische
* Typumwandlung des ersten Operanden möglich ist
*/
String operator+ (const String& s1, const String& s2)
{
// Puffer für den Summenstring erzeugen
char* buffer = new char[s1.len+s2.len];
s :
b u f f e r : ?
l e n : ?
s i z e : ?
Zunächst werden len und size initialisiert. Dabei wird mit strlen() die Länge des übergebe-
nen C-Strings ermittelt:
len = std::strlen(s);
size = len;
Danach wird durch die Anweisung
buffer = new char[size];
Speicherplatz angelegt, der groß genug für die Zeichenfolge ist und dessen Adresse dem String-
Objekt in buffer zugewiesen wird. Auch der Inhalt dieses Speicherplatzes ist zunächst nicht
definiert:
s :
b u f f e r : ? ? ? ? ?
l e n : 5
s i z e : 5
s :
b u f f e r : ' h ' ' a ' ' l ' ' l ' ' o '
l e n : 5
s i z e : 5
s :
b u f f e r :
l e n : 0
s i z e : 0
Hier nutzen wir die Tatsache aus, dass man mit new auch ein Feld mit null Elementen anlegen
kann. In der Praxis wäre 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ür den String-Konstruktor nicht NULL verwendet
werden kann, da die intern verwendeten String-Funktionen nicht mit NULL als Parameter aufge-
rufen werden dürfen (strlen(NULL) würde auf vielen Systemen zum Absturz führen). Bei der
Verwendung von NULL wäre eine Sonderbehandlung notwendig. Da die Standard-String-Klasse
eine derartige Sonderbehandlung allerdings nicht durchführt, wird auch hier darauf verzichtet.
6.1.4 Destruktoren
Der Destruktor dient bei der String-Klasse dazu, den explizit angelegten Speicherplatz mit der
Zerstörung des Strings freizugeben. Da es sich um ein Feld von Zeichen handelt, muss die Feld-
Syntax des Delete-Operators verwendet werden:
String::~String ()
{
// mit new[] angelegten Speicherplatz wieder freigeben
delete [] buffer;
}
Sandini Bib
Aufgerufen wird der Destruktor, unmittelbar bevor der Speicherplatz für das eigentliche Objekt
freigegeben wird. Dies ist z.B. der Fall, wenn der Gültigkeitsbereich eines lokal angelegten Ob-
jekts verlassen wird:
void f()
{
Bsp::String s = "hallo"; // Konstruktor für s
...
} // Am Blockende: Destruktor für s
Der Destruktor wird für jedes angelegte Objekt aufgerufen. Das gilt auch für statische, temporäre
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ären Rückgabewerts an namen[0]
// Destruktor für temporären Rückgabewert
return neu;
// Copy-Konstruktor für temporären Rückgabewert
} // Am Blockende: Destruktor für neu
Der Test auf Ungleichheit wird als Inline-Funktion einfach auf den Test auf Gleichheit zurück-
geführt:
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);
}
// Zeichen kopieren
std::memcpy(sum.buffer,s1.buffer,s1.len);
std::memcpy(sum.buffer+s1.len,s2.buffer,s2.len);
// Summenstring zurückliefern
return sum;
}
Sandini Bib
Diese Implementierung enthält aber gleich mehrere Laufzeitnachteile: Zunächst wird ein Sum-
menstring mit initialem Speicherplatz angelegt, der aber sofort wieder freigegeben wird, um ihn
zu vergrößern (etwas, was auch leicht vergessen wird und dann ein Speicherplatz-Leck (Memory-
Leak) darstellt). Schließlich wird mit der Return-Anweisung der eigentliche Rückgabewert als
Kopie von sum angelegt und sum wieder zerstört. Damit wird insgesamt dreimal new und zwei-
mal delete aufgerufen.
Es wäre natürlich besser, wenn nur einmal new aufgerufen würde. Dies gilt umso mehr, als
dass new und delete zu den teureren“ Operationen gehören. Zu diesem Zweck wird ein spe-
”
zieller Konstruktor definiert, der einen String anlegt, dem sowohl die Startgröße als auch der
vollständig initialisierte Puffer mit der Zeichenfolge des Summenstrings übergeben wird:
String::String (unsigned l, char* buf)
{
len = l; // Anzahl an Zeichen übernehmen
size = len; // ist gleichzeitig auch Speicherplatzgröße
buffer = buf; // Speicherplatz übernehmen
}
Dieser Konstruktor ist eine spezielle Optimierung, die als öffentliche Schnittstelle gefährlich
wäre. Man könnte nicht sicherstellen, dass als zweiter Parameter immer mit new[] angelegter
und initialisierter Speicherplatz der Länge len ü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öße erzeugt und initialisiert wird:
String operator+ (const String& s1, const String& s2)
{
// Puffer für den Summenstring erzeugen
char* buffer = new char[s1.len+s2.len];
dieser Summenstring gleich als Rückgabewert erzeugt wird. Damit wird nur das eine new aufge-
rufen.
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ängen. Bei dieser Implementierung geht es allerdings merklich schneller.
In der Schleife wird zunächst 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ür das neue Zeichen vorhanden ist. Schon in C
ist es ein verbreiteter Fehler anzunehmen, eine Eingabe könne 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; // Zeiger auf alten Speicherplatz
size = size*2i + 32; // Speicherplatz mehr als verdoppeln
buffer = new char[size]; // neuen Speicherplatz anlegen
std::memcpy(buffer,tmp,len); // Zeichen kopieren
delete [] tmp; // alten Speicherplatz freigeben
}
Nun kann das neue Zeichen eingetragen und die Anzahl der Zeichen entsprechend erhöht werden:
buffer[len] = c;
++len;
1
Hinweis: Auf manchen Systemen wird isspace() auch in <cctype> fälschlicherweise global, also ohne
std:: definiert, was hier zu einer Fehlermeldung führt.
Sandini Bib
Reference-Counting
Eine Optimierungsmöglichkeit, die auch verdeutlicht, welche mächtige Möglichkeiten sich durch
die Programmierung von Klassen in C++ eröffnen, ist die Verwendung einer Technik namens
Reference-Counting. Diese Technik basiert auf der Überlegung, dass das Kopieren und Zuweisen
von Strings teuer ist, sehr häufig vorkommt und dass Strings relativ selten manipuliert werden
(also einzelne Zeichen darin nur selten verändert 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; // Zeichenfolge als dynamisches Feld (Array)
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröße von buffer
unsigned refs; // Anzahl Strings, die diesen Body verwenden
...
};
Die Objekte der Hilfsklasse StringBody verwalten die eigentlichen Zeichenfolgen und können
von mehreren Strings, die alle den gleichen Wert haben, gemeinsam verwendet werden. Die
Komponente references hält 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ört 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ört.
Jeder lesende Zugriff auf einen String (etwa die Abfrage seiner Länge) wird einfach an den
Body weitergereicht. Nur wenn ein String verändert wird, dessen Body mehrfach verwendet
wird, wird eine echte Kopie des Bodys angelegt und dem geänderten String zugeordnet.
Da das Durchreichen von Strings (Kopieren, Zuweisen) typischerweise weitaus häufiger ge-
schieht als das Ändern einzelner Zeichen, werden mit diesem Mechanismus Speicherplatz-Funk-
tionen vermieden, die vergleichsweise viel Zeit kosten.
Andere typische Optimierungen sind spezielle Funktionalitäten für Teilstrings, die ebenfalls
über interne Hilfsklassen verwaltet werden.
Sandini Bib
Reference-Counting kann man auch mit Hilfe von speziellen Smart-Pointern implementieren.
Darauf wird in Abschnitt 9.2.1 eingegangen.
public:
Datei (char*); // char*-Konstruktor für den Dateinamen
~Datei (); // Destruktor
...
};
2
Hier zeigt sich der Vorteil des String-Ansatzes von Java, bei dem zwischen einer Klasse für Strings, bei
denen keine einzelnen Zeichen geändert werden können, und Strings, bei denen jede Änderung möglich ist,
unterschieden wird. Dafür 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
Datei::~Datei ()
{
// Datei schließen
fclose(fp);
}
Im Anwendungsprogramm wird die Datei dann automatisch mit der Deklaration eines Objekts
geöffnet und beim Verlassen des Blocks automatisch wieder geschlossen:
void f ()
{
Datei d("testprog.cc"); // Konstruktor öffnet die Datei
...
gut ü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 Destruk-
tor oder nichts davon.
Das heißt: Braucht man für eine Klasse eine eigene Implementierung von einem Copy-Kon-
struktor, einem Zuweisungsoperator oder einem Destruktor, dann braucht man in der Regel eine
eigene Implementierung für 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üssen 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önnen dynamische Komponenten besitzen. Das sind Komponenten, die nicht ein-
fach durch Zuweisung kopiert werden können (typischerweise Zeiger).
Klassen mit dynamischen Komponenten brauchen eine eigene Implementierung des Copy-
Konstruktors, des Zuweisungsoperators und eines Destruktors.
Ein Destruktor ist eine Funktion, die bei der Zerstörung 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ückliefern, dem etwas zugewiesen wurde (also
*this).
Durch dynamische Komponenten können beliebig komplexe Vorgänge so abstrahiert werden,
dass sie über ein Objekt einfach zu handhaben sind.
Das Kopieren und Zuweisungen können verboten werden.
Eine Klasse braucht in der Regel einen Copy-Konstruktor, einen Zuweisungsoperator und
einen Destruktor oder nichts davon.
Sandini Bib
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ückliefern, so dass dieser manipuliert werden kann.
Dies ist nur möglich, wenn der Operator [ ] nicht eine Kopie, sondern Zugriff auf das Origi-
nalzeichen des Strings liefert, damit es manipuliert werden kann. Das bedeutet, dass die Opera-
torfunktion eine Referenz zurückliefern muss.
Der Operator muss deshalb wie folgt deklariert werden:
namespace Bsp {
class String {
private:
char* buffer; // Zeichenfolge als dynamisches Feld (Array)
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröße von buffer
...
public:
char& operator [] (unsigned); // Zugriff auf ein Zeichen
...
};
}
Sandini Bib
return buffer[idx];
}
Nach dem Test, ob der übergebene Index überhaupt im erlaubten Bereich liegt, wird das Zeichen
mit dem Index idx der internen Komponente buffer als Referenz zurückgeliefert. Das zurück-
gelieferte Zeichen der Funktion ist also keine Kopie, sondern das Originalzeichen des Strings
und kann damit auch manipuliert werden.
chenkette, und der bleibt konstant. Das, worauf der Zeiger zeigt, ist aber nicht konstant. Wenn
der Operator [ ] in seiner bisherigen Implementierung auch für konstante Objekte erlaubt würde,
könnten diese deshalb den Inhalt des Strings verändern.
Der Operator sollte aber für konstante Objekte definiert sein. Es wäre recht uneinsichtig,
wenn es nur bei variablen Strings möglich wäre, ein Zeichen über den Operator [ ] abzufragen.
Das Folgende sollte also möglich sein:
const Bsp::String s = "Tasse";
...
char c = s[0];
Das bedeutet, dass eigentlich zwei Implementierungen des Operators [ ] benötigt werden: ei-
ne für konstante und eine für variable Strings. Eine solche unterschiedliche Implementierung
ist möglich. Eine Funktion kann sowohl für variable als auch für konstante Objekte überladen
werden (nur wenn es keine eigene Funktion für variable Objekte gibt, wird die Funktion für
konstante Objekte verwendet).
Aus diesem Grund sollte die Klasse Bsp::String eine zweite Deklaration für den Operator
[ ] erhalten:
namespace Bsp {
class String {
private:
char* buffer; // Zeichenfolge als dynamisches Feld (Array)
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröße von buffer
...
public:
// Operator [] für Variablen
char& operator [] (unsigned);
return buffer[idx];
}
Entscheidend ist nur, dass das Zeichen nicht mehr als Referenz, sondern als Kopie zurückgeliefert
wird. Handelt es sich bei dem Rückgabewert um ein größeres Objekt, wäre aus Zeitgründen eine
Referenz vorzuziehen, die dann aber natürlich als konstant deklariert werden sollte:
namespace Bsp {
class String {
...
public:
const char& operator [] (unsigned) const;
...
};
}
// Zeichen kopieren
std::memcpy (p, buffer, len);
// Stringendekennzeichen anhängen
p[len] = '\0';
// und zurückliefern
return p;
}
Diese Lösung 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üher oder später sicherlich zu
einem Speicherplatzproblem führen wird.
Falls eine solche Implementierung zur Konvertierung verwendet wird, sollte diese deshalb
zumindest im Namen den Hinweis enthalten, dass Speicherplatz angelegt wird, der freizuge-
ben ist (etwa durch den Namen asNewCharPtr()).
class String {
private:
char* buffer; // Zeichenfolge als dynamisches Feld (Array)
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröße von buffer
...
public:
const char* toCharPtr() const;
};
3
Vorsicht, das bedeutet nicht, dass man davon ausgehen kann, dass auch data() mit dem Stringendekenn-
zeichen endet. Es ist nur bei den meisten Implementierungen so. Wer ein Stringendekennzeichen braucht,
sollte also immer c_str() aufrufen.
Sandini Bib
if (fp) {
// Daten lesen
...
}
else {
// FEHLER: fp ist NULL
...
}
wird z.B. bei einer Klasse für geöffnete Dateien Entsprechendes ermöglicht:
Bsp::Datei f("hallo"); // Konstruktor ö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ür false steht. Der Test
if (fp)
ist also eigentlich eine Abkürzung für:
if (fp != NULL)
Man kann hierbei streiten, ob dadurch die Lesbarkeit der Programme erhöht wird, da diese
Schreibweise dazu führt, dass in Bedingungen nicht mehr nur Boolesche Ausdrücke oder Werte
stehen können.
Die Übertragung auf C++ kann man nun unter dem gleichen Blickwinkel betrachten. Extra
dafür wurde definiert, dass Bedingungen in Kontrollstrukturen auch dann möglich sind, wenn
für 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üllt.
Um also die obige If-Abfrage zu ermöglichen, muss z.B. eine Typumwandlung in einen Zei-
gertyp implementiert werden. Dies könnte bei einer Klasse für geöffnete Dateien z.B. wie folgt
aussehen:
Sandini Bib
namespace Bsp {
class Datei {
private:
FILE* fp; // Zeiger auf die geöffnete 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 verlie-
ren. Mit einer so definierten Typkonvertierung besteht die Möglichkeit, Zugriff auf eine private
Komponente zu erhalten und diese zu modifizieren. Ohne Fehlermeldung wäre z.B. Folgendes
möglich:
Bsp::Datei f;
FILE* fp;
...
fp = f;
Eine erste Verbesserung wäre deshalb die Umwandlung des Zeigers in den Datentyp void*:
namespace Bsp {
class Datei {
private:
FILE* fp; // Zeiger auf die geöffnete Datei
public:
...
operator void* () {
return (void*)fp;
}
};
}
Sandini Bib
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öffnete Datei
public:
...
operator void* () {
return fp != NULL ? reinterpret_cast<void*>(32)
: static_cast<void*>(0);
}
};
}
In diesem Fall werden die Werte 32 bzw. 0 mit Hilfe von Operatoren zur Typumwandlung in
Adressen umgewandelt. Während die Verwendung von 0 als Adresse möglich ist und somit für
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 Element-
funktion zur expliziten Typumwandlung wie istOK() funktioniert ebenfalls und führt zudem zu
lesbarerem Anwendungscode:
if (f.istOK())
Die Standardklassen für I/O verwenden allerdings die gerade vorgestellte Technik mit der auto-
matischen Typumwandlung. Dabei wird auch der Operator ! überladen, um so etwas wie
if (! f)
zu ermöglichen. In Abschnitt 4.5.2 wurde bereits genauer darauf eingegangen.
namespace Bsp {
class Datei {
private:
FILE* fp; // Zeiger auf die geöffnete Datei
int zeilen; // Zeilenanzahl (-1: noch nicht bekannt)
public:
// Konstruktor
Datei (...) : zeilen(-1) { // Zeilenanzahl zunächst nicht bekannt
...
}
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ür konstante Objekte der
Klasse ermittelt werden:4
const Bsp::Datei f("prog.dat");
4
Dieses Beispiel geht davon aus, dass die Konstantheit einer Datei bestimmt, ob schreibend zugegriffen
werden darf. Die Standarddatentypen für den Dateizugriff bieten verschiedene Datentypen für lesenden
und schreibenden Zugriff.
Sandini Bib
public:
// Konstruktor
Datei (...) : zeilen (-1) { // Zeilenanzahl zunächst nicht bekannt
...
}
ohneKopie(f); // OK
mitKopie(f); // FEHLER: Kopie anlegen nicht erlaubt
g = f; // FEHLER: Zuweisungen nicht erlaubt
}
Sandini Bib
6.2.6 Proxy-Klassen
Die meisten Probleme, die in diesem Abschnitt erläutert wurden, haben damit zu tun, dass man
die Kontrolle über die Operationen verliert, die für ein Objekt aufgerufen werden. Liefert man
einen Zeiger auf interne Daten, kann dieser zweckentfremdet werden. Liefert man über den In-
dexoperator 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");
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äsen-
tiert Referenzen auf ein Zeichen in einem String.
Ein derartiges Objekt wird von dem Indexoperator für Variablen zurückgeliefert:
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ückgabewerts wird dazu das Zeichen im String übergeben, auf das
verwiesen werden soll. Die Referenz-Komponente ch wird damit initialisiert. Auf diese Weise,
besteht auch weiterhin Kontrolle über die Operationen, die mit dem Rückgabewert des Index-
operators durchgeführt werden.
Die Verwendung dieser Referenz zeigt folgendes Beispiel:
// dyna/stringtest2.cpp
#include <iostream> // C++-Headerdatei für I/O
#include "string.hpp" // C++-Headerdatei für Strings
int main ()
{
typedef Bsp::String string;
string name;
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ässt dann eine ent-
sprechende 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ückgabewert des Indexoperators ist nicht
erlaubt:
++vorname[0]; // FEHLER, ++ für Referenzen nicht definiert
Nach diesem Muster kann man jederzeit auch geschachtelte Ausdrücke unter Kontrolle halten.
Man muss nur die Fähigkeiten von Rückgabewerten kontrollieren, indem man als Rückgabetyp
immer eine selbst definierte Klasse verwendet.
// dyna/string3.hpp
namespace Bsp {
class String {
public:
// Fehlerklasse:
class RangeError {
public:
int index; // fehlerhafter Index
return cstring[i];
}
5
Initialisierungslisten werden in den Abschnitten 4.1.7 und 6.4.2 eingeführt.
Sandini Bib
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äre es z.B. möglich,
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ür das Objekt im Catch-Block wie bei Funktionen ein Name deklariert
werden, um auf die Komponenten zugreifen zu können. Man beachte außerdem, dass im Catch-
Bereich 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ür den Zugriff eine Elementfunktion zu definieren, da das Fehlerobjekt nach der Auswertung
ohnehin zerstört wird.
// dyna/string4.hpp
namespace Bsp {
class String {
public:
// Fehlerklasse:
class RangeError {
public:
int index; // fehlerhafter Index
String value; // String dazu
return cstring[i];
}
if (i >= len) {
/* Ausnahme:
* - neu: String selbst und fehlerhaften Index ü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öst, nicht als
Parameter der Ausnahme übergeben werden kann, man auch nicht mehr die Identität des Objekts
feststellen kann. Wird diese Möglichkeit benötigt, 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önnen für variable und konstante Objekte unterschiedlich überladen werden.
Liefert eine Elementfunktion die Möglichkeit zu einem schreibenden Zugriff auf interne
Komponenten, muss sichergestellt werden, dass die Funktion nicht für Konstanten aufge-
rufen werden kann.
Funktionen, die dynamische Komponenten in einen anderen Typ konvertieren, können mit
unterschiedlichen Vor- und Nachteilen eine Kopie oder eine Konstante zurückliefern.
Sandini Bib
class String {
protected:
char* buffer; // Zeichenfolge als dynamisches Feld (Array)
unsigned len; // aktuelle Anzahl an Zeichen
unsigned size; // Speicherplatzgröße von buffer
public:
Sandini Bib
private:
/* Konstruktor aus Länge und Puffer
* - intern für Operator +
*/
String (unsigned, char*);
};
// Standard-Ausgabeoperator
inline std::ostream& operator << (std::ostream& strm, const String& s)
{
s.printOn(strm); // String auf Stream ausgeben
return strm; // Stream zurückliefern
}
Sandini Bib
// Standard-Eingabeoperator
inline std::istream& operator >> (std::istream& strm, String& s)
{
s.scanFrom(strm); // String von Stream einlesen
return strm; // Stream zurückliefern
}
/* Operator !=
* - als Umsetzung auf Operator == inline implementiert
*/
inline bool operator!= (const String& s1, const String& s2) {
return !(s1==s2);
}
#endif // STRING_HPP
// dyna/fstring1.hpp
#ifndef FARBSTRING_HPP
#define FARBSTRING_HPP
/* Klasse FarbString
* - abgeleitet von String
*/
class FarbString : public String {
protected:
String farb; // Farbe, die der String besitzt
public:
// Default-, String- und String/String-Konstruktor
FarbString (const String& s = "", const String& f = "black")
: String(s), farb(f) {
}
#endif // FARBSTRING_HPP
Zunächst wird die neue Komponente für die Farbe definiert. Da das Symbol farbe als Funktion
zum Setzen und Abfragen der Farbe verwendet wird, trägt die Komponente den Namen farb
(sinnvolle Namen bei Schnittstellen sollten vorgehen):6
class FarbString : public String {
protected:
String farb; // Farbe, die der String besitzt
...
};
Als Nächstes wird der Konstruktor definiert. Als Parameter können dabei bis zu zwei Strings
ü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 übergebenen Farbe f wird die Komponente
farb initialisiert.
Die Elementfunktion farbe() wurde so überladen, dass sie sowohl zum Setzen als auch zum
Abfragen der Farbe verwendet werden kann. Dies ist eine gängige Technik in C++. Wird kein
Parameter übergeben, liefert sie die aktuelle Farbe, wird ein Parameter übergeben, ist dies die
neue Farbe:
6
Es gibt keine generell übliche Lösung für 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ürzt und manchmal beginnen die
Elementfunktionen mit Präfixen wie get und set.
Sandini Bib
Wer will, dass in Abhängigkeit vom Typ zur Laufzeit die richtige Funktion aufgerufen wird, sollte
den Operator als Elementfunktion definieren. Zusätzlich kann ja ein globaler Operator definiert
werden, der ein Objekt vom Typ char* mit einem String bzw. mit einem FarbString vergleicht.
Eine andere Möglichkeit wäre 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önnen, 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 Ungleich-
heit immer den Test auf Gleichheit für Objekte vom Typ String auf. Dies würde dann auch für
FarbStrings gelten. Die Farbe würde 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ängig vom tatsächlichen Datentyp immer die String-Funktion aufgerufen wird.
Sofern eine Klasse zur Vererbung vorgesehen ist, sollte deshalb auf Friend-Funktionen grund-
sätzlich verzichtet werden. Die automatische Typumwandlung für den ersten Operanden kann
man auch durch zusätzliche globale Hilfsfunktionen ermöglichen.
Eine andere Möglichkeit ist die Technik, die auch schon bei den Ein- und Ausgabeopera-
toren angewendet wird. Man definiert den Operator als globale Funktion, die nur eine virtuelle
Elementfunktion aufruft, die die eigentliche Arbeit übernimmt. Dies sähe in der Basisklasse z.B.
wie folgt aus:
class String {
...
public:
virtual bool equals (const String& s2);
...
};
// dyna/fstring1.cpp
// Headerdatei der eigenen Klasse
#include "fstring.hpp"
Die Funktion zum Einlesen liest einfach nur zwei Strings für den Inhalt und die Farbe ein. Ei-
ne aufwändigere Implementierung könnte die Farbe als optionale Angabe interpretieren, indem
öffnende und schließende Klammern entsprechend ausgewertet werden.
int main ()
{
Bsp::FarbString f("hallo"); // FarbString mit Default-Farbe
Bsp::FarbString r("rot","red"); // FarbString mit Rot als Farbe
std::cout << f << " " << r << std::endl; // FarbStrings ausgeben
std::cout << f << " " << r << std::endl; // FarbStrings ausgeben
6.3.7 Zusammenfassung
Friend-Funktionen können niemals virtuell sein.
Aus diesem Grund sind Friend-Funktionen bei Vererbung problematisch und sollten vermie-
den werden.
Ist ein Destruktor in der Basisklasse virtuell, ist er das auch in allen davon abgeleiteten Klas-
sen.
Sind in einer Basisklasse Copy-Konstruktor, Destruktor und Zuweisungsoperator notwen-
dig, müssen diese nicht in einer davon abgeleiteten Klasse notwendig sein. Die Default-
Implementierungen dieser Operationen in abgeleiteten Klassen rufen die selbst definierten
Implementierungen der Basisklasse automatisch auf.
Sandini Bib
class Person {
private:
std::string vname; // Vorname der Person
std::string nname; // Nachname der Person
public:
// Konstruktor aus Nachname und optional Vorname
Person (const std::string&, const std::string& = "");
// vergleichen
bool operator == (const Person& p) const {
return vname == p.vname && nname == p.nname;
}
bool operator != (const Person& p) const {
return vname != p.vname || nname != p.nname;
}
...
};
#endif // PERSON_HPP
Sandini Bib
An dieser Stelle sei zunächst darauf hingewiesen, dass es sich bei der Klasse Person um keine
Klasse mit dynamischen Komponenten handelt, obwohl sie aus Teilobjekten besteht, die dynami-
sche Komponenten besitzen. Die Probleme dynamischer Komponenten sind nämlich erfolgreich
auf die Teilobjekte abgewälzt worden. Dort wurde dafür gesorgt, dass die Zuweisung, der Copy-
Konstruktor 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ür das Anlegen einer Kopie. Entsprechend werden bei der Zerstörung 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
übergebenen Parametern:
// dyna/person1.cpp
/* Konstruktor aus Nachname und Vorname
* - Default für Vorname: ""
*/
Person::Person (const std::string& nn, const std::string& vn)
: nname(nn), vname(vn) // Vor- und Nachname anhand übergebener
// Parameter initialisieren
{
// mehr ist nicht zu tun
}
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
Dann wird für die Komponente vname der Copy-Konstruktor der String-Klasse aufgerufen,
da zur Initialisierung jetzt der String vn übergeben wird. Dieser initialisiert den Vornamen
mit dem übergebenen String (siehe Abschnitt 6.1.3):
v n a m e :
b u f f e r : ' N ' ' i ' ' c ' ' o ' ' l ' ' a ' ' i '
l e n : 7
s i z e : 7
n n a m e :
b u f f e r : ?
l e n : ?
s i z e : ?
Dass zunächst 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ür die Komponente nname ebenfalls der Copy-Konstruktor der String-
Klasse aufgerufen, da zur Initialisierung der String nn übergeben wird:
v n a m e :
b u f f e r : ' N ' ' i ' ' c ' ' o ' ' l ' ' a ' ' i '
l e n : 7
s i z e : 7
n n a m e :
b u f f e r : ' J ' ' o ' ' s ' ' u ' ' t ' ' t ' ' i ' ' s '
l e n : 8
s i z e : 8
Schließlich werden die Anweisungen des Konstruktors der Klasse Person ausgeführt. Doch
da die Komponenten bereits vollständig 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 Imple-
mentierung einer Funktion ist.
Da oft nach der Initialisierung gar keine Anweisungen mehr durchgeführt werden müssen,
wird der Konstruktor in der Praxis häufig gleich als Inline-Funktion in der Headerdatei definiert:
namespace Bsp {
class Person {
...
public:
Sandini Bib
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ür die Komponente vname allerdings der Default-Konstruktor der String-Klasse
aufgerufen (es wird ja kein Argument zur Initialisierung übergeben). Dieser initialisiert den
Vornamen mit dem Leerstring (siehe Abschnitt 6.1.2):
Sandini Bib
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 : 0
s i z e : 0
Danach wird die Komponente nname ebenfalls durch den Default-Konstruktor der String-
Klasse mit dem Leerstring initialisiert:
v n a m e :
b u f f e r :
l e n : 0
s i z e : 0
n n a m e :
b u f f e r :
l e n : 0
s i z e : 0
Schließlich werden die Anweisungen des Konstruktors der Klasse Person ausgeführt, die
den Komponenten vname und nname dann die richtigen Werte zuweisen:
v n a m e :
b u f f e r : ' N ' ' i ' ' c ' ' o ' ' l ' ' a ' ' i '
l e n : 7
s i z e : 7
n n a m e :
b u f f e r : ' J ' ' o ' ' s ' ' u ' ' t ' ' t ' ' i ' ' s '
l e n : 8
s i z e : 8
Dazu wird der Zuweisungsoperator der String-Klasse aufgerufen, der den Speicherplatz für
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 Initiali-
sierungsliste entstehen: Beide Komponenten werden über den Default-Konstruktor mit einem
Default-Wert belegt, der aber falsch ist und durch eine Zuweisung anschließend sofort ersetzt
Sandini Bib
werden muss. Im vorliegenden Beispiel bedeutete dies, dass völlig unnötigerweise Speicherplatz
angelegt und sofort wieder freigegeben wird, da er zur Initialisierung der übergebenen Namen
nicht ausreicht. Dies bedeutet einen erheblichen Laufzeitnachteil (Speicherplatzverwaltung ist
zeitaufwändig), der sich bei Objekten, die mehr oder komplexere Komponenten besitzen, noch
vergrößert.
Eine solche fehlerhafte Initialisierung mit dem Default-Wert kann aber nicht nur sehr viel Zeit
kosten, sondern auch irreparable Schäden anrichten. Man denke z.B. an eine Klasse für geöffnete
Dateien, die zunächst eine verkehrte Default-Datei öffnet.
Schließlich kann es auch sein, dass für Klassen kein aufrufbarer Default-Konstruktor existiert.
Dies ist immer dann der Fall, wenn zur Initialisierung mindestens ein Argument gebraucht wird.
Solche Objekte könnten 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 übergeben. Die Klasse könnte
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).
class Person {
private:
std::string vname; // Vorname der Person
const std::string nname; // 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) {
}
#endif // PERSON_HPP
Doch Vorsicht: Komponenten als Konstanten zu deklarieren bedeutet, dass sie wirklich nicht
verändert werden können, solange das Objekt existiert. Das bedeutet insbesondere, dass keine
Zuweisung mit dem Default-Zuweisungsoperator möglich ist, der allen Komponenten neue Wer-
te zuweist. Insofern ist die Deklaration des Nachnamens für die Klasse Person nicht sinnvoll.
Ein typisches Beispiel für konstante Komponenten sind Objekt-IDs (siehe Abschnitt 6.5.1).
Genauso ist es möglich, dass eine Klasse Referenzen als Komponenten besitzt. Auch diese
müssen über eine Initialisierungsliste initialisiert werden. Man muss dabei allerdings sicherstel-
len, dass das Objekt, für das die Referenz steht, nicht kürzer existiert als das Objekt, das die
Referenz als Komponenten besitzt. Insofern kann man Komponenten eigentlich nur dann als Re-
ferenzen verwenden, wenn die Lebensdauer des referenzierten Objekts von dem Objekt, das die
Referenz besitzt, kontrolliert wird oder wenn das referenzierte Objekt während der gesamten
Laufzeit des Programms existiert.
6.4.3 Zusammenfassung
Komponenten einer Klasse können einen beliebigen Datentyp besitzen. Ob es sich dabei um
einen Datentyp mit dynamischen Komponenten handelt, spielt für die Implementierung der
Klasse keine Rolle.
Sofern nicht anders angegeben, wird für Teilobjekte abstrakter Datentypen jeweils der Default-
Konstruktor aufgerufen. Ist dieser nicht definiert oder aufrufbar, kann kein Objekt der Klasse
angelegt werden.
Initialisierungslisten ermöglichen es, den Konstruktoren für Teilobjekte Argumente zu über-
geben, wodurch statt des Default-Konstruktors ein entsprechend anderer Konstruktor aufge-
rufen wird.
Initialisierungslisten sind Zuweisungen vorzuziehen.
Klassen können auch Konstanten und Referenzen als Komponenten besitzen. Diese müssen
im Konstruktor mit Hilfe einer Initialisierungsliste initialisiert werden.
Sandini Bib
// Headerdateien einbinden
#include <string>
7
Die Bezeichnung Klassenvariable stammt aus Smalltalk.
Sandini Bib
class Person {
/* neu: statische Klassenkomponenten
*/
private:
static long personenMaxID; // höchste 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; // Vorname der Person
std::string nname; // Nachname der Person
const long pid; // 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&);
#endif // PERSON_HPP
Entscheidend für die Tatsache, dass es sich bei personenMaxID und personenAnzahl nicht
um Komponenten handelt, die jedes Objekt der Klasse für sich verwaltet, ist die Deklaration der
Komponenten als statische Komponenten:
class Person {
private:
static long personenMaxID; // höchste ID aller Personen
static long personenAnzahl; // aktuelle Anzahl aller Personen
...
};
Das bedeutet, dass diese Variablen nur genau einmal für 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 Klassen-
methoden 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ültigkeits-
bereich der Klasse Bsp::Person gehören. Sie haben damit Zugriff auf private statische Kom-
ponenten 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önnen.
Sandini Bib
int main ()
{
std::cout << "Personenanzahl: " << Bsp::Person::anzahl()
<< std::endl;
/* neu: Copy-Konstruktor
*/
Person::Person (const Person& p)
: nname(p.nname), vname(p.vname), pid(++personenMaxID)
{
++personenAnzahl; // Anzahl der existierenden Personen erhöhen
}
/* 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
return *this;
}
Auch hier gilt, dass die Variable für den entsprechenden Gültigkeitsbereich über den Bereichs-
operator angesprochen wird:
namespace Bsp {
long Person::personenMaxID = 0;
long Person::personenAnzahl = 0;
...
}
Selbstverständlich darf jede statische Klassenkomponente nur an einer Stelle im Programm ini-
tialisiert werden.
Der Konstruktor initialisiert die Komponenten der Klasse Person über die Initialisierungs-
liste:
Person::Person (const std::string& nn, const std::string& vn)
: nname(nn), vname(vn), pid(++personenMaxID)
{
++personenAnzahl; // Anzahl der existierenden Personen erhöhen
}
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 Kom-
ponente pid wird die inkrementierte Klassenkomponente mit der bisher höchsten Personen-ID,
personenMaxID, verwendet. Da die Komponente pid konstant ist, muss dieser Wert mit Hilfe
einer Initialisierungsliste initialisiert werden; eine nachträgliche Zuweisung wäre nicht möglich.
Im Funktionskörper wird außerdem der Zähler für die Anzahl der existierenden Personen ent-
sprechend erhöht.
Damit die Anzahl jeweils stimmt, muss der Zähler für die Anzahl existierender Personen bei
der Zerstörung 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
Der Destruktor wird für jedes Objekt aufgerufen, das existierte und zerstört wird. Entsprechend
muss auch wirklich für jedes Objekt, das angelegt wird, der Zähler heraufgesetzt werden. Insbe-
sondere 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öht wird.
Person::Person (const Person& p)
: nname(p.nname), vname(p.vname), pid(++personenMaxID)
{
// Anzahl der existierenden Personen erhöhen
++personenAnzahl;
}
Ohne Implementierung des Copy-Konstruktors wird beim Kopieren eines Objekts die ID ko-
piert (damit hätten zwei Objekte die gleiche ID) und die Anzahl nicht erhöht (wohl aber bei der
Zerstörung des Objekts vermindert).
Außerdem muss der Zuweisungsoperator implementiert werden, damit nicht auch die ID zu-
gewiesen 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;
}
return *this;
}
Da die ID als Konstante deklariert wurde, wäre eine Zuweisung ansonsten gar nicht möglich.
Der Default-Zuweisungsoperator würde auch die ID zuweisen, was aber ein Fehler ist.
Bei einer Klasse zur Personenverwaltung kann dies z.B. dazu verwendet werden, einen klassen-
spezifischen Aufzählungstyp (siehe Abschnitt 9.6.2) für die Anrede zu definieren.
Die Klasse zur Personenverwaltung hätte dann z.B. folgende Spezifikation:
// dyna/person4.hpp
#ifndef PERSON_HPP
#define PERSON_HPP
// Headerdateien einbinden
#include <string>
class Person {
/* statische Klassenkomponenten
*/
private:
static long personenMaxID; // höchste 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ählungstyp für die Anrede
enum Anrede { herr, frau, firma, leer };
// nicht-statische Klassenkomponenten
private:
Anrede anr; // neu: Anrede (kann auch leer sein)
std::string vname; // Vorname der Person
std::string nname; // Nachname der Person
const long pid; // 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
// Destruktor
~Person ();
// Zuweisung
Person& operator = (const Person&);
// 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);
}
...
};
#endif // PERSON_HPP
Der Aufzählungstyp Anrede wird einfach in der Klassendeklaration definiert und auch verwen-
det. Dies hat den Vorteil, dass für die verwendeten Symbole Anrede, herr, frau, firma und
leer keine globalen Namenskonflikte drohen. Die öffentliche Deklaration ermöglicht es aber
dennoch, diese Symbole außerhalb der Klasse anzusprechen. Dies geschieht mit dem Bereichs-
operator, wie das folgende Anwendungsbeispiel zeigt:
// dyna/ptest4.cpp
#include <iostream>
#include "person.hpp"
Sandini Bib
int main ()
{
Bsp::Person nico("Josuttis","Nicolai");
class BspKlasse::Hilfsklasse {
private:
int x;
public:
void f();
...
};
6.5.5 Zusammenfassung
Klassen können statische Komponenten besitzen. Dabei handelt es sich um Objekte, die pro
Klasse nur einmal existieren und von allen Objekten der Klasse gemeinsam verwendet wer-
den. Sie entsprechen globalen Objekten, die auf den Namens- und Gültigkeitsbereich einer
Klasse beschränkt sind.
Statische Klassenkomponenten müssen außerhalb der Klassendeklaration genau einmal de-
finiert und gegebenenfalls initialisiert werden.
Statische Elementfunktionen erlauben es, auf statische Komponenten zuzugreifen, ohne dass
ein dazugehöriges Objekt der Klasse angegeben werden muss. Sie entsprechen globalen
Funktionen, die auf den Namens- und Gültigkeitsbereich einer Klasse beschränkt sind.
Mit Aufzählungstypen lassen sich statische Klassenkonstanten definieren, die auch zur De-
klaration anderer Komponenten verwendet werden können.
Klassen bilden einen eigenen Gültigkeitsbereich, in dem es möglich ist, eigene Datentypen
wie Aufzählungstypen oder Hilfsklassen (eingebettete Klassen) zu definieren.
Für Objekt-IDs müssen alle Konstruktoren (auch der Copy-Konstruktor) und der Zuwei-
sungsoperator einer Klasse selbst definiert werden. Die Komponente für die ID sollte als
Konstante deklariert werden.
Für Objektzähler müssen 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ür verschie-
dene 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ür beliebige Objekte; steht der Datentyp
der Parameter bzw. Elemente fest, findet eine vollständige Typprüfung für alle Operationen statt.
In diesem Kapitel werden zunächst Funktionstemplates vorgestellt. Es folgen Klassentem-
plates. Abgerundet wird das Kapitel durch Hinweise zum Umgang mit Templates in der Praxis
und spezielle Design-Techniken in Verbindung mit Templates.
431
Sandini Bib
wohl der Algorithmus eigentlich jedes Mal gleich ist. Dies kostet nicht nur unnötig Zeit, sondern
ist auch eine permanente Fehlerquelle.
C++ besitzt aus diesem Grund das Sprachmittel der Templates (deutsch: Schablonen). Tem-
plates sind Funktionen und Klassen, die nicht für einen bestimmten Typ, sondern für einen noch
festzulegenden Typ implementiert werden. Im Anwendungsprogramm kann dann eine solche
Implementierung für einen bestimmten Datentyp, für den die Funktion oder Klasse gebraucht
wird, realisiert werden. Auf diese Weise muss eine Maximum-Funktion für zwei Objekte mit be-
liebigem Typ nur einmal exemplarisch implementiert werden. Containerklassen wie Stacks, ver-
kettete Listen usw. müssen ebenfalls nur einmal implementiert und getestet werden und können
dann für jeden beliebigen Datentyp verwendet werden.
Die Definition und Anwendung von Templates wird im Folgenden zunächst für Funktionen
am Beispiel der Maximum-Funktion und dann für Klassen am Beispiel der Klasse Stack vorge-
stellt.
7.1.1 Terminologie
Auch beim Thema Templates sind die Begriffe nicht klar definiert. So wird z.B. bei einer Funk-
tion, bei der Datentypen parametrisiert sind, mal von einem Funktionstemplate (englisch: functi-
on 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 Funktions-
template verwenden. Analog sollte man bei einer Klasse, für 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ächlich zu übersetzenden
Code generiert. Diese Namensgebung ist insofern unglücklich, da dieser Begriff im objektorien-
tierten Umfeld für das Anlegen von Objekten einer Klasse verwendet wird. Im Umfeld von C++
hängt die Bedeutung des Wortes instantiieren also immer vom genauen Kontext ab.
Sandini Bib
7.2 Funktionstemplates
Wie bereits in der Einführung zu diesem Abschnitt erwähnt wurde, dienen Funktionstemplates
dazu, mehrere Funktionen, die sich nur im Datentyp ihrer Parameter unterscheiden, nur einmal
zu definieren und sie dann für verschiedene Datentypen zu verwenden.
Im Unterschied zu Makros sind Funktionstemplates aber kein blinder“ Textersatz, sondern
”
Funktionen, die semantisch geprüft und ohne unerwünschte 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.
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; // max() für zwei ints
std::cout << max(s,t) << std::endl; // max() für zwei Strings
}
In dem Moment, in dem die Funktion max() für zwei Objekte eines Typs aufgerufen wird, wird
die Schablone zur Realität. Das bedeutet, der Compiler verwendet die Template-Definition und
realisiert sie, indem er für T jeweils int bzw. std::string einsetzt und entsprechenden Code
erzeugt. Ein Template wird also nicht als Code übersetzt, der mit beliebigen Typen umgehen
kann, sondern nur als Schablone intern festgehalten, die dazu dient, im Anwendungsfall für die
entsprechenden Typen jeweils den entsprechenden Code zu realisieren. Wird max() für sieben
verschiedene Typen aufgerufen, werden also auch sieben Funktionen kompiliert.
Den Vorgang, bei dem der zu übersetzende Code aus dem Template-Code generiert wird,
nennt man auch Instantiierung. Diese Namensgebung ist insofern unglücklich, da dieser Begriff
im objektorientierten Umfeld für das Anlegen von Objekten einer Klasse verwendet wird. Ich
werde diesen Vorgang deshalb im weiteren Verlauf des Buches auch weiterhin mitunter als Rea-
lisierung bezeichnen.
Aufruf und Realisierung sind nur möglich, wenn alle in dem Funktionstemplate angewende-
ten Operationen für den Datentyp der Parameter auch definiert sind. Um die Maximum-Funktion
für Objekte der Klasse std::string aufrufen zu können, muss für Strings also der Vergleichs-
operator < 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ücke geliefert, und jeder Ausdruck wird nur einmal ausgewertet
(x also z.B. nur einmal inkrementiert).
Sandini Bib
Explizite Qualifizierung
Man kann beim Aufruf von Templates aber durch eine explizite Qualifizierung festlegen, für
welchen Datentyp ein Template verwendet werden soll:
max<long>(i,l) // OK, ruft max() mit long als T auf
In diesem Fall wird das Funktionstemplate max<>() für den Typ long als T realisiert. Nun wird
wie bei herkömmlichen Funktionen nur noch geprüft, ob die übergebenen Argumente als long
verwendet werden können, was durch eine implizite Typumwandlung möglich ist.
Ein Überladen wäre in diesem Fall auch für Zeiger im Allgemeinen möglich. Auf diese Weise
könnte man festlegen, dass beim Aufruf von max() für Zeiger nicht die Zeiger selbst (also ihre
Adressen) verglichen werden, sondern die Werte der Objekte, auf die sie verweisen. Dies sähe
wie folgt aus:
template <typename T>
T* const & max (T* const & a, T* const & b)
{
return (*a > *b ? a : b);
}
Man beachte, dass zur Übergabe 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 überladene Funktionen:
// tmpl/max2.hpp
#include <iostream>
#include <cstring>
#include <string>
#include "max.hpp"
int main ()
{
int a=7; // zwei Variablen vom Datentyp int
int b=11;
std::cout << max(a,b) << std::endl; // max() für zwei ints
Allerdings haben einige Compiler noch das Problem, dass sie beim Aufruf von max() mit zwei
Zeigern diese Version und die Version für const T& als gleichwertig ansehen und eine Mehr-
deutigkeit melden. Dies ist aber ein Fehler der Compiler.
7.2.7 Zusammenfassung
Templates sind Schablonen für Code, der nach der Festlegung der tatsächlichen Datentypen
übersetzt wird.
Das Generieren von übersetzbarem Code aus Template-Code nennt man in C++ instantiieren.
Templates dürfen mehrere Template-Parameter besitzen.
Funktionstemplates dürfen überladen werden.
1
Vgl. die Implementierung der Funktion swap() auf Seite 191
Sandini Bib
7.3 Klassentemplates
Genauso wie Typen von Funktionen parametrisiert werden können, ist es auch möglich, einen
oder mehrere Typen in Klassen zu parametrisieren. Dies ist vor allem bei Containerklassen sinn-
voll, die dazu dienen, andere Objekte zu verwalten. Klassentemplates bieten die Möglichkeit,
diese zu implementieren, obwohl der Typ der verwalteten Objekte noch nicht feststeht. Typische
Beispiele für Klassentemplates sind Klassen zur Mengenverwaltung, Stacks, verkettete Listen
und so weiter. Im Rahmen der objektorientierten Modellierung werden Templates auch als para-
metrisierbare Klasse bezeichnet.
Nachfolgend wird die Implementierung eines Klassentemplates am Beispiel einer Klasse für
Stacks betrachtet. Diese verwendet intern ein Klassentemplate der Standardbibliothek, vector<>
(siehe Abschnitt 3.5.1 und Abschnitt 9.1.1).
public:
Stack(); // Konstruktor
void push(const T&); // Element einkellern
T pop(); // Element auskellern
T top() const; // oberstes Element
};
// Konstruktor
template <typename T>
Stack<T>::Stack ()
{
// nichts mehr zu tun
}
Sandini Bib
template<typename T>
T Stack<T>::pop ()
{
if (elems.empty()) {
throw "Stack<>::pop(): der Stack ist leer";
}
T elem = elems.back(); // oberstes Element merken
elems.pop_back(); // oberstes Element auskellern
return elem; // gemerktes oberstes Element zurückliefern
}
In der Klasse kann dann der Typ T wie jeder andere Datentyp zur Deklaration von Klassenkom-
ponenten und Elementfunktionen verwendet werden. Im vorliegenden Beispiel wird festgelegt,
dass die Elemente des Stacks intern mit Hilfe eines Vektors für 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ück.
Der Datentyp der Klasse ist Stack<T>, wobei T ein Template-Parameter ist. Deshalb muss
dieser Datentyp auch ü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ückgabewerten besteht diese Eindeutigkeit nicht. Sollten diese angege-
ben werden müssen, sähe dies am Beispiel der Deklaration von Copy-Konstruktor und Zuwei-
sungsoperator wie folgt aus:2
template <typename T>
class Stack {
...
Stack (const Stack<T>&); // Copy-Konstruktor
Stack<T>& operator= (const Stack<T>&); // Zuweisungsoperator
...
};
2
Es gibt im Standard allerdings einige Regelungen, die festlegen, wo in einer Klassendeklaration die Anga-
be von Stack statt Stack<T> reicht. Wer wie ich sicher gehen will, verwendet Stack<T> im Zweifelsfall
überall, wo der Datentyp der Klasse benötigt wird.
Sandini Bib
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ücklie-
fert. Insofern muss dieses Element vor dem Entfernen zwischengespeichert werden:
template<typename T>
T Stack<T>::pop ()
{
if (elems.empty()) {
throw "Stack<>::pop(): der Stack ist leer";
}
T elem = elems.back(); // oberstes Element merken
elems.pop_back(); // oberstes Element auskellern
return elem; // gemerktes oberstes Element zurückliefern
}
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ält (siehe Seite 523 und Seite 525). Insofern wird dies bei beiden Funktionen
geprüft, und gegebenenfalls eine Ausnahme vom Typ const char* ausgelöst:
template<typename T>
T Stack<T>::top () const
{
if (elems.empty()) {
throw "Stack<>::top(): der Stack ist leer";
}
return elems.back(); // oberstes Element als Kopie zurückliefern
}
Selbstverständlich könnten die Funktionen auch innerhalb der Klassendeklaration implementiert
werden:
template <typename T>
class Stack {
...
void push (const T& elem) {
elems.push_back(elem); // Kopie einkellern
}
...
};
Sandini Bib
int main()
{
try {
Bsp::Stack<int> intStack; // Stack für Integer
Bsp::Stack<std::string> stringStack; // Stack für 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ür den entsprechenden Datentyp realisiert.
Mit der Verwendung von stringStack wird für die Klasse und alle aufgerufenen Elementfunk-
tionen entsprechender Code für den Typ std::string generiert.
Die Betonung liegt dabei auf alle aufgerufenen Elementfunktionen“. Bei Klassentemplates
”
gilt die Regel, dass nur für 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ür Datentypen verwenden, die gar nicht alle Fähigkeiten aller Elementfunktionen haben,
solange diese nicht wirklich benötigt werden. Ein Beispiel wäre eine Klasse, in der manche
Elementfunktionen mit dem Operator < sortieren. Solange diese Elementfunktionen nicht aufge-
Sandini Bib
rufen werden, muss der als Template-Parameter übergebene Datentyp auch nicht den Operator <
definiert haben.
Insgesamt wird in diesem Fall also für zwei Klassen Code generiert. Besitzt ein Klassentem-
plate statische Komponenten, werden entsprechend auch zwei verschiedene statische Kompo-
nenten angelegt.
Ein Klassentemplate bildet für jeden eingesetzten Datentyp einen eigenen Typ, der überall
verwendet werden kann:
void foo (const Bsp::Stack<int>& s) // Parameter s ist int-Stack
{
Bsp::Stack<int> istack[10]; // istack ist ein Feld von zehn int-Stacks
...
}
Dabei wird in der Praxis häufig eine Typedef-Anweisung verwendet, um die Anwendung von
Klassentemplates lesbarer (und flexibler) zu halten:
typedef Bsp::Stack<int> IntStack;
Für 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)
{
elems.push_back(elem); // Kopie einkellern
}
Hier ist ein vollständiges Beispiel einer Spezialisierung des Klassentemplates Stack<> für den
Datentyp std::string:
// tmpl/stack2.hpp
#include <deque>
#include <string>
template<>
class Stack<std::string> {
private:
std::deque<std::string> elems; // Elemente
public:
Stack() { // Konstruktor
}
void push(const std::string&); // Element einkellern
std::string pop(); // Element auskellern
std::string top() const; // oberstes Element
};
template<>
void Stack<std::string>::push (const std::string& elem)
{
elems.push_back(elem); // Kopie einkellern
}
Sandini Bib
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ückliefern
}
template<>
std::string Stack<std::string>::top () const
{
if (elems.empty()) {
throw "Stack<std::string>::top(): der Stack ist leer";
}
return elems.back(); // oberstes Element als Kopie zurückliefern
}
Partielle Spezialisierungen
Man kann Templates auch nur teilweise spezialisieren (partiell spezialisieren).
Zum Klassentemplate
template <typename T1, typename T2>
class MeineKlasse {
...
};
kann es z.B. folgende partielle Spezialisierungen geben:
// partielle Spezialisierung: beide Typen sind gleich
template <typename T>
class MeineKlasse<T,T> {
...
};
Sandini Bib
public:
Stack(); // Konstruktor
void push(const T&); // Element einkellern
T pop(); // Element auskellern
T top() const; // oberstes Element
};
// Konstruktor
template <typename T, typename CONT>
Stack<T,CONT>::Stack ()
{
// nichts mehr zu tun
}
}
return elems.back(); // oberstes Element als Kopie zurückliefern
}
int main()
{
try {
Bsp::Stack<int> intStack; // Stack für Integer
Bsp::Stack<double,std::deque<double> >
dblStack; // Stack für Gleitkommawerte
// Integer-Stack manipulieren
intStack.push(7);
std::cout << intStack.pop() << std::endl;
// Gleitkommawerte-Stack manipulieren
dblStack.push(42.42);
std::cout << dblStack.pop() << std::endl;
std::cout << dblStack.pop() << std::endl;
}
catch (const char* msg) {
std::cerr << "Exception: " << msg << std::endl;
return EXIT_FAILURE;
}
}
Mit
Bsp::Stack<double,std::deque<double> >
wird z.B. ein Stack für Gleitkommawerte deklariert, der intern als Container eine Deque verwen-
det.
Sandini Bib
7.3.5 Zusammenfassung
Auch Klassen können als Templates für noch nicht festgelegte Datentypen implementiert
werden.
Mit Klassentemplates können insbesondere Containerklassen, die andere Objekte verwalten,
in Hinblick auf den Typ der verwalteten Objekte parametrisiert werden.
Für Klassentemplates werden nur die Funktionen instantiiert, die auch benötigt werden.
Implementierungen von Klassentemplates können für bestimmte Datentypen spezialisiert
werden. Dabei ist auch eine partielle Spezialisierung möglich.
Für Template-Parameter ist es möglich, Default-Werte anzugeben.
Sandini Bib
public:
Stack(); // Konstruktor
void push(const T&); // Element einkellern
T pop(); // Element auskellern
T top() const; // oberstes Element
};
// Konstruktor
template <typename T, int MAXSIZE>
Stack<T,MAXSIZE>::Stack ()
: numElems(0) // kein Elemente
{
// nichts mehr zu tun
}
int main()
{
try {
Sandini Bib
// Integer-Stack manipulieren
int20Stack.push(7);
std::cout << int20Stack.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;
}
}
Zu beachten ist, dass int20Stack und int40Stack in diesem Fall verschiedene Typen besitzen
und folglich einander nicht zugewiesen oder wechselseitig verwendet werden können.
Selbstverständlich könnte man auch in diesem Fall für die Template-Parameter Default-Werte
festlegen:
template <typename T = int, int MAXSIZE = 100>
class Stack {
...
};
Ich halte dies in diesem Fall jedoch für nicht sinnvoll. Default-Werte sollten intuitiv richtig sein.
Der Datentyp int als Elementtyp und eine Maximalgröße von 100 sind aber jeweils nicht intuitiv.
Insofern ist es besser, wenn der Anwendungsprogrammierer beides sichtbar explizit festlegen
muss.
void foo() {
KLASSE<int,s> x; // OK
...
}
Das globale Feld s wird mit der Zeichenfolge "hallo" initialisiert, wodurch s die globale Zei-
chenfolge "hallo" ist.
7.4.3 Zusammenfassung
Templates können auch Parameter besitzen, die keine Datentypen, sondern Werte sind.
Diese Werte dürfen keine Objekte und nicht lokal sein.
Damit ist die Verwendung von String-Literalen als Template-Argumente nur eingeschränkt
möglich.
Sandini Bib
public:
Stack(); // Konstruktor
void push(const T&); // Element einkellern
T pop(); // Element auskellern
T top() const; // oberstes Element
Wer meint, durch diese Funktion sei die Typprüfung ausgeschaltet worden, der irrt. Man kann
nach wie vor keine Stacks einander zuweisen, deren Elementtypen nicht einander zuweisbar sind.
Wird z.B. versucht, einen Stack von Strings einem Stack von ints zuzuweisen, wird für die Zeile
elems.push_front(tmp.pop()); // Kopie jeweils vorn einfügen
eine Fehlermeldung erzeugt, da tmp.pop() einen String liefert, den man nicht als int verwen-
den kann.
Man beachte auch, dass eine Template-Version eines Zuweisungsoperators nicht den Default-
Zuweisungsoperator überdeckt. Der Default-Zuweisungsoperator wird nach wie vor generiert
und bei einer Zuweisung zwischen zwei Stacks mit dem gleichen Elementtyp auch aufgerufen.
Man kann die Implementierung auch hier so verändern, dass als Elemente-Container auch
ein Vektor verwendet werden kann. Dazu muss die Deklaration wie folgt aussehen:
template <typename T, typename CONT = std::deque<T> >
class Stack {
private:
CONT elems; // Elemente
public:
Stack(); // Konstruktor
void push(const T&); // Element einkellern
T pop(); // Element auskellern
T top() const; // oberstes Element
Solange nicht versucht wird, einen Stack mit Elementen eines anderen Datentyps zuzuweisen,
handelt es sich um ein korrektes Programm.
Das komplette Beispiel befindet sich in den beiden Dateien tmpl/stack5.hpp und
tmpl/stest5.cpp. Wundern Sie sich nicht, wenn Ihr Compiler Fehler meldet. Wir nutzen hier
fast alle wichtigen Sprachmittel von Templates aus.
Dynamische Polymorphie
Verwendet man die Vererbung zur Implementierung von Polymorphie, dann definiert eine (ab-
strakte) Basisklasse die gemeinsame Schnittstelle (den Oberbegriff) zu einer Reihe von konkreten
Datentypen.
Hat man z.B. eine Gruppe von geometrischen Klassen, könnte es (wie in Abschnitt 5.3.3
vorgestellt) eine abstrakte Basisklasse GeoObj geben, von der alle möglichen konkreten Klassen
abgeleitet sind (siehe Abbildung 7.1).
G e o O b j
d r a w ( )
p o s i t i o n ( )
. . .
P u n k t L i n i e K r e i s
d r a w ( ) d r a w ( ) d r a w ( )
p o s i t i o n ( ) p o s i t i o n ( ) p o s i t i o n ( )
. . . . . . . . .
Um mit dem gemeinsamen Oberbegriff zu programmieren, werden dann einfach Referenzen oder
Zeiger auf Objekte der Basisklasse verwendet. Dies mag z.B. wie folgt aussehen:
// tmpl/dynapoly.cpp
// beliebiges geometrisches Objekt zeichnen
void zeichne (const GeoObj& obj) {
obj.draw();
}
Sandini Bib
int main()
{
Linie l;
Kreis k, k1, k2;
abstand(k1,k2); // abstand(GeoObj&,GeoObj&)
abstand(l,k); // abstand(GeoObj&,GeoObj&)
Statische Polymorphie
Man kann statt der Vererbung aber auch Templates für den Einsatz von Polymorphie verwenden.
In diesem Fall gibt es keine Basisklasse, die die gemeinsamen Eigenschaften explizit festlegt.
Stattdessen werden die notwendigen Eigenschaften implizit als Operationen eines entsprechen-
den Template-Parameters festgelegt.
Sandini Bib
int main()
{
Linie l;
Kreis k;
Kreis k1, k2;
abstand(k1,k2); // abstand<Kreis,Kreis>(GeoObj&,GeoObj&)
abstand(l,k); // abstand<Linie,Kreis>(GeoObj&,GeoObj&)
Bei den ersten beiden Funktionen, zeichne() und abstand(), wurde der Datentyp GeoObj zu
einem Template-Parameter. Dabei wurde in abstand() durch die Verwendung von zwei ver-
schiedenen Template-Parametern auch die Übergabe zweier verschiedener Arten von geometri-
schen Objekten ermöglicht:
abstand(l,c); // abstand<Linie,Kreis>(GeoObj&,GeoObj&)
Eine inhomogene Menge ist mit Templates nicht mehr möglich. Allerdings muss dafür der Da-
tentyp der Elemente in der Menge nicht mehr als Zeiger deklariert werden:
std::vector<Linie> menge; // OK: homogene Menge
Dies kann signifikante Vorteile haben.
Sie ist ungebunden (Klassen sind von keinem anderen Code abhängig). Damit ist insbeson-
dere auch die Verwendung von fundamentalen Datentypen möglich.
Sie vermeidet die Verwendung von Zeigern.
Konkrete Datentypen müssen nicht die gesamte Schnittstelle bereitstellen (Templates brau-
chen nur die Operationen, die auch aufgerufen werden).
In Hinsicht auf Typsicherheit haben beide Formen ebenfalls Vor- und Nachteile: Bei dynami-
scher Polymorphie muss man den Oberbegriff explizit angeben. Damit kann es nicht passieren,
dass eine Klasse als geometrisches Objekt verwendet werden kann, nur weil sie die entsprechen-
den Operationen anbietet. Auf der anderen Seite wird die Typsicherheit von homogenen Mengen
bei dynamischer Polymorphie nicht gewährleistet. Um sicherzustellen, dass sich in einer Men-
ge geometrischer Objekte nur Linien befinden, müssen entsprechende Abfragen programmiert
werden, die zur Laufzeit stattfinden.
Beide Arten haben also ihre Vor- und Nachteile. Aufgrund des möglichen besseren Laufzeit-
verhaltens sollte man in der Praxis aber darüber nachdenken, statische Polymorphie zu verwen-
den, wenn Parameter zur Kompilierzeit bekannt sind und keine inhomogenen Mengen benötigt
werden.
7.5.4 Zusammenfassung
Wird bei der Verwendung eines Template-Parameters auf einen dazu definierten Hilfstyp zu-
gegriffen, muss dieser mit typename qualifiziert werden.
Auch innere Klassen und Elementfunktionen können Templates sein. Damit kann man auch
bei Operationen von Klassentemplates implizite Typumwandlungen ermöglichen. Eine Typ-
prüfung findet aber nach wie vor statt.
Template-Versionen des Zuweisungsoperators überdecken nicht den Default-Zuweisungs-
operator.
Mit Templates kann man Polymorphie implementieren. Dies hat Vor- und Nachteile.
Sandini Bib
Explizite Instantiierung
Eine portable Möglichkeit, die mehrfache Übersetzung des gleichen Template-Codes zu vermei-
den, bietet die Technik der expliziten Instantiierung oder expliziten Generierung.
In diesem Fall werden die Templates in Headerdateien wirklich nur noch deklariert:
// tmpl/expl1.hpp
// Deklaration des Funktions-Templates max()
template <typename T>
const T& max (const T& a, const T& b);
// Konstruktor
template <typename T>
Stack<T>::Stack ()
{
// nichts mehr zu tun
}
template<typename T>
T Stack<T>::pop ()
{
if (elems.empty()) {
throw "Stack<>::pop(): der Stack ist leer";
}
T elem = elems.back(); // oberstes Element merken
elems.pop_back(); // oberstes Element auskellern
return elem; // gemerktes oberstes Element zurückliefern
}
int main()
{
try {
Bsp::Stack<int> intStack; // Stack für Integer
Bsp::Stack<std::string> stringStack; // Stack für Strings
// Integer-Stack manipulieren
intStack.push(7);
intStack.push(max(intStack.top(),42)); // max() für ints
Sandini Bib
// 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;
}
}
Die benötigten Instantiierungen kann man separat explizit generieren:
// tmpl/expldef1.cpp
#include <string>
#include "expldef.hpp"
m a x . h p p :
# i f n d e f M A X _ H P P
# d e f i n e M A X _ H P P
t e m p l a t e < t y p e n a m e T >
c o n s t T & m a x ( c o n s t T & , c o n s t T & ) ;
# e n d i f
m a x d e f . h p p :
# i f n d e f M A X D E F _ H P P
# d e f i n e M A X D E F _ H P P
# i n c l u d e " m a x . h p p "
t e m p l a t e < t y p e n a m e T >
c o n s t T & m a x ( c o n s t T & a , c o n s t T & b )
{
r e t u r n a > b ? a : b ;
}
# e n d i f
m a x t e s t . c p p :
# i n c l u d e " m a x . h p p "
i n t f o o ( i n t x , i n t y )
{
. . .
z = m a x ( x , y ) ;
. . .
}
m a x d e f . c p p :
# i n c l u d e " m a x d e f . h p p "
Durch die Vorgehensweise der expliziten Instantiierung kann mitunter signifikant Zeit gespart
werden. Es lohnt sich also, Template-Code auf zwei getrennte Headerdateien (eine für die De-
klaration und eine für die Definition) aufzuteilen. Will man nicht explizit instantiieren, muss man
im Anwendungsprogramm einfach nur statt der Headerdatei mit den Deklarationen die Header-
datei mit den Definitionen einbinden. Insofern schafft dieser Ansatz erheblich mehr Flexibilität,
ohne dass man signifikante Nachteile in Kauf nehmen muss.
Will man den Vorteil von Inline-Funktionen beibehalten, muss man diese wie bei anderen
Klassen dann auch in die Headerdatei mit den Deklarationen implementieren.
Template-Compilation-Modell
Eine andere Möglichkeit zur Behandlung von Templates wurde im Standard mit Hilfe des Schlüs-
selwortes export als so genanntes Template-Compilation-Modell definiert. Diese Möglichkeit
legt fest, dass ein Template durch export qualifiziert werden kann. Damit wird es automatisch
in eine Template-Datenbank bzw. in ein Template-Repository exportiert.
Wird nun ein derartiges Template verwendet, wird mit Hilfe dieses Repositorys geklärt, ob
das Template für die jeweiligen Datentypen schon übersetzt wurde, und, falls das nicht der Fall
ist, wird es automatisch übersetzt und zum laufenden Programm dazugebunden.
Wie dies genau aussieht, ist implementierungsspezifisch. Außerdem gibt es bisher noch so
gut wie keinen Compiler, der dieses Sprachmittel unterstützt. Insofern ist diese Möglichkeit in
der Praxis derzeit leider (noch) nicht anwendbar.
7.6.2 Fehlerbehandlung
Templates schaffen auch Probleme bei der Fehlerbehandlung. Das Hauptproblem liegt in der Tat-
sache, dass Compiler Typfehler erst beim Übersetzen des Templates für bestimmte Datentypen
erkennen. Dann melden sie aber wie üblich die Stelle, an der der Fehler erkannt wurde. Mit einer
derartigen Fehlermeldung kann man nur mit viel Glück etwas anfangen. In kommerziellem Code
sind die durch Templates vorgenommenen Ersetzungen und Ergänzungen viel zu kompliziert,
um nachvollziehbar zu sein.
Die folgenden Anweisungen aus der STL enthalten zum Beispiel den Fehler, dass im Such-
kriterium als Parameter für das Template greater<> fälschlicherweise int statt std::string
übergeben wurde:
std::list<std::string> menge;
...
// erstes Element größer als "A" finden
std::list<std::string>::iterator pos;
pos = find_if (menge.begin(),menge.end(), // Bereich
std::bind2nd(std::greater<int>(),"A")); // Suchkriterium
Dies kann z.B. folgende Fehlermeldung auslösen:
/local/include/stl/_algo.h: In function `struct _STL::_List_iterator<_STL::basic
_string<char,_STL::char_traits<char>,_STL::allocator<char> >,_STL::_Nonconst_tra
its<_STL::basic_string<char,_STL::char_traits<char>,_STL::allocator<char> > > >
_STL::find_if<_STL::_List_iterator<_STL::basic_string<char,_STL::char_traits<cha
Sandini Bib
r>,_STL::allocator<char> >,_STL::_Nonconst_traits<_STL::basic_string<char,_STL::
char_traits<char>,_STL::allocator<char> > > >, _STL::binder2nd<_STL::greater<int
> > >(_STL::_List_iterator<_STL::basic_string<char,_STL::char_traits<char>,_STL:
:allocator<char> >,_STL::_Nonconst_traits<_STL::basic_string<char,_STL::char_tra
its<char>,_STL::allocator<char> > > >, _STL::_List_iterator<_STL::basic_string<c
har,_STL::char_traits<char>,_STL::allocator<char> >,_STL::_Nonconst_traits<_STL:
:basic_string<char,_STL::char_traits<char>,_STL::allocator<char> > > >, _STL::bi
nder2nd<_STL::greater<int> >, _STL::input_iterator_tag)':
/local/include/stl/_algo.h:115: instantiated from `_STL::find_if<_STL::_List_i
terator<_STL::basic_string<char,_STL::char_traits<char>,_STL::allocator<char> >,
_STL::_Nonconst_traits<_STL::basic_string<char,_STL::char_traits<char>,_STL::all
ocator<char> > > >, _STL::binder2nd<_STL::greater<int> > >(_STL::_List_iterator<
_STL::basic_string<char,_STL::char_traits<char>,_STL::allocator<char> >,_STL::_N
onconst_traits<_STL::basic_string<char,_STL::char_traits<char>,_STL::allocator<c
har> > > >, _STL::_List_iterator<_STL::basic_string<char,_STL::char_traits<char>
,_STL::allocator<char> >,_STL::_Nonconst_traits<_STL::basic_string<char,_STL::ch
ar_traits<char>,_STL::allocator<char> > > >, _STL::binder2nd<_STL::greater<int>
>)'
testprog.cpp:18: instantiated from here
/local/include/stl/_algo.h:78: no match for call to `(_STL::binder2nd<_STL::grea
ter<int> >) (_STL::basic_string<char,_STL::char_traits<char>,_STL::allocator<cha
r> > &)'
/local/include/stl/_function.h:261: candidates are: bool _STL::binder2nd<_STL::g
reater<int> >::operator ()(const int &) const
Es handelt sich in der Tat nur um eine Fehlermeldung, die besagt, dass es
in einer Funktion in /local/include/stl/_algo.h
– instantiiert von Zeile 115 in /local/include/stl/_algo.h
– instantiiert von Zeile 18 in testprog.cpp
in Zeile 78 keine passende Funktion zum Aufruf von etwas gibt,
für das sich in Zeile 261 von /local/include/stl/_function.h ein Kandidat befindet,
der möglicherweise gemeint ist.
Wichtig ist zunächst einmal, dass Compiler mit dem Fehler auch die Stelle im Anwendungscode
melden, die zu diesem Fehler geführt hat. Bei der vorliegenden Fehlermeldung ist das z.B. in der
Zeile
testprog.cpp:18: instantiated from here
der Fall (wie man sieht, kann schon die Suche dieser Zeile ein Problem sein).3
Nun muss man nur noch das Problem verstehen. Mit viel Übung und Hintergrundwissen
mag man darauf kommen, dass irgendetwas mit basic_string gesucht wird, aber nur etwas
mit int vorhanden ist. Wen man nicht darauf kommt, bleibt immerhin die Möglichkeit, sich die
3
Leider gibt es immer noch C++-Compiler, die noch nicht einmal die Information liefern, welche eigene
Zeile das Problem verursacht hat. Beschweren Sie sich! Es gibt auch vorbildliche Compiler, die nur eine
Fehlermeldung zur betroffenen Zeile im eigenen Code ausgeben.
Sandini Bib
auslösende Stelle im eigenen Code noch einmal genau anzusehen und hoffentlich auf die Lösung
zu kommen, dass int in der Zeile einfach durch std::string ersetzt werden muss.
Die beispielhafte Fehlermeldung zeigt auch noch ein anderes Problem auf: Templates können
selbst zu Warnungen oder Fehlern führen. Dies liegt daran, dass durch die massiven Ersetzun-
gen sehr lange interne Symbolnamen erzeugt werden. Symbole mit bis zu 10.000 Zeichen sind
möglich. Nicht alle Compiler und Linker verkraften derart lange Symbole. Manche geben auch
einfach nur eine Warnung aus, die man dann getrost ignorieren kann.
7.6.3 Zusammenfassung
Templates sprengen das herkömmliche Compiler/Linker-Modell.
Templates können explizit instantiiert (realisiert) werden.
Durch eine Aufteilung von Templates auf Headerdateien für die Deklaration und Headerda-
teien für die Definition kann man Templates in der Praxis flexibler handhaben.
Für Templates existiert ein Compilation-Modell, das in der Praxis leider noch nicht verwendet
werden kann.
Fehlermeldungen von Templates sind ein Problem. Wichtig ist es, die auslösende Stelle im
eigenen Code zu finden und dort das Problem zu erkennen.
Templates können zu sehr langen Symbolen führen.
Sandini Bib
Kapitel 8
Die Standard-I/O-Bibliothek im
Detail
Nachdem bereits in den Abschnitten 3.1.4 und 4.5 die Standardtechniken zur Ein- und Ausgabe
mit Stream-Klassen eingeführt wurden, wird in diesem Abschnitt auf Details der Standard-I/O-
Bibliothek eingegangen. Damit bekommt man langsam einen Eindruck von dem vollen Umfang
der Möglichkeiten, die diese Bibliothek bietet.
Zu diesem Thema gehören auch die Möglichkeiten, auf Dateien und auf Strings formatierend
zuzugreifen.
Alle Möglichkeiten der I/O-Bibliothek vorzustellen, würde den Rahmen dieses Buches spren-
gen. Hier konzentriere ich mich auf die Details, die für die tägliche Arbeit bei der Ein- und
Ausgabe wichtig sind. Für weitergehende Betrachtungen sei auf spezielle Bücher zur Standard-
bibliothek oder zur I/O-Bibliothek verwiesen (z.B. mein Buch The C++ Standard Library oder
das Buch Standard C++ IOStreams and Locales von Langer und Kreft, siehe Literaturverzeich-
nis).
473
Sandini Bib
i o s _ b a s e b a s i c _ s t r e a m b u f < >
s t r e a m b u f / w s t r e a m b u f
b a s i c _ i o s < >
i o s / w i o s
{ v i r t u a l }
b a s i c _ i o s t r e a m < >
i o s t r e a m / w i o s t r e a m
1
Die Formatierung machen sie aber genau genommen auch nicht selbst, sondern sie delegieren sie zum
Teil an spezielle Klassen, die die Besonderheiten der Internationalisierung berücksichtigen.
Sandini Bib
Um Ein-/Ausgaben für spezielle Geräte zu ermöglichen, darf man also keine speziellen, von
basic_ios abgeleiteten Klassen implementieren, sondern muss eine spezielle Streambuffer-
Klasse implementieren und diese in den von basic_ios abgeleiteten Stream-Objekten verwen-
den.
Headerdateien
Die Definitionen der Stream-Klassen werden auf verschiedene Headerdateien aufgeteilt:
<iosfwd>
enthält Vorwärtsdeklarationen der Streamklassen.
<streambuf>
enthält die Definition der Basisklasse für Streambuffer, basic_streambuf<>.
<istream>
Enthält die Definitionen der Klassen, die nur Eingaben unterstützen (basic_istream<>)
und für die Klassen, die sowohl Ein- als auch Ausgaben unterstützen (basic_iostream<>).
<ostream>
Enthält die Definitionen der Klassen, die nur Ausgaben (basic_ostream<>) unterstützen.
<iostream>
Enthält die Definitionen der globalen Stream-Objekte wie cin und cout.
Diese Aufteilung mag etwas merkwürdig erscheinen. Es ist in der Tat ausreichend, bei der Ver-
wendung von Streams für Ein- und Ausgaben nur die Headerdatei <istream> einzubinden. Nur
wenn wirklich die globalen Stream-Objekte benötigt werden, muss <iostream> eingebunden
werden. Dieses Design hat historische Gründe. Bei der Verwendung der globalen Stream-Objekte
in einem Modul ist spezieller Code notwendig, der sicherstellt, dass diese Objekte auch initiali-
siert sind. Dieser Code wird über <iostream> eingebunden. Da dieser Code etwas Zeit kostet,
hat man sich entschieden, dass man auf diese Headerdatei verzichten kann, wenn man zwar
iostreams, nicht aber die globalen Objekte verwendet.
Sofern man also nur die Datentypen der Streamklassen in Deklarationen benötigt, sollte man
einfach nur <iosfwd> einbinden. Wenn die Operationen zum Einlesen oder Ausgeben benötigt
werden, sollte man <istream> bzw. <ostream> einbinden. Und nur wenn man die globalen
Stream-Objekte verwendet, sollte man <iostream> einbinden.
Für spezielle Stream-Features wie parametrisierte Manipulatoren, Dateizugriff oder String-
Streams existieren weitere Headerdateien (<iomanip>, <fstream>, <sstream> und
<strstream>). Auf diese wird bei der Vorstellung der entsprechenden Techniken noch einge-
gangen.
Zustandsbits
Die verschiedenen Bitkonstanten werden in Tabelle 8.2 aufgelistet. Sie werden über den Da-
tentyp iostate in der Klasse std::ios_base als Flags definiert und dort in einer internen
Komponente verwaltet.
Bitkonstante Bedeutung
goodbit alles in Ordnung, kein (anderes) Bit gesetzt
eofbit End-Of-File (Ende der Daten)
failbit Fehler: letzter Vorgang nicht korrekt abgeschlossen
badbit fataler Fehler: Zustand nicht definiert
Bei ios::goodbit von einem Flag zu reden ist etwas verwirrend, da es typischerweise den Wert
0 besitzt und damit eigentlich nur anzeigt, ob kein anderes Flag gesetzt ist.
failbit und badbit unterscheiden sich wie folgt:
failbit wird gesetzt, wenn ein Vorgang nicht korrekt durchgeführt werden konnte, der
Stream aber prinzipiell in Ordnung ist. Typisch dafür sind Formatfehler beim Einlesen. Wenn
z.B. ein Integer eingelesen werden soll, das nächste Zeichen aber ein Buchstabe ist, wird
dieses Flag gesetzt.
badbit wird gesetzt, wenn der Stream prinzipiell nicht mehr in Ordnung ist oder Zeichen
verlorengegangen sind. Dieses Flag wird z.B. beim Positionieren vor einen Dateianfang ge-
setzt.
Man beachte, dass eofbit normalerweise zusammen mit failbit gesetzt wird, da die Be-
dingung EOF (Ende der Daten) erst dann erkannt wird, wenn der Versuch, weiter einzulesen,
fehlschlägt.
Zusätzlich existiert auf manchen Systemen das Flag ios::hardfail. Dies ist allerdings
nicht standardisiert.
Da die Flags in der Klasse ios_base definiert werden, müssen sie entsprechend qualifiziert
werden:
std::ios_base::eofbit
Man kann sie aber auch über ios qualifizieren:
std::ios::eofbit
Dies ist möglich, da ios eine Realisierung der von ios_base abgeleiteten Klasse basic_ios<>
ist. Da es früher nur die Klasse ios gab, ist diese Art der Qualifizierung rückwärtskompatibel
und wird relativ häufig angetroffen.
Funktion Bedeutung
good() true, wenn alles in Ordnung (ios::goodbit gesetzt) ist
eof() true bei End-Of-File (ios::eofbit gesetzt)
fail() true bei Fehler (ios::failbit oder ios::badbit gesetzt)
bad() true bei fatalem Fehler (ios::badbit gesetzt)
rdstate() liefert die Kombination der gesetzten Flags zurück
clear() löscht alle Flags
clear(bits) löscht alle Flags und setzt bits als Zustand
setstate(bits) setzt zusätzlich bits als Zustand
Die ersten vier Elementfunktionen liefern als Booleschen Wert, ob bestimmte Flags gesetzt sind.
Man beachte, dass fail() liefert, ob failbit oder badbit gesetzt ist. Dies ist aus historischen
Gründen so und hat den Vorteil, dass ein Aufruf zum Test auf einen Fehlerfall reicht.
Wird clear() kein Parameter übergeben, werden alle Fehlerflags (auch ios::eofbit) ge-
löscht. Wird ein Parameter übergeben, werden die darin übergebenen Flags gesetzt. Auf Seite
211 befindet sich ein kleines Anwendungsbeispiel dazu.
Man beachte, dass clear() bei jeder fehlgeschlagenen Stream-Operation aufgerufen werden
muss. Dies ist anders als bei der I/O-Schnittstelle von C. Dort konnte man nach dem aufgrund
eines Formatfehlers fehlgeschlagenen Versuch, einen int einzulesen, sofort den Rest der Zeile
auslesen. In den Stream-Klassen wird in dem Fall das failbit gesetzt, das dafür sorgt, dass
jeder weitere Leseversuch so lange fehlschlägt, bis es explizit wieder gelöscht wird.
Schließlich werden für die Bewertung von Booleschen Ausdrücken in Kontrollstrukturen die
bereits in Abschnitt 4.5.2 vorgestellten Konvertierungsfunktionen definiert (siehe Tabelle 8.4).
Elementfunktion Bedeutung
operator void* () liefert zurück, ob der Stream in Ordnung ist
(entspricht dem Aufruf von !fail())
operator ! () liefert zurück, ob der Stream nicht in Ordnung ist
(entspricht dem Aufruf von fail())
Elementfunktion Bedeutung
exceptions() liefert die Bits, die automatisch Ausnahmen auslösen
exceptions(bits) setzt die Bits, die automatisch Ausnahmen auslösen
8.1.3 Standardoperatoren
Als Standardoperatoren für die Ein- und Ausgabe werden in den Klassen istream und ostream
die Operatoren >> und << definiert und für alle fundamentalen Datentypen einschließlich des
Typs char* überladen.
Der Operator << bietet auch die Möglichkeit, Zeiger auszugeben. Wird dem Operator als
Parameter der Typ void* übergeben, wird jeweils die Adresse des Zeigers in einer maschinen-
abhängigen Form ausgegeben. Die folgende Anweisung gibt z.B. den Inhalt eines Strings und
dessen Adresse aus:
const char* cstring = "hallo";
std::cout << "String: " << cstring << " steht an Adresse: "
<< static_cast<const void*>(cstring) << std::endl;
Dies kann z.B. zu folgender Ausgabe führen:
String: hallo steht an Adresse: 0x10000018
Man kann diese Adresse auch entsprechend mit dem Input-Operator wieder einlesen. Man be-
achte dabei aber, dass Adressen flüchtige Werte in einem Programm sind. Das gleiche Objekt
kann in einem neu gestarteten Programm eine völlig andere Adresse besitzen.
Sandini Bib
8.1.4 Standardfunktionen
Statt der Standardoperatoren für Streams, << und >>, können auch verschiedene Elementfunk-
tionen zum Einlesen oder Ausgeben verwendet werden. Diese Elementfunktionen werden im
Folgenden vorgestellt. Dabei wird die Semantik für die Realisierung der Stream-Klassen für
chars, also istream und ostream, erläutert. Besonderheiten, die aus der Tatsache resultieren,
dass es sich um Funktionen für basic_istream<> und basic_ostream<> handelt, werden
nicht berücksichtigt.
2
Eigentlich muss aus dem gleichen Grund hinter jeder Ausgabe in C-Programmen – zumindest wenn sie
nicht mit einem Newline endet – ein fflush() aufgerufen werden. Aber wer macht das schon?
Sandini Bib
Man beachte, dass als Parameter die Adresse eines Output-Streams übergeben werden muss.
Dadurch kann auch 0 bzw. NULL übergeben werden, was eine bestehende Bindung aufhebt:
// Kopplung von cin an alle Output-Streams aufheben
std::cin.tie (static_cast<std::ostream*>(0));
Zurückgeliefert wird jeweils die alte Bindung (Zeiger auf Output-Stream oder 0). Ein Aufruf
ohne Parameter liefert ebenfalls die alte Bindung.
Anwendungsbeispiele
Das klassische Filtergerüst, das einfach nur alle gelesenen Zeichen wieder ausgibt, sieht in C++
wie folgt aus:
// io/charcat1.cpp
#include <iostream>
int main()
{
char c;
int main()
{
char c;
if (! std::cin.eof()) {
std::cerr << "Lesefehler" << std::endl;
return EXIT_FAILURE;
}
if (! std::cout.good()) {
std::cerr << "Schreibfehler" << std::endl;
return EXIT_FAILURE;
}
}
8.1.5 Manipulatoren
Wie bereits in Abschnitt 4.5.2 erwähnt wurde, werden für Streams verschiedene Manipulatoren
definiert. Dabei handelt es sich um globale Objekte, die, wenn sie mit dem Standard-Ein- bzw.
Standard-Ausgabeoperator aufgerufen werden, den Stream in irgendeiner Form manipulieren,
was nicht unbedingt heißt, dass etwas ein- oder ausgelesen wird. Tabelle 8.6 listet alle grundle-
genden Manipulatoren aus <istream> und <ostream> auf.
Hinzu kommen weitere Manipulatoren, die z.B. zur Umschaltung von Ein- und Ausgabeformaten
dienen. Sie werden in den nachfolgenden Abschnitten vorgestellt.
Wenn dem Operator << als zweiter Operand eine Funktion func übergeben wird, die als Parame-
ter ein ostream-Objekt erhält und ein ostream-Objekt zurückliefert, wird diese Funktion mit
dem ostream-Objekt aufgerufen, für das der Operator << aufgerufen wird.
Das klingt sehr kompliziert, ist aber eigentlich relativ einfach: Die Funktion (und damit der
Manipulator) endl() ist im Prinzip folgendermaßen definiert (auch hier sind die Besonderheiten
für die Tatsache, dass statt ostream eigentlich die Klasse basic_ostream<> verwendet wird,
weggelassen):
namespace std {
ostream& endl (ostream& strm)
{
// Newline ausgeben
strm << '\n';
// Ausgabepuffer leeren
strm.flush();
inline
std::istream& ignoreLine (std::istream& strm)
{
char c;
8.1.6 Formatdefinitionen
Für die Definition von Ein- und Ausgabeformaten werden in der Klasse ios_base verschiede-
ne Komponenten definiert, die z.B. eine minimale Feldbreite oder die Anzahl der Stellen von
Gleitkommazahlen definieren. In einer dieser Komponenten wird eine Reihe von Formatflags als
Bitkonstanten verwaltet. Mit diesen Formatflags kann z.B. die Ausgabe eines positiven Vorzei-
chens veranlasst werden.
Manche dieser Formatflags gehören zusammen, wie beispielsweise die Flags, die die ok-
tale, dezimale oder hexadezimale Ausgabe einschalten. Um die Verwaltung solcher zusammen-
gehöriger Flags zu vereinfachen, werden jeweils Bitfelder definiert, mit denen nur diese Flags
angesprochen werden können.
Elementfunktion Bedeutung
setf(flags) Setzt flags als zusätzliche Formatflags und liefert den vor-
herigen Zustand aller Formatflags zurück
setf(flags, maske) Setzt flags als die neuen Formatflags der durch maske
identifizierten Gruppe und liefert den vorherigen Zustand
aller Formatflags zurück
unsetf(flags) löscht flags
flags() liefert alle gesetzten Formatflags
flags(flags) Setzt flags als neue Formatflags und liefert den vorheri-
gen Zustand aller Formatflags zurück
copyfmt(stream) kopiert alle Formatdefinitionen aus stream
Manipulator Effekt
setiosflags(flags) setzt flags als Formatflags
(ruft für den Stream setf(flags) auf)
resetiosflags(maske) löscht alle Formatflags der durch maske identifizierten Gruppe
(ruft für den Stream setf(0,maske) auf)
Für diese Manipulatoren muss, wie bei allen Manipulatoren mit Parametern, die Headerdatei
<iomanip> eingebunden werden. Beispiel:
#include <iostream>
#include <iomanip>
...
std::cout << resetiosflags(std::ios::adjustfield) // Ausrichtungsflags löschen
<< setiosflags(std::ios::left); // linksbündig setzen
Für einige Flag-Manipulationen existieren außerdem spezielle Manipulatoren, die nachfolgend
vorgestellt werden. Sie sind in der Handhabung in der Regel deutlich einfacher und lesbarer und
sollten deshalb nach Möglichkeit verwendet werden.
Elementfunktion Bedeutung
width() liefert die aktuelle Feldbreite zum Ein- und Auslesen
width(wert) setzt die aktuelle Feldbreite auf wert und liefert die bisherige Feldbreite
fill() liefert das aktuelle Füllsymbol
fill(c) definiert c als aktuelles Füllsymbol und liefert das bisherige Füllsymbol
Mit der Elementfunktion width() kann für eine Ausgabe eine minimale Feldbreite definiert
werden. Eine solche Definition bezieht sich allerdings nur auf die direkt folgende Ausgabe, so-
fern es sich dabei nicht um die Ausgabe eines char handelt. Ohne Parameter wird die aktuelle
minimale Feldbreite zurückgeliefert; mit Parameter wird eine neue minimale Feldbreite gesetzt
und die bisherige zurückgeliefert. Der Default-Wert ist 0.
Mit der Elementfunktion fill() kann dazu ein Füllsymbol definiert und abgefragt werden.
Das Default-Füllsymbol ist das Leerzeichen.
Zur Ausrichtung innerhalb des Feldes existieren drei Flags. Sie werden in der Klasse ios mit
einem dazugehörigen Bitfeld, wie in Tabelle 8.10 aufgelistet, definiert.
Im Gegensatz zur Feldbreite gelten Füllsymbol und Ausrichtung bis auf weiteres, also bis ein
neuer Befehl etwas anderes definiert.
Tabelle 8.11 zeigt die Auswirkungen der Funktionen und Flags bei der Ausgabe verschiede-
ner Konstanten. Als Füllsymbol wird jeweils der Unterstrich verwendet.
Sandini Bib
Für width() und fill() werden zusätzlich die in Tabelle 8.12 dargestellten Manipulatoren
definiert. Da es sich um Manipulatoren mit Parametern handelt, muss dazu die Headerdatei
<iomanip> eingebunden werden.
Manipulator Bedeutung
setw() setzt die Feldbreite zum Ein- und Auslesen
(entspricht width())
setfill() definiert ein Füllsymbol (entspricht fill())
left erzwingt die linksbündige Ausgabe
right erzwingt die rechtsbündige Ausgabe
internal erzwingt Vorzeichen linksbündig, Wert rechtsbündig
Die Anweisungen
#include <iostream>
#include <iomanip>
...
std::cout << std::setfill('_');
std::cout << std::left << std::setw(8) << "1."
<< std::right << std::setw(8) << 3.14 << std::endl;
std::cout << std::left << std::setw(8) << "2."
<< std::right << std::setw(8) << 7.33 << std::endl;
std::cout << std::left << std::setw(8) << "Summe:"
<< std::right << std::setw(8) << 10.47 << std::endl;
geben z.B.
1.__________3.14
2.__________7.33
Summe:_____10.47
aus.
Mit width() kann auch eine maximale Feldbreite zum Einlesen eines Strings (Datentyp
char*) mit dem Operator >> definiert werden. Ist der Wert ungleich 0, werden höchstens
width()-1 Zeichen gelesen. width() sollte sicherheitshalber immer verwendet werden, wenn
C-Strings mit dem Operator >> eingelesen werden, da sonst der hinter dem C-String liegende
Speicherplatz bei zu vielen Zeichen einfach überschrieben wird:
Sandini Bib
char puffer[81];
std::cin >> s; // OK
Flag Bedeutung
showpos positives Vorzeichen mit ausgeben
uppercase Großbuchstaben verwenden
Tabelle 8.13: Flags für Vorzeichen und Buchstaben von numerischen Werten
Mit dem Flag showpos kann die Ausgabe eines positiven Vorzeichens erzwungen werden. Das
Flag uppercase bietet die Möglichkeit zu definieren, dass bei der Ausgabe von Zahlen Groß-
buchstaben statt Kleinbuchstaben verwendet werden. Dies betrifft ganze Zahlen, die hexadezi-
mal ausgegeben werden, und Gleitkommazahlen, die in wissenschaftlicher Notation ausgegeben
werden.
Die Anweisungen
std::cout << 12345678.9 << std::endl;
Zahlensystem
Drei zusammengehörige Flags legen fest, in welchem Zahlensystem ganze Zahlen eingelesen
und ausgegeben werden. Die Flags werden in der Klasse ios mit dem dazugehörigen Bitfeld,
wie in Tabelle 8.15 aufgelistet, definiert.
Sandini Bib
Manipulator Bedeutung
showpos erzwingt die Ausgabe eines positiven Vorzeichens
(setzt das Flag ios::showpos)
noshowpos verhindert die Ausgabe eines positiven Vorzeichens
(löscht das Flag ios::showpos)
uppercase erzwingt die Verwendung von Großbuchstaben
(setzt das Flag ios::uppercase)
nouppercase erzwingt die Verwendung von Kleinbuchstaben
(löscht das Flag ios::uppercase)
Tabelle 8.14: Manipulatoren für Vorzeichen und Buchstaben von numerischen Werten
Die Umschaltung gilt jeweils für alle folgenden ganzen Zahlen, bis das Format erneut umge-
schaltet wird. Dezimal ist der Default-Wert.
Falls kein Flag für das Zahlensystem gesetzt ist (dies ist nicht der Default-Fall), hängt das
Zahlensystem eingelesener Werte von den führenden Zeichen dieser Werte ab. Beginnt ein Wert
mit 0x oder 0X, wird die Zahl hexadezimal eingelesen. Beginnt sie nur mit 0, wird sie oktal
eingelesen, ansonsten wird sie dezimal eingelesen.
Wenn die Flags direkt gesetzt werden, muss darauf geachtet werden, dass immer nur ein Flag
gesetzt ist. Zum direkten Umschalten gibt es zwei Möglichkeiten:
1. Ein Flag setzen und das andere zurücksetzen:
std::cout.unsetf (std::ios::dec);
std::cout.setf (std::ios::hex);
2. Ein Flag setzen und dabei automatisch alle anderen Flags des Bitfelds zurücksetzen:
std::cout.setf (std::ios::hex, std::ios::basefield);
Manipulator Bedeutung
oct Ein-/Ausgabe oktal
dec Ein-/Ausgabe dezimal
hex Ein-/Ausgabe hexadezimal
Ein zusätzliches Flag bietet die Möglichkeit, bei der Ausgabe die im Quellcode übliche Notation
zum Kennzeichnen des Zahlensystems zu verwenden (siehe Tabelle 8.17).
Flag Bedeutung
showbase Zahlensystem kennzeichnen
Tabelle 8.17: Flag zum Kennzeichnen des Zahlensystems für ganzzahlige Werte
Wenn das Flag showbase gesetzt ist, werden oktale Zahlen mit einer führenden 0 und hexadezi-
male Zahlen mit führendem 0x (bzw. wenn ios::uppercase gesetzt ist, 0X) ausgegeben.
Die Anweisungen
std::cout << 127 << ' ' << 255 << std::endl;
std::cout << std::hex << 127 << ' ' << 255 << std::endl;
std::cout.setf(std::ios::showbase);
std::cout << 127 << ' ' << 255 << std::endl;
std::cout.setf(std::ios::uppercase);
std::cout << 127 << ' ' << 255 << std::endl;
liefern z.B. folgende Ausgabe:
127 255
7f ff
0x7f 0xff
0X7F 0XFF
showbase kann außerdem mit den in Tabelle 8.18 aufgelisteten Manipulatoren gesetzt oder
gelöscht werden.
Manipulator Bedeutung
showbase Zahlensystem kennzeichnen (setzt das Flag ios::showbase)
noshowbase Zahlensystem nicht kennzeichnen (löscht das Flag ios::showbase)
Tabelle 8.18: Manipulatoren zum Kennzeichnen des Zahlensystems für ganzzahlige Werte
Sandini Bib
Gleitkomma-Notation
Für die Ausgabe von Gleitkommawerten existieren mehrere Formatflags und Komponenten. Die
in Tabelle 8.19 aufgelisteten Flags definieren zunächst grundsätzlich, ob die Ausgabe in Dezimal-
oder Exponentialdarstellung erfolgt. Sie werden in der Klasse ios mit dem dazugehörigen Bit-
feld definiert. Falls ios::fixed gesetzt ist, werden Gleitkommawerte mit der dezimalen Nota-
tion ausgegeben. Falls ios::scientific gesetzt ist, wird die wissenschaftliche (exponentielle)
Notation verwendet.
Zur Definition der Genauigkeit existiert die Elementfunktion precision() (siehe Tabelle 8.20).
Elementfunktion Bedeutung
precision() liefert die aktuelle Genauigkeit bei der Ausgabe von
Gleitkommawerten
precision(wert) setzt wert als neue Genauigkeit bei der Ausgabe von
Gleitkommawerten und liefert den bisherigen Wert
Tabelle 8.20: Elementfunktionen für die Genauigkeit bei der Ausgabe von Gleitkommawerten
Ohne Parameter aufgerufen, wird die Genauigkeit zurückgeliefert. Mit Parameter aufgerufen,
wird dieser als neue Genauigkeit verwendet (und der alte Wert zurückgeliefert). Der Default-
Wert für die Genauigkeit ist 6.
Ist das Flag ios::fixed gesetzt, werden Gleitkommazahlen in Dezimaldarstellung ausge-
geben; ist das Flag ios::scientific gesetzt, werden sie in Exponentialdarstellung ausgege-
ben. Die mit precision() gesetzte Genauigkeit definiert dabei in beiden Fällen die Anzahl der
Stellen nach dem Dezimalpunkt. Man beachte dabei, dass nicht einfach abgeschnitten, sondern
gerundet wird.
Weder ios::fixed noch ios::scientific ist defaultmäßig gesetzt. In einem solchen Fall
werden sämtliche sinnvolle Stellen, maximal aber precision() Stellen, ausgegeben. Führende
Nullen vor dem Dezimalpunkt und abschließende Nullen nach dem Dezimalpunkt spielen dabei
keine Rolle. Das bedeutet, dass abschließende Nullen und unter Umständen sogar der Dezimal-
punkt nicht ausgegeben werden. Reichen precision() Stellen aus, wird die Dezimaldarstellung
benutzt, ansonsten wird die Exponentialdarstellung verwendet.
Mit showpoint kann explizit verlangt werden, dass der Dezimalpunkt und abschließende
Nullen ausgegeben werden, bis precision() Stellen erreicht sind (siehe Tabelle 8.21).
Flag Bedeutung
showpoint erzwingt die Ausgabe des Dezimalpunktes
Die etwas komplizierten Abhängigkeiten der Flags und der Genauigkeit zeigt Tabelle 8.22 am
Beispiel zweier konkreter Werte.
Auch für Gleitkommawerte kann mit dem Flag showpos die Ausgabe eines positiven Vorzei-
chens veranlasst werden und mit dem Flag uppercase bei der wissenschaftlichen Notation ein
großes E erzwungen werden.
Die Notation und die Genauigkeit von Gleitkommawerten können auch mit den in Tabel-
le 8.23 aufgelisteten Manipulatoren geändert werden. Da setprecision ein Manipulator mit
Parameter ist, muss zu seiner Verwendung die Headerdatei <iomanip> eingebunden werden.
Manipulator Bedeutung
fixed verwendet die dezimale Notation
scientific erzwingt die wissenschaftliche Notation
setprecision(wert) setzt wert als neuen Wert für die Genauigkeit
showpoint erzwingt die Ausgabe des Dezimalpunktes
(setzt das Flag ios::showpoint)
noshowpoint der Dezimalpunkt wird nur bei Bedarf ausgegeben
(löscht das Flag ios::showpoint)
Die Anweisung
std::cout << std::scientific << std::showpoint
<< std::setprecision(8)
<< 0.123456789 << std::endl;
liefert z.B. folgende Ausgabe:
1.23456789e-01
Sandini Bib
Flag Bedeutung
boolalpha falls gesetzt, textuelle Darstellung;
falls nicht gesetzt, numerische Darstellung
Falls das Flag nicht gesetzt ist (dies ist das vordefinierte Verhalten), wird beim Ein- und Auslesen
von Booleschen Werten die numerische Darstellung verwendet. In dem Fall wird 0 für false und
1 für true verwendet. Falls in dem Fall ein Boolescher Wert eingelesen wird und das eingelesene
Zeichen weder 0 noch 1 ist, wird dies als Fehler betrachtet und das failbit im Stream gesetzt.
Falls das Flag gesetzt ist, werden Boolesche Werte in einer textuellen Form dargestellt. Dazu
wird die zur jeweiligen Sprachumgebung gehörige textuelle Repräsentation von true und false
verwendet. In der Standard-Sprachumgebung von C++ sind dies die Zeichenfolgen "true" und
"false"; in anderen Sprachumgebungen können es andere Zeichenfolgen sein (beispielsweise
"wahr" und "falsch").
Zum bequemen Manipulieren dieses Flags innerhalb einer Ein- oder Ausgabeanweisung
können auch entsprechende Manipulatoren verwendet werden (siehe Tabelle 8.25).
Manipulator Bedeutung
boolalpha erzwingt die textuelle Darstellung
(setzt das Flag ios::boolalpha)
noboolalpha erzwingt die numerische Darstellung
(löscht das Flag ios::boolalpha)
Mit den folgenden Anweisungen wird z.B. der Wert der Booleschen Variablen b einmal nume-
risch und einmal textuell ausgegeben:
bool b;
...
std::cout << std::noboolalpha << b << " == "
<< std::boolalpha << b << std::endl;
Je nach dem Wert von b und der aktuellen Sprachumgebung lautet die Ausgabe zum Beispiel:
0 == false
Sandini Bib
Allgemeine Formatdefinitionen
Zwei Flags fehlen noch in der Liste der Formatflags: skipws und unitbuf (siehe Tabelle 8.26).
Flag Bedeutung
skipws führende Trennzeichen (Whitespaces) mit Operator >> überlesen
unitbuf Ausgabepuffer nach jeder Ausgabeoperation leeren
Das Flag skipws ist als Default eingeschaltet. Es führt dazu, dass mit dem Operator >> führen-
de Leerzeichen überlesen werden. Dies ist hilfreich, um z.B. beim Einlesen von numerischen
Werten die trennenden Leerzeichen automatisch zu überlesen. Es bedeutet aber, dass mit dem
Operator >> keine Leerzeichen gelesen werden können. Um dies zu ermöglichen, muss das Flag
ausgeschaltet werden.
unitbuf steuert die Pufferung von Ausgaben. Ist unitbuf gesetzt, werden Ausgaben grund-
sätzlich nicht gepuffert ausgegeben. Nach jeder Ausgabeoperation wird der Ausgabepuffer auto-
matisch geleert. Dieses Flag ist als Default normalerweise nicht gesetzt, was bedeutet, dass die
Ausgaben gepuffert werden. Bei den Standard-Streams für Fehlerausgaben, cerr und wcerr, ist
dieses Flag allerdings automatisch gesetzt, um die ungepufferte Ausgabe zu erzwingen.
Beide Flags können mit den in Tabelle 8.27 aufgelisteten Manipulatoren gesetzt und gelöscht
werden.
Manipulator Bedeutung
skipws überliest führende Leerzeichen mit dem Operator >>
(setzt das Flag ios::skipws)
noskipws überliest führende Leerzeichen mit dem Operator >> nicht
(löscht das Flag ios::skipws)
unitbuf leert den Ausgabepuffer nach jeder Ausgabeoperation
(setzt das Flag ios::unitbuf)
nounitbuf leert den Ausgabepuffer nicht nach jeder Ausgabeoperation
(löscht das Flag ios::unitbuf)
8.1.8 Internationalisierung
Man kann die Formatierung der Ein-/Ausgabe an nationale Besonderheiten anpassen. Eine derar-
tige Internationalisierung (englisch: internationalization, oder kurz i18n3 ) wird durch die Klasse
std::locale unterstützt.
Jeder Stream gehört zu einer Sprachumgebung, indem er mit einem Locale-Objekt assozi-
iert ist. Die initiale Sprachumgebung ist dabei eine Kopie der globalen Sprachumgebung zu dem
3
i18n ist eine gängige Abkürzung für i gefolgt von 18 Zeichen, gefolgt von n“.
”
Sandini Bib
Zeitpunkt, als der Stream erzeugt wurde. Sie kann mit den in Tabelle 8.28 aufgelisteten Funktio-
nen abgefragt und verändert werden.
Elementfunktion Bedeutung
getloc() liefert die aktuelle Sprachumgebung
imbue(loc) setzt loc als aktuelle Sprachumgebung
Das folgende Beispiel zeigt, wie man diese Elementfunktionen verwenden kann:
// io/loc1.cpp
#include <iostream>
#include <locale>
int main()
{
// verwende die klassische Sprachumgebung, um Daten
// von der Standard-Eingabe zu lesen
std::cin.imbue(std::locale::classic());
Sprachumgebung Bedeutung
C Default: ANSI-C-Konventionen (Englisch, 7 Bit)
de_DE Deutsch in Deutschland
de_DE.88591 Deutsch in Deutschland mit ISO Latin-1-Kodierung
de_AT Deutsch in Österreich
de_CH Deutsch in der Schweiz
en_US Englisch in den USA
en_GB Englisch in Großbritannien
en_AU Englisch in Australien
en_CA Englisch in Kanada
fr_FR Französisch in Frankreich
fr_CH Französisch in der Schweiz
fr_CA Französisch in Kanada
ja_JP.jis Japanisch in Japan mit Japanese Industrial Standard (JIS) Kodierung
ja_JP.sjis Japanisch in Japan mit Shift JIS Kodierung
ja_JP.ujis Japanisch in Japan mit UNIXifizierter JIS Kodierung
ja_JP.EUC Japanisch in Japan mit Extended UNIX Code Kodierung
ko_KR Koreanisch in Korea
zh_CN Chinesisch in China
zh_TW Chinesisch in Taiwan
lt_LN.bit7 ISO Latin, 7 Bit
lt_LN.bit8 ISO Latin, 8 Bit
POSIX POSIX-Konventionen (Englisch, 7 Bit)
Der Aufruf
std::locale("de_DE"));
ist nur dann erfolgreich, wenn diese Sprachumgebung auch vom System unterstützt wird. Ist dies
nicht der Fall, wird eine Ausnahme vom Typ std::runtime_error ausgelöst.
Falls alles klappt, werden die Eingaben also nach klassischen (englischen) Konventionen
gelesen und die Ausgaben nach deutschen Konventionen geschrieben. Damit liest die Schleife
also Gleitkommawerte im englischen Format und gibt sie im deutschen Format wieder aus. So
wird z.B. der eingelesene Wert
47.11
als
47,11
ausgegeben.
Sandini Bib
Normalerweise definiert ein Programm Sprachumgebungen nur dann, wenn z.B. Konfigurations-
daten aus einer Datei gelesen werden und man sicherstellen muss, dass diese Daten unabhängig
von der aktuellen Sprachumgebung lesbar sind (mit deutscher Sprachumgebung würde der Ver-
such, 47.11 als Gleitkommawert einzulesen, den Wert 47 liefern, da der Punkt den Gleitkomma-
wert beendet). Stattdessen wird normalerweise die Sprachumgebung anhand der Umgebungsva-
riablen LANG ermittelt. Übergibt man einem Locale-Objekt einen Leerstring zur Initialisierung,
wird dazu das entsprechende Objekt erzeugt:
std::locale langLocale(""); // Sprachumgebung anhand LANG
Mit der Elementfunktion name() kann man den Namen einer Sprachumgebung als string ab-
fragen:
// Name der Sprachumgebung ausgeben
std::cout << langLocale.name() << std::endl;
8.1.9 Zusammenfassung
Für die Ein- und Ausgabe stehen standardmäßig Stream-Klassen zur Verfügung. Dabei wird
zwischen Klassen zur Eingabe, Ausgabe und Ein- und Ausgabe unterschieden.
Für Stream-Klassen werden zahlreiche Funktionen vordefiniert. Sie umfassen neben der ei-
gentlichen Ein- und Ausgabe Möglichkeiten zur Formatdefinition, zum Zugriff auf Zustands-
bits, zum automatischen Auslösen von Ausnahmen und zur Internationalisierung.
Die Formatierung wird nicht allein durch Elementfunktionen, sondern auch durch zahlreiche
Flags gesteuert.
Manipulatoren erlauben es, Streams innerhalb von Ein- oder Ausgabeanweisungen zu mani-
pulieren. Sie können auch selbst definiert werden.
Sandini Bib
8.2 Dateizugriff
Dieser Abschnitt beschreibt die Besonderheiten der Stream-Klassen, mit denen auf Dateien zu-
gegriffen werden kann.
// Vorwärtsdeklarationen
void zeichensatzInDateiSchreiben (const std::string& dateiname);
void dateiAusgeben (const std::string& dateiname);
Sandini Bib
i o s _ b a s e b a s i c _ s t r e a m b u f < >
s t r e a m b u f / w s t r e a m b u f
b a s i c _ i o s < >
i o s / w i o s b a s i c _ f i l e b u f < >
f i l e b u f / w f i l e b u f
{ v i r t u a l }
b a s i c _ i o s t r e a m < >
i o s t r e a m / w i o s t r e a m
b a s i c _ f s t r e a m < >
f s t r e a m / w f s t r e a m
int main ()
{
try {
zeichensatzInDateiSchreiben("charset.out");
dateiAusgeben("charset.out");
}
catch (const std::string& msg) {
std::cerr << "Exception: " << msg << std::endl;
return EXIT_FAILURE;
}
}
Sandini Bib
8.2.3 Datei-Flags
Für den genauen Modus der Dateibearbeitung existieren wiederum einige Flags, die in der Klasse
ios_base definiert werden (siehe Tabelle 8.30).
Flag Bedeutung
in zum Lesen öffnen (Default bei ifstream)
out zum Schreiben öffnen (Default bei ofstream)
app beim Schreiben immer anhängen
ate nach dem Öffnen an das Dateiende positionieren ( at end“)
”
trunc bisherigen Dateiinhalt löschen
binary spezielle Zeichen nicht ersetzen
binary sollte bei einem Zugriff auf Binärdateien (also zum Beispiel ausführbare Program-
me) gesetzt werden. Damit wird sichergestellt, dass die Zeichen so, wie sie sind, eingelesen
und geschrieben werden. Ist binary nicht gesetzt, wird automatisch berücksichtigt, dass in der
Windows-Welt das Zeilenende nicht durch ein Newline-Zeichen, sondern durch die Zeichen-
folge CarriageReturn/Linefeed (CR/LF) gekennzeichnet wird. Ohne binary wird deshalb auf
Windows-Systemen beim Einlesen die Zeichenfolge CR/LF in das Zeichen Newline umgesetzt
und beim Schreiben ein Newline als Zeichenfolge CR/LF geschrieben.
Zusätzlich existieren bei manchen Compilern spezielle Flags, wie nocreate oder noreplace.
Diese Flags sind aber nicht standardisiert, und ihre Verwendung ist insofern nicht portabel.
Sandini Bib
Die Flags können mit dem Operator | verknüpft werden. Sie werden dann dem Konstruktor als
optionales zweites Argument übergeben. Die folgende Anweisung öffnet z.B. eine Datei zum
Schreiben und sorgt dafür, dass der geschriebene Text an das Ende der Datei angehängt wird:
std::ofstream datei("xyz.out", std::ios::out | std::ios::app);
Tabelle 8.31 stellt die verschiedenen Kombinationen dieser Flags den Strings der C-Schnittstellen
zum Öffnen von Dateien, fopen(), gegenüber. Auf die Kombinationen mit binary und ate
wird nicht eingegangen. Ein gesetztes binary entspricht dem String aus dem C-Modus mit ei-
nem angehängten b. Ein gesetztes ate bedeutet nur, dass unmittelbar nach dem Öffnen der Datei
an das Ende gegangen wird. Andere, nicht aufgelistete Kombinationen, wie trunc|app, sind
nicht erlaubt.
Elementfunktion Bedeutung
open(name) öffnet eine Datei mit dem Default-Modus des Streams
open(name,flags) öffnet eine Datei mit flags als Modus
close() schließt eine Datei
is_open() liefert, ob eine Datei geöffnet ist
Tabelle 8.32: Elementfunktionen zum expliziten Öffnen und Schließen von Dateien
Das folgende Beispiel zeigt eine Verwendung. Es öffnet nacheinander alle als Parameter in der
Kommandozeile übergebenen Dateien und gibt deren Inhalt aus (dies entspricht dem UNIX-
Programm cat):
Sandini Bib
// io/cat1.cpp
// Headerdateien
#include <fstream>
#include <iostream>
using namespace std;
// Datei öffnen
datei.open(argv[i]);
// Datei schließen
datei.close();
}
}
In dem Programm werden für den Stream datei nacheinander die übergebenen Dateinamen
geöffnet, ausgegeben und wieder geschlossen. Man beachte, dass dabei nach der Ausgabe des
Inhalts jeweils clear() aufgerufen wird. Dies ist notwendig, weil am Ende der Datei eofbit
und failbit gesetzt werden und diese vor jeder weiteren Operation gelöscht werden müssen.
Auch open() löscht diese Bits nicht.
Sandini Bib
Elementfunktion Bedeutung
tellg() liefert eine Leseposition
seekg(pos) setzt pos als absolute Leseposition
seekg(offset,rpos) setzt pos als Leseposition relativ zu offset
tellp() liefert eine Schreibposition
seekp(pos) setzt pos als absolute Schreibposition
seekp(offset,rpos) setzt pos als Schreibposition relativ zu offset
Zur Sicherheit wird zwischen der Lese- und der Schreibposition unterschieden (g steht für get“
”
und p steht für put“). Die Lesefunktionen werden in istream, die Schreibfunktionen dagegen
”
in ostream definiert. Dies bedeutet aber nicht, dass sie für alle Objekte der Klasse istream bzw.
ostream aufgerufen werden dürfen. Ein Aufruf für cin, cout oder cerr ist z.B. nicht definiert.
Die Tatsache, dass die Funktionen nicht erst in den Datei-Stream-Klassen definiert werden, be-
ruht darauf, dass die Klassen beim Lesen und Schreiben oft als istream oder ostream betrachtet
werden, da dafür die meisten I/O-Operationen definiert sind.
Aufgerufen werden seekg() und seekp() mit absoluten oder relativen Positionsangaben:
Ein absoluter Wert ist die Position eines Zeichens, die als long übergeben wird (manchmal
wird dafür ein anderer Datentyp wie etwa streampos definiert).
tellg() bzw. tellp() dienen dazu, solche Positionen zu ermitteln. Ihre Verwendung
ist empfehlenswert, da sich mitunter die logische und die tatsächliche Zeichenposition un-
terscheiden können. Dies geschieht z.B. bei DOS-Dateien, in denen das Zeichen Newline
tatsächlich aus zwei Zeichen (CR und LF) besteht.
Beispiel:
// aktuelle Position merken
std::ios::pos_type pos = datei.tellg();
...
// zur gemerkten Position zurückkehren
datei.seekg(pos);
Man beachte, dass es einen speziellen Datentyp für Dateipositionen, std::ios::pos_type
gibt. Eine Verwendung eines ganzzahligen Datentyps wie long ist dagegen nicht erlaubt.4
Man kann allerdings stattdessen auch std::streampos verwenden.
Bei relativen Werten können zu drei Konstanten Offsets angegeben werden. Die Konstanten
werden von der Klasse ios_base wie in Tabelle 8.34 aufgelistet definiert.
4
Dies war in C++-Versionen von der Standardisierung möglich.
Sandini Bib
Konstante Bedeutung
beg relativ zum Dateianfang ( begin“)
”
cur relativ zur aktuellen Position ( current“)
”
end relativ zum Dateiende ( end“)
”
Tabelle 8.34: Konstanten für Dateipositionen
Beispiel:
// an den Anfang positionieren
datei.seekp (0, std::ios::beg);
...
// 20 Zeichen weiter positionieren
datei.seekp (20, std::ios::cur);
...
// 10 Zeichen vor das Ende positionieren
datei.seekp (-10, std::ios::end);
Grundsätzlich muss darauf geachtet werden, dass nicht vor den Dateianfang oder hinter das Da-
teiende positioniert wird. Ansonsten ist das Verhalten undefiniert.
Das folgende Beispiel zeigt die Verwendung der Funktionen zur Positionierung anhand einer
Funktion, die zweimal hintereinander die gleiche Datei ausgibt:
// io/cat2.cpp
// Headerdateien
#include <iostream>
#include <fstream>
#include <string>
5
In älteren Versionen von C++ war dies durch eine einfache Zuweisung möglich, da die Objekte cin,
cout und cerr den Spezialtyp istream_withassign bzw. ostream_withassign hatten. Diese Typen
wurden im Rahmen der Standardisierung entfernt, und der Mechanismus zum Umleiten arbeitet nun wie
hier beschrieben.
Sandini Bib
von cout am Programmende ist kritisch. Aus diesem Grund sollte nach einer Umleitung von
Streams immer wieder der Zustand vor der Umleitung hergestellt werden. Das folgende Pro-
gramm zeigt ein vollständiges Beispiel:
// io/redirect.cpp
#include <iostream>
#include <fstream>
#include <string>
int main()
{
std::cout << "erste Zeile" << std::endl;
8.2.7 Zusammenfassung
Spezielle Stream-Klassen erlauben Ein- und Ausgaben in Dateien.
Spezielle Elementfunktionen erlauben das Positionieren in Dateien.
Wird bis zum Ende einer Datei gelesen, werden eofbit und failbit gesetzt. Diese müssen
vor weiteren Operationen mit dem Stream gelöscht werden.
Die Standardkanäle können durch Zuweisung anderer Streambuffer umgeleitet werden. Der-
artige Umleitungen sollten danach unbedingt restauriert werden.
Sandini Bib
8.3.1 String-Stream-Klassen
Analog zu den Stream-Klassen für Dateien gibt es Stream-Klassen für Strings:
Das Klassentemplate basic_istringstream<> mit den Spezialisierungen istringstream
und wistringstream zum formatierten Lesen aus Strings ( input string stream“)
”
Das Klassentemplate basic_ostringstream<> mit den Spezialisierungen ostringstream
und wostringstream zum formatierten Schreiben in Strings ( output string stream“)
”
Das Klassentemplate basic_stringstream<> mit den Spezialisierungen stringstream
und wstringstream zum formatierten Lesen aus und Schreiben in Strings
Das Klassentemplate basic_stringbuf<> mit den beiden Spezialisierungen stringbuf
und wstringbuf wird von den anderen String-Stream-Klassen für das eigentliche Lesen und
Schreiben verwendet.
Diese Datentypen werden in der Headerdatei <sstream> definiert und stehen mit den Basis-
Stream-Klassen in der in Abbildung 8.3 dargestellten Beziehung.
Die wesentliche Funktion, die bei String-Streams hinzukommt, ist die Funktion str(), mit
der der String des String-Streams gesetzt bzw. abgefragt werden kann (siehe Tabelle 8.35).
Elementfunktion Bedeutung
str() liefert den Inhalt des String-Streams als string
str( string) setzt den Inhalt des String-Streams auf string
i o s _ b a s e b a s i c _ s t r e a m b u f < >
s t r e a m b u f / w s t r e a m b u f
b a s i c _ i o s < >
i o s / w i o s b a s i c _ s t r i n g b u f < >
s t r i n g b u f / w s t r i n g b u f
{ v i r t u a l }
b a s i c _ i o s t r e a m < >
i o s t r e a m / w i o s t r e a m
b a s i c _ s t r i n g s t r e a m < >
s t r i n g s t r e a m / w s t r i n g s t r e a m
// io/sstr1.cpp
#include <iostream>
#include <sstream>
int main()
{
// String-Stream zum formatierten Schreiben anlegen
std::ostringstream os;
// Gleitkommawerte anhängen
os << "float: " << 4.67 << std::endl;
dec: 15 hex: f
float: 4.67
oct: 17 hex: f
float: 4.67
Zunächst wird der Wert 15 dezimal und hexadezimal formatiert in den String-Stream geschrie-
ben und der String ausgegeben. Anschließend wird ein Gleitkommawert in den String-Stream
geschrieben, was bedeutet, dass dieser Gleitkommawert an den bisherigen Inhalt angehängt wird.
Schließlich wird die Schreibposition mit seekp() an den Anfang gesetzt, und mit der folgen-
den Ausgabe des oktalen Wertes von 15 wird der bisher dort stehende Inhalt des Strings über-
schrieben. Wie die anschließende Ausgabe zeigt, bleiben die restlichen Zeichen im String damit
erhalten.
Da der String selbst nach jeder Zeile durch das Schreiben von std::endl einen Zeilenum-
bruch enthält und bei seiner Ausgabe nochmal std::endl ausgegeben wird, wird bei jeder Aus-
gabe eine abschließende Leerzeile ausgegeben.
Will man den String leeren, muss man den Leerstring als neuen Inhalt zuweisen:
std::ostringstream os; // String-Stream anlegen
...
os.str(""); // String-Stream leeren
Sandini Bib
int main()
{
// String anlegen, aus dem formatiert gelesen werden soll
std::string s = "Pi: 3.1415";
8.3.2 Lexical-Cast-Operator
Mit Hilfe von String-Streams und Templates kann man sich bequem einen eigenen Operator zur
logischen Typumwandlung implementieren:
// io/lexcast.hpp
#include <sstream>
return out;
}
Der erste Template-Parameter ist der Zieltyp und wird explizit festgelegt (siehe Seite 435). Der
zweite Template-Parameter wird implizit durch das übergebene Argument festgelegt. In der
Funktion wird das übergebene Argument in einen String-Stream geschrieben und als Zieltyp
wieder ausgelesen. Die letzte Abfrage stellt fest, ob es noch Zeichen gibt, die bei der Umwand-
lung nicht verarbeitet wurden. Dies stellt sicher, dass der String 42.42 nicht erfolgreich in einen
int umgewandelt wird.
Der Aufruf dieses Operators kann z.B. wie folgt aussehen:
// io/lexcast.cpp
#include <iostream>
#include <string>
#include <cstdlib>
#include "lexcast.hpp"
8.3.3 char*-Stream-Klassen
Die Stream-Klassen, die formatiert auf C-Strings zugreifen, sind eigentlich nur aus Gründen der
Rückwärtskompatibilität vorhanden. Sie werden allerdings zum Teil immer noch verwendet, und
da dabei einige Fallen lauern, sollen sie kurz vorgestellt werden.
Analog zu den String-Stream-Klassen gibt es Stream-Klassen für den Datentyp char*:
Die Klasse istrstream für Strings, aus denen gelesen wird ( input string stream“)
”
Die Klasse ostrstream für Strings, in die geschrieben wird ( output string stream“)
”
Die Klasse strstream für Strings, die zum Lesen und Schreiben verwendet werden
Die Klasse strstreambuf als Puffer für char*-Streams
Sie werden in der Headerdatei <strstream> definiert.
// Zeile lesen
std::cin.get(puffer,sizeof(puffer));
Schreiben in char*-Streams
Ein char*-Stream zum Schreiben kann entweder von sich aus wachsen oder mit einem Puffer
fester Größe initialisiert werden. Mit ios::app oder ios::ate kann man die in den String-
Stream geschriebenen Ausgaben an einen bereits im Puffer befindlichen String anhängen.
Mit der Elementfunktion str() wird auch hier der Inhalt des Strings geliefert. Allerdings
muss hier Folgendes beachtet werden:
Sofern der char*-Stream nicht mit einem Puffer fester Größe initialisiert wurde, geht er mit
str() in den Besitz des Aufrufers über. Damit der Speicherplatz wieder freigegeben wird,
muss er mit freeze() wieder dem char*-Stream übergeben werden.
Nach dem Aufruf von str() ist der Stream eingefroren und kann nicht weiter manipuliert
werden. Auch hier kann mit freeze() erreicht werden, dass der char*-Stream wieder ma-
nipuliert werden kann.
str() hängt kein Stringendekennzeichen '\0' an. Wird dies benötigt, muss es mit Hilfe des
Manipulators std::ends explizit angehängt werden.
Sandini Bib
Das folgende Programm zeigt, wie char*-Streams für das formatierte Schreiben verwendet wer-
den können:
// io/charstr1.cpp
#include <iostream>
#include <strstream>
int main()
{
// dynamischen char*-Stream zum Schreiben erzeugen
std::ostrstream puffer;
/* Zeichenfolge ausgeben
* - str() friert char*-Stream ein
*/
std::cout << puffer.str() << std::endl;
/* Zeichenfolge ausgeben
* - str() friert char*-Stream ein
*/
std::cout << puffer.str() << std::endl;
Man beachte, dass der jeweilige Aufruf von str() den Stream einfriert, was für weitere Ände-
rungen oder für die korrekte Freigabe des dazugehörigen Speicherplatzes jeweils mit
puffer.freeze(false);
aufgehoben wird.
Man beachte auch, dass jeweils std::ends angehängt wird, damit der String als C-String
verwendet werden kann. Da dieses Stringendekennzeichen aber mitten im String den C-String
beenden würde, muss es für weitere angehängte Ausgaben überschrieben werden. Mit
puffer.seekp(-1,std::ios::end);
wird die Schreibposition entsprechend gesetzt.
All diese Probleme hat man mit den in Abschnitt 8.3.1 vorgestellten String-Streams nicht.
8.3.4 Zusammenfassung
Spezielle Stream-Klassen erlauben Ein- und Ausgaben in Strings (Datentyp string).
In String-Streams kann man Lese- und Schreibpositionen ebenfalls explizit setzen.
Um die Rückwärtskompatibilität zu wahren, besteht auch die Möglichkeit, in C-Strings vom
Typ char* zu schreiben und aus ihnen zu lesen. Diese Möglichkeit ist allerdings fehler-
trächtig und sollte vermieden werden.
Mit dem selbst definierten Operator lexical_cast kann man bequem Datentypen logisch
umwandeln.
Sandini Bib
Kapitel 9
Weitere Sprachmittel und Details
In diesem Kapitel werden all die Sprachmittel erläutert, die im bisherigen Verlauf des Buchs noch
nicht erläutert wurden, in der Praxis aber eine wichtige Rolle spielen (können). Hinzu kommen
einige Details zur Standardbibliothek.
Im Einzelnen wird auf STL-Container (insbesondere Vektoren), numerische Limits, Smart-
Pointer, Funktionsobjekte, verschiedene weitere Aspekte der Verwendung von new und delete,
Funktions- und Komponentenzeiger, die Kombination von C++-Code mit C-Code und einige
noch offene Schlüsselwörter eingegangen.
519
Sandini Bib
Ausdruck Bedeutung
vector<typ> v erzeugt einen leeren Vektor ohne Elemente
vector<typ> v1(v2) erzeugt einen Vektor als Kopie eines anderen Vektors
(alle Elemente werden kopiert)
vector<typ> v(n) erzeugt einen Vektor mit n Elementen, die jeweils mit
dem Default-Konstruktor erzeugt werden
vector<typ> v(n,elem) erzeugt einen Vektor mit n Elementen, die jeweils
Kopien von elem sind
vector<typ> v(anf,end) erzeugt einen Vektor und initialisiert ihn mit Kopien der
Elemente aus dem Bereich [anf;end)
v.~vector< typ>() löscht alle Elemente und gibt den Speicherplatz wieder
frei
Die Initialisierung der Elemente durch einen übergebenen Bereich kann vor allem dazu verwen-
det werden, einen Vektor mit Elementen aus einem anderen Container zu initialisieren:1
std::list<Elem> l; // l ist verkettete Liste von Elementen
...
// Vektor v mit Elementen der Liste l initialisieren
std::vector<Elem> v(l.begin(),l.end());
1
Sofern ein System noch keine Elementfunktionstemplates unterstützt, kann der Bereich, anhand dessen
initialisiert wird, typischerweise nur vom gleichen Typ sein.
Sandini Bib
Vergleiche
Tabelle 9.2 listet die Operationen zum Vergleichen auf.
Ausdruck Bedeutung
v1 == v2 liefert, ob v1 gleich v2 ist
v1 != v2 liefert, ob v1 ungleich v2 ist
(entspricht: !(v1==v2))
v1 < v2 liefert, ob v1 kleiner als v2 ist
v1 > v2 liefert, ob v1 größer als v2 ist
(entspricht: v2<v1)
v1 <= v2 liefert, ob v1 kleiner oder gleich v2 ist
(entspricht: !(v2<v1))
v1 >= v2 liefert, ob v1 größer oder gleich v2 ist
(entspricht: !(v1<v2))
Vergleiche mit den üblichen Vergleichsoperatoren sind nur zwischen Vektoren mit gleichem Ele-
menttyp möglich. Zum Vergleichen von zwei Containern verschiedener Klassen bzw. Typen,
müssen STL-Algorithmen verwendet werden (siehe Abschnitt 9.1.3).
Ausdruck Bedeutung
v.size() liefert die aktuelle Anzahl von Elementen
v.empty() liefert, ob der Container leer ist
(entspricht: size()==0)
v.max_size() liefert die maximal mögliche Anzahl von Elementen
v.capacity() liefert die Kapazität (maximale Anzahl von Elementen ohne
Reallokierung)
v.reserve(anz) vergrößert gegebenenfalls die Kapazität auf mindestens anz Elemente
max_size()
liefert die höchstmögliche Anzahl von Elementen, die ein Vektor aufnehmen kann. Dieser
Wert ist implementierungsabhängig. Da ein Vektor alle Elemente typischerweise in einem
Speicherblock verwaltet, kann es vor allem auf PCs relevante Einschränkungen geben. An-
sonsten ist es im Allgemeinen der maximale Wert, der als Index möglich ist.
capacity()
liefert die Anzahl von Elementen, die ein Vektor aufnehmen kann, ohne neuen Speicher anzu-
fordern. Dieser Wert ist nicht nur für das Zeitverhalten wichtig, denn durch eine Anforderung
von neuem Speicher (eine so genannte Reallokierung) werden alle Verweise auf Elemente
ungültig. Das bedeutet konkret, dass alle Iteratoren und Referenzen (das, was der Indexope-
rator liefert) ungültig werden.
Das Anfordern von neuem Speicher kann aber auch viel Zeit kosten, da alle Elemente
vom alten in den neuen Speicher kopiert werden. Eine Reallokierung bedeutet konkret, dass
für alle Elemente jeweils ein Copy-Konstruktor und ein Destruktor aufgerufen wird. Der
Copy-Konstruktor kopiert sie in den neuen Speicher, und der Destruktor zerstört sie im alten
Speicher. Dies kann, falls diese Operationen nicht trivial sind, recht teuer werden.
Es ist natürlich sinnvoll, eine Anforderung von neuem Speicher nach Möglichkeit zu vermeiden.
Dazu gibt es mehrere Möglichkeiten:
Zum einen kann ein Vektor beim Erzeugen gleich mit einer bestimmten Anzahl von Ele-
menten initialisiert werden. Diese Werte können auf verschiedene Art und Weise übergeben
werden.
Ohne Werte zur Initialisierung werden die Elemente mit dem Default-Konstruktor er-
zeugt:
std::vector<Elem> v(5); // erzeugt einen Vektor mit fünf Elementen
// (fünfmal Default-Konstruktor: Elem())
Dies bedeutet natürlich, dass ein Default-Konstruktor definiert sein muss. Und falls dieser
nicht trivial ist, kann auch das schon recht teuer sein.
Eine andere Möglichkeit besteht darin, schon einmal Platz für eine bestimmte Anzahl von
Elementen zu reservieren. Dazu dient reserve():
std::vector<Elem> v; // erzeugt einen leeren Vektor
v.reserve(5); // Platz für fünf Elemente reservieren
Es ist implementierungsabhängig, auf welche Weise genau versucht wird, ein optimales Lauf-
zeitverhalten in Bezug auf Geschwindigkeit und Speicherplatz zu erhalten. Das bedeutet, dass
einzelne Implementierungen jederzeit die Kapazität in größeren Schritten erhöhen können.
Der Sprachstandard legt allerdings fest, dass die Kapazität von Vektoren nie verkleinert wird
(also auch mit reserve() nicht). Insofern ist sichergestellt, dass beim Löschen von Elementen
Verweise auf die davor liegenden Elemente gültig bleiben.
Zuweisungen
Tabelle 9.4 listet die verschiedenen Möglichkeiten auf, Vektoren komplett neue Elemente zuzu-
weisen.
Sandini Bib
Ausdruck Bedeutung
v1 = v2 weist v1 alle Elemente von v2 zu
v.assign(n) weist n Elemente zu, die mit dem Default-Konstruktor erzeugt
werden
v.assign(n,elem) weist n Elemente zu, die jeweils Kopien von elem sind
v.assign(anf,end) weist Kopien der Elemente aus dem Bereich [anf;end) zu
v1.swap(v2) vertauscht die Elemente von v1 mit denen von v2
swap(v1,v2) ” (als globale Funktion)
Der Zuweisungsoperator weist alle Elemente eines Vektors einem anderen zu. Dabei werden, je
nach Situation und Implementierung, für die Elemente Zuweisungsoperatoren, Copy-Konstruk-
toren (falls die Menge größer wird) und Destruktoren (falls die Menge kleiner wird) aufgerufen.
Die Varianten der assign()-Funktionen entsprechen den Möglichkeiten der Konstruktoren.
Speziell die Zuweisung mit einem Bereich kann vor allem dazu verwendet werden, einem Vektor
Elemente aus einem anderen Container zuzuweisen:2
std::list<Elem> l;
std::vector<Elem> v;
...
v.assign(l.begin(),l.end());
Für das Laufzeitverhalten sehr interessant ist die Möglichkeit der Zuweisung durch Vertauschen
mit Hilfe von swap(). Diese Operation ist die einzige Zuweisungsform, die für die Elemente
keine Operationen aufruft. Sie hat deshalb ein ausgezeichnetes (weil konstantes) Zeitverhalten.
Wenn das zugewiesene Objekt anschließend nicht mehr gebraucht wird, sollte deshalb immer
swap() verwendet werden.
Elementzugriff
Zum direkten Zugriff auf die Elemente eines Vektors existieren die in Tabelle 9.5 dargestellten
Möglichkeiten.
Ausdruck Bedeutung
v.at(idx) liefert das Element mit Index idx (mit Bereichsüberprüfung)
v[idx] liefert das Element mit Index idx (ohne Bereichsüberprüfung)
v.front() liefert das erste Element (ohne Überprüfung, ob ein Element existiert)
v.back() liefert das letzte Element (ohne Überprüfung, ob ein Element existiert)
2
Sofern ein System noch keine Elementfunktionstemplates unterstützt, kann der Bereich typischerweise
nur vom gleichen Typ sein.
Sandini Bib
Zunächst ist zu beachten, dass bis auf at() keine Überprüfung stattfindet, ob ein entsprechendes
Element überhaupt existiert. Bei einem leeren Vektor wäre der Aufruf von Operator [], front()
und back() also undefiniert:
std::vector<Elem> v; // leer!
if (v.size() > 5) {
v[5] = elem; // OK
}
if (!v.empty()) {
std::cout << v.front(); // OK
}
v.at(5) = elem; // FEHLER => Ausnahme std::out_of_range
at() prüft dagegen Bereichsgrenzen. Findet eine Bereichsverletzung statt, wird eine Ausnahme
vom Typ std::out_of_range (siehe Abschnitt 3.6.3) generiert.
std::vector<Window> fenster;
...
Iterator-Funktionen
Zum indirekten Zugriff auf die Elemente des Vektors existieren die üblichen Iterator-Funktionen,
die in Tabelle 9.6 dargestellt sind.
Sandini Bib
Ausdruck Bedeutung
v.begin() liefert einen Iterator für das erste Element
v.end() liefert einen Iterator für die Position hinter dem letzten Element
v.rbegin() liefert einen Reverse-Iterator für das erste Element eines umgekehrten
Durchlaufs (also das letzte Element)
v.rend() liefert einen Reverse-Iterator für die Position hinter dem letzten
Element eines umgekehrten Durchlaufs (also die Position vor dem
ersten Element)
Die Iteratoren bleiben so lange gültig, bis ein Element mit kleinerem Index eingefügt oder
gelöscht oder die Kapazität durch das Einfügen von Elementen oder einen Aufruf von reserve()
erhöht wird.
Ausdruck Bedeutung
v.insert(pos) fügt an der Iterator-Position pos ein Element ein, das mit
dem Default-Konstruktor erzeugt wird, und liefert die
Position des neuen Elements
v.insert(pos,elem) fügt an der Iterator-Position pos eine Kopie von elem ein
und liefert die Position des neuen Elements
v.insert(pos,anf,end) fügt an der Iterator-Position pos Kopien der Elemente des
Bereichs [anf;end) ein
v.push_back(elem) hängt eine Kopie von elem an das Ende an
v.erase(pos) löscht das Element an der Iterator-Position pos und liefert
die Position des Folgeelements
v.erase(anf,end) löscht alle Elemente des Teilbereichs [anf;end) und liefert
die Position des Folgeelements
v.pop_back() löscht das letzte Element (ohne es zurückzuliefern)
v.resize(anz) ändert die Anzahl der Elemente auf anz, wobei bei einer
Vergrößerung neue Elemente mit dem Default-Konstruktor
erzeugt werden
v.resize(anz,elem) ändert die Anzahl der Elemente auf anz, wobei bei einer
Vergrößerung neue Elemente als Kopien von elem erzeugt
werden
v.clear() löscht alle Elemente (leert den Container)
Bei all diesen Elementfunktionen ist zunächst zu beachten, dass keine Überprüfung auf fehler-
hafte Parameter stattfindet. Der Programmierer muss sicherstellen, dass Iteratoren wirklich auf
Elemente des Vektors verweisen, dass der Anfang eines Bereichs vor dem Ende liegt und dass
nicht versucht wird, ein Element aus einer leeren Menge zu löschen.
Für das Laufzeitverhalten ist zu beachten, dass das Einfügen oder Löschen schneller erfolgt,
wenn vorher die Kapazität ausreichend groß definiert wurde,
wenn mehrere Elemente mit nur einer Operation eingefügt werden und
wenn Elemente am Ende eingefügt und gelöscht werden.
Durch das Einfügen und Löschen von Elementen werden Verweise auf nachfolgende Elemente
(Iteratoren und Referenzen) undefiniert. Wird dabei neuer Speicherplatz angefordert, weil die
vorherige Kapazität überschritten wurde, sind sogar alle Verweise undefiniert.
Das Löschen von Elementen mit einem bestimmten Wert wird nicht direkt unterstützt. Statt-
dessen gibt es die Möglichkeit, Algorithmen wie folgt einzusetzen:
std::vector<Elem> v;
...
// alle Elemente mit Wert elem löschen
v.erase (std::remove(v.begin(),v.end(),elem),
v.end());
Dabei entfernt std::remove() die Elemente logisch“ und liefert das neue Ende zurück.
”
erase() entfernt dann die Elemente physikalisch“.
”
Soll nur das erste Element mit einem bestimmten Wert gelöscht werden, kann die Element-
funktion nicht verwendet werden. Eine mögliche Alternative sieht wie folgt aus:
std::vector<Elem> v;
...
// erstes Element mit Wert elem löschen
std::vector<Elem>::iterator pos;
pos = std::find (v.begin(), v.end(), // Position des Elements finden
elem);
if (pos != v.end()) { // falls gefunden, Element löschen
v.erase(pos);
}
Ausdruck Bedeutung
ContTyp<typ> c erzeugt einen leeren Container ohne Elemente
ContTyp<typ> c1(c2) erzeugt einen Container als Kopie eines anderen
ContTyp<typ> c(anf,end) erzeugt einen Container und initialisiert ihn mit Kopien der
Elemente aus dem Bereich [anf;end)
c.~ ContTyp<typ>() löscht alle Elemente und gibt den Speicherplatz frei
c.size() liefert die aktuelle Anzahl von Elementen
c.empty() liefert, ob der Container leer ist
(entspricht: size()==0, kann aber schneller sein)
c.max_size() liefert die maximal mögliche Anzahl von Elementen
c1 == c2 liefert, ob c1 gleich c2 ist
c1 != c2 liefert, ob c1 ungleich c2 ist
(entspricht: !(c1==c2))
c1 < c2 liefert, ob c1 kleiner als c2 ist
c1 > c2 liefert, ob c1 größer als c2 ist
(entspricht: c2<c1)
c1 <= c2 liefert, ob c1 kleiner oder gleich c2 ist
(entspricht: !(c2<c1))
c1 >= c2 liefert, ob c1 größer oder gleich c2 ist
(entspricht: !(c1<c2))
c1 = c2 weist v1 alle Elemente von v2 zu
c1.swap(c2) vertauscht die Elemente von c1 mit denen von c2
swap(c1,c2) ” (als globale Funktion)
c.begin() liefert einen Iterator für das erste Element
c.end() liefert einen Iterator für die Position hinter dem letzten
Element
c.rbegin() liefert einen Reverse-Iterator für das erste Element eines
umgekehrten Durchlaufs (also das letzte Element)
c.rend() liefert einen Reverse-Iterator für die Position hinter dem
letzten Element eines umgekehrten Durchlaufs (also die
Position vor dem ersten Element)
c.insert(pos,elem) fügt eine Kopie von elem ein
(der Rückgabewert und die Bedeutung von pos sind
unterschiedlich)
c.erase(pos) löscht das Element an der Iterator-Position pos
c.erase(anf,end) löscht alle Elemente des Teilbereichs [anf;end) und liefert
die Position des Folgeelements
c.clear() löscht alle Elemente (leert den Container)
Nicht-modifizierende Algorithmen
Die nicht-modifizierenden Algorithmen werden in Tabelle 9.9 aufgelistet. Sie ändern weder
die Reihenfolge noch den Wert der Elemente, für die sie aufgerufen werden. Die Algorithmen
können bei allen Containern angewendet werden.
Am wichtigsten ist sicherlich der Algorithmus for_each(). Er erlaubt es, beliebige, auch
sehr komplexe Aktionen mit den Elementen durchzuführen. Ein Beispiel für seine Verwendung
befindet sich auf Seite 541.
Modifizierende Algorithmen
Modifizierende Algorithmen verändern den Wert von Elementen. Dies kann für den bearbeiteten
Bereich oder einen Zielbereich des Algorithmus gelten. Solange es nur für einen Zielbereich gilt
und dieser nicht gleich dem Quellbereich ist, wird der Quellbereich nicht verändert. Tabelle 9.10
listet die modifizierenden Algorithmen auf.
Sandini Bib
Name Funktionalität
for_each() ruft für jedes Element eine Operation auf
count() zählt die Elemente
count_if() zählt die Elemente, die ein Kriterium erfüllen
min_element() liefert die Position des kleinsten Elements
max_element() liefert die Position des größten Elements
find() sucht bestimmtes Element
find_if() sucht bestimmtes Element, das ein Kriterium erfüllt
search_n() sucht n aufeinander folgende bestimmte Werte
search() sucht die erste Teilfolge von bestimmten Werten
find_end() sucht die letzte Teilfolge von bestimmten Werten
find_first_of() sucht eines von mehreren möglichen Elementen
adjacent_find() sucht zwei benachbarte Elemente mit bestimmten
Eigenschaften
equal() testet zwei Bereiche auf Gleichheit
mismatch() liefert die beiden ersten unterschiedlichen Elemente
zweier Bereiche
lexicographical_compare() vergleicht zwei Bereiche zum Sortieren
Name Funktionalität
for_each() ruft für jedes Element eine Operation auf
copy() kopiert einen Bereich
copy_backward() kopiert einen Bereich angefangen von hinten
transform() modifiziert die Elemente eines oder zweier Bereiche
merge() fasst die Elemente zweier Bereiche zusammen
swap_ranges() vertauscht die Elemente zweier Bereiche
fill() gibt allen Elementen einen festen Wert
fill_n() gibt n Elementen einen festen Wert
generate() gibt allen Elementen einen jeweils generierten Wert
generate_n() gibt n Elementen einen jeweils generierten Wert
replace() ersetzt bestimmte Werte durch einen festen neuen Wert
replace_if() ersetzt Werte, die ein Kriterium erfüllen, durch einen
festen neuen Wert
replace_copy() kopiert einen Bereich und ersetzt dabei Elemente
replace_copy() kopiert einen Bereich und ersetzt dabei Elemente, die
ein Kriterium erfüllen
Da die Elemente von assoziativen Containern als Konstanten betrachtet werden, können diese
Algorithmen nicht verwendet werden, wenn der manipulierte Bereich bzw. der Zielbereich in
einem assoziativen Container liegt.
Sandini Bib
Löschende Algorithmen
Löschende Algorithmen entfernen Elemente aus einem Bereich. Dies betrifft entweder den be-
arbeiteten Bereich oder einen Zielbereich, in den die nicht entfernten Elemente kopiert werden.
Solange es einen Zielbereich gibt und dieser nicht gleich dem Quellbereich ist, wird der Quell-
bereich nicht verändert. Tabelle 9.11 listet die löschenden Algorithmen auf.
Name Funktionalität
remove() löscht Elemente mit einem bestimmten Wert
remove_if() löscht Elemente, die ein Kriterium erfüllen
remove_copy() kopiert einen Bereich und löscht dabei Elemente mit
einem bestimmten Wert
remove_copy_if() kopiert einen Bereich und löscht dabei Elemente, die
ein Kriterium erfüllen
unique() löscht aufeinander folgende Duplikate
unique_copy() kopiert und löscht dabei aufeinander folgende
Duplikate
Da die Elemente von assoziativen Containern als Konstanten betrachtet werden, können diese
Algorithmen nicht verwendet werden, wenn der manipulierte Bereich bzw. der Zielbereich in
einem assoziativen Container liegt.
Bei diesen Algorithmen ist zu beachten, dass die bearbeiteten Bereiche nicht kleiner werden.
Die Elemente werden nur entsprechend umsortiert, und das neue Ende wird zurückgeliefert.
Die gelöschten Elemente müssen daher unter Umständen noch wirklich entfernt werden (siehe
Seite 526).
Mutierende Algorithmen
Mutierende Algorithmen verändern die Reihenfolge von Elementen, ohne deren Wert zu ändern.
Dies bedeutet aber nicht, dass keine Zuweisungen stattfinden. Im Gegenteil, bei jeder Umsortie-
rung werden Elemente einander zugewiesen, da die Speicherplätze erhalten bleiben. Tabelle 9.12
listet die mutierenden Algorithmen auf.
Da die Elemente von assoziativen Containern als Konstanten betrachtet werden, können auch
diese Algorithmen nicht verwendet werden, wenn der manipulierte Bereich bzw. der Zielbereich
in einem assoziativen Container liegt.
Sortierende Algorithmen
Sortierende Algorithmen verändern die Reihenfolge von Elementen so, dass für die weitere Ver-
arbeitung eine zumindest teilweise Sortierung vorliegt. Das Sortierkriterium kann dabei jeweils
als Funktionsobjekt übergeben werden. Als Default-Sortierkriterium werden die Werte der Ele-
mente mit dem Operator < verglichen. Tabelle 9.13 listet die sortierenden Algorithmen auf.
Da die Elemente von assoziativen Containern als Konstanten betrachtet werden, können diese
Algorithmen nicht verwendet werden, wenn der manipulierte Bereich bzw. der Zielbereich in
einem assoziativen Container liegt.
Sandini Bib
Name Funktionalität
reverse() kehrt die Reihenfolge von Elementen um
reverse_copy() kopiert und kehrt die Reihenfolge der Elemente um
rotate() rotiert die Elemente
rotate_copy() kopiert und rotiert dabei die Elemente
next_permutation() permutiert die Reihenfolge in eine Richtung
prev_permutation() permutiert die Reihenfolge in die andere Richtung
random_shuffle() bringt die Reihenfolge der Elemente durcheinander
partition() verschiebt bestimmte Elemente nach vorn
stable_partition() verschiebt bestimmte Elemente nach vorn und behält dabei
relative Reihenfolgen bei
Name Funktionalität
sort() sortiert Elemente
stable_sort() sortiert Elemente unter Beibehaltung der Reihenfolge von
Elementen mit gleichem Wert
partial_sort() sortiert die ersten n Elemente
partial_sort_copy() kopiert die ersten n sortierten Elemente
nth_element() sortiert in Hinsicht auf ein bestimmtes Element
make_heap() macht einen Bereich zu einem Heap
push_heap() integriert ein Element in einen Heap
pop_heap() löst ein Element aus einem Heap
sort_heap() sortiert einen Heap (damit ist er kein Heap mehr)
Neben den traditionellen kompletten Sortierungen gibt es verschiedene Algorithmen, die nur teil-
weise sortieren. Da das Sortieren in der Regel aufwändig ist, lohnt es sich häufig, die Sortierung
nicht zu vollenden. Wenn z.B. das mittlere Element gesucht oder die Grenze zwischen der ersten
und der zweiten Hälfte einer Sortierung gebraucht wird, kann dies auch mit nth_element()
berechnet werden, ohne alle Elemente komplett zu sortieren.
Name Funktionalität
binary_search() liefert, ob ein Element enthalten ist
includes() liefert, ob alle Elemente einer Teilmenge enthalten sind
lower_bound() liefert das erste Element >= einem Wert
upper_bound() liefert das erste Element > einem Wert
equal_range() liefert einen Bereich von Elementen mit einem
bestimmten Wert
merge() fasst die Elemente zweier Bereiche zusammen
set_union() bildet die Vereinigungsmenge zweier Bereiche
set_intersection() bildet die Schnittmenge zweier Bereiche
set_difference() bildet die Differenzmenge zweier Bereiche
set_symmetric_difference() bildet die Komplementärmenge zweier Bereiche
inplace_merge() verschmilzt zwei direkt hintereinander liegende
Teilbereiche
Numerische Algorithmen
Eine besondere Form von Algorithmen bilden die numerischen Algorithmen. Sie werden in Ta-
belle 9.15 aufgelistet.
Name Funktionalität
accumulate() verknüpft alle Elemente
partial_sum() verknüpft ein Element jeweils mit allen Vorgängern
adjacent_difference() verknüpft jeweils ein Element mit seinem Vorgänger
inner_product() verknüpft alle Verknüpfungen von jeweils zwei Elementen
zweier Bereiche
Numerische Algorithmen dienen dazu, die Elemente eines Bereichs in verschiedener Form nu-
merisch miteinander zu verknüpfen. Dabei sollte man sich vom Namen nicht zu sehr festlegen
lassen. accumulate() bildet zwar als Default die Summe aller Elemente, kann aber durch ein
einfaches Umdefinieren der Verknüpfung auch das Produkt der Elemente bilden. Entsprechend
bildet adjacent_difference() zwar als Default jeweils die Differenz eines Elements zu sei-
nem Vorgänger, durch ein Umdefinieren der Verknüpfung kann aber auch die Summe, das Pro-
dukt oder jede andere denkbare Verknüpfung berechnet werden.
adjacent_difference() und inner_product() dienen auch dazu, eine Folge von abso-
luten Werten in eine Folge von relativen Werten und umgekehrt umzuwandeln.
Die Algorithmen accumulate() und inner_product() liefern als Ergebnis einen Wert und
sind insofern nicht-manipulierend. Die beiden anderen Algorithmen liefern mehrere Werte, die
in einen Zielbereich geschrieben werden, und sind insofern für den Zielbereich manipulierend.
Für weitere Details zu den Algorithmen sei insbesondere auf mein Standardwerk, The C++
”
Standard Library – A Tutorial and Reference“ verwiesen (siehe Literaturliste).
Sandini Bib
Interessant ist natürlich vor allem der Wertebereich von Datentypen. Das folgende Beispiel zeigt
eine mögliche Auswertung dieser Informationen und gibt die Maxima für alle möglichen Daten-
typen mit einigen weiteren Informationen aus:
// sonst/limits.cpp
#include <iostream>
#include <limits>
#include <string>
int main()
{
using namespace std;
Sandini Bib
max(float): 3.40282e+38
max(double): 1.79769e+308
max(long double): 1.79769e+308
is_signed(char): true
is_specialized(string): false
Sandini Bib
Die letzte Ausgabe besagt, dass es zum Datentyp std::string keine derartigen Angaben gibt.
Das macht Sinn, da ein String kein numerischer Datentyp ist. Trotzdem ist diese Information,
wie man sieht, für alle Typen abfragbar. Das gilt sogar für selbst definierte Datentypen. Dies ist
durch eine Programmiertechnik in Verbindung mit Templates möglich: Für alle Datentypen wird
eine allgemeine Default-Implementierung definiert:
namespace std {
// allgemeine Definition des Templates für numerische Limits
template <typename T>
class numeric_limits {
public:
// im Allgemeinen existieren keine numerischen Limits
static const bool is_specialized = false;
namespace std {
template<> class numeric_limits<float> {
public:
static const bool is_specialized = true;
9.1.5 Zusammenfassung
Vektoren besitzen eine Größe und eine Kapazität.
Vektoren prüfen beim Elementzugriff normalerweise nicht, ob das Element überhaupt exis-
tiert.
Ein Vektor kann (und sollte) einem (dynamischen) Feld vorgezogen werden.
Mit numerischen Limits kann man die Eigenschaften numerischer Datentypen auswerten.
Sandini Bib
9.2.1 Smart-Pointer
In diesem Abschnitt werden zunächst Objekte vorgestellt, die sich wie schlaue Zeiger“ verhal-
”
ten.
public:
Sandini Bib
// Copy-Konstruktor
CountedPtr (const CountedPtr<T>& p) throw()
: ptr(p.ptr), count(p.count) { // Objekt und Zähler übernehmen
++*count; // Anzahl der Verweise inkrementieren
}
// Destruktor
~CountedPtr () throw() {
freigabe(); // als letzter Verweis Objekt zerstören
}
// Zuweisung
CountedPtr<T>& operator= (const CountedPtr<T>& p) throw() {
if (this != &p) { // falls keine Zuweisung an sich selbst
freigabe(); // als letzten Verweis altes Objekt zerstören
ptr = p.ptr; // neues Objekt übernehmen
count = p.count; // Zähler vom neuen Objekt übernehmen
++*count; // Anzahl der Verweise inkrementieren
}
return *this;
}
private:
void freigabe() {
if (--*count == 0) { // falls letzter Verweis
Sandini Bib
#endif /*COUNTED_PTR_HPP*/
Man kann nun jedes beliebige Objekt mit new erzeugen und einem solchen Zeiger die Kontrolle
übergeben:
CountedPtr<Bsp::Bruch> bp = new Bsp::Bruch(16,100);
bp kann man nun beliebig kopieren und zuweisen. Dabei werden jeweils nur weitere Verweise
auf das Objekt erzeugt.
Man beachte, dass sowohl der Operator * als auch der Operator -> implementiert werden.
Man kann also mit beiden Operatoren auf das Objekt zugreifen:
std::cout << *bp << std::endl;
...
double d = bp->toDouble();
Implementiert man den Operator -> für eigene Klassen, muss man dabei etwas zurückliefern,
für das dann erneut der Operator -> aufgerufen wird. Wird wie hier für ein Objekt vom Typ
CountedPtr<Bsp::Bruch> der Operator -> aufgerufen, liefert dieser den Typ Bsp::Bruch*,
für den dann erneut der Operator -> aufgerufen wird.
Das folgende Programm zeigt, wie man diese Klasse dazu verwenden kann, Objekte gleich-
zeitig in mehreren STL-Containern zu verwalten:
// sonst/refsem1.cpp
#include <iostream>
#include <list>
#include <deque>
#include <algorithm>
#include "countptr.hpp"
using namespace std;
int main()
{
// Datentyp für Smart-Pointer dazu
typedef CountedPtr<int> IntPtr;
Sandini Bib
-3 5 81 1 6 0
0 6 1 81 5 -3
Im Boost-Repository für ergänzende C++-Bibliotheken befinden sich mehrere Smart-Pointer-
Klassen für unterschiedliche Zwecke (siehe http://www.boost.org).
9.2.2 Funktionsobjekte
Es ist sogar auch möglich und sinnvoll, Objekte zu definieren, die sich wie Funktionen verhal-
ten. In diesem Fall wird für die Objekte der Operator ()“ definiert. Diese sicherlich mysteriös
”
klingende Technik soll zunächst einmal an einem Beispiel motiviert werden.
Man kann den STL-Algorithmen zur Verarbeitung eine Operation übergeben. Dies kann z.B.
eine Funktion sein. Das folgende Beispiel verdeutlicht dies:
// sonst/transform1.cpp
#include <set>
#include <vector>
#include <algorithm>
#include <iterator>
#include <iostream>
int main()
{
std::set<int> coll1;
std::vector<int> coll2;
}
In diesem Beispiel wird durch den Aufruf
transform (coll1.begin(),coll1.end(), // Quelle
std::back_inserter(coll2), // Ziel (einfügend)
add10);
jedes Element aus coll1 einfügend nach coll2 transformiert, wobei durch den Aufruf der
Funktion add10() jeweils 10 addiert wird. Mit add10 wird dabei die Funktion als Operation
an transform() übergeben (siehe Abbildung 9.1).
Die Ausgabe des Programms lautet wie folgt:
1 2 3 4 5 6 7 8 9
11 12 13 14 15 16 17 18 19
c o l l 1 Ite ra to r t r a n s f o r m ( ) Ite ra to r c o l l 2
i n t a d d 1 0 ( i n t e l e m )
{
r e t u r n e l e m + 1 0 ;
}
Was passiert aber nun, wenn man verschiedene Werte addieren will? Sofern diese Werte zur
Kompilierzeit feststehen, kann man dazu ein Template verwenden (vgl. Abschnitt 7.4):
Sandini Bib
int main()
{
...
transform (coll1.begin(),coll1.end(), // Quelle
std::back_inserter(coll2), // Ziel (einfügend)
add<10>); // Operation
...
}
Es ist allerdings derzeit nicht ganz klar, ob man ein Template einfach auf diese Weise als Opera-
tion übergeben kann.3 Unter Umständen muss man noch den genauen Datentyp der übergebenen
Operation spezifizieren:
transform (coll1.begin(),coll1.end(), // Quelle
std::back_inserter(coll2), // Ziel (einfügend)
(int(*)(int))add<10>); // Operation
Der Ausdruck
int(*)(int)
steht dabei für eine Funktion, die einen int bekommt und einen int zurückliefert. Durch
(int(*)(int))add<10>
wird explizit festgelegt, dass add<10> diesen Typ besitzt.
Doch was passiert, wenn man den zu addierenden Wert erst zur Laufzeit erfährt? Eigentlich
braucht man diesen Wert als zweiten Parameter, doch transform() ruft diese Operation nur mit
einem Argument, nämlich dem jeweiligen Element, auf. Damit müssen wir den zu addierenden
Wert irgendwie anders an add() übergeben.
Man könnte natürlich eine globale Variable verwenden, doch ist das sicherlich in mehrfacher
Hinsicht bedenklich.
An dieser Stelle helfen Funktionsobjekte. Dabei handelt es sich um Objekte, die sich wie
Funktionen verhalten, wie Objekte aber weitere Daten verwalten können. Wir können ein Objekt
erzeugen, dem wir den zu addierenden Wert übergeben, und dieses Objekt dann als Operation
transform() übergeben. Dies sieht wie folgt aus:
3
Der Standard ist in diesem Punkt nicht eindeutig und Compiler verhalten sich deshalb unterschiedlich.
Sandini Bib
// sonst/transform2.cpp
#include <set>
#include <vector>
#include <algorithm>
#include <iterator>
#include <iostream>
int main()
{
std::set<int> coll1;
std::vector<int> coll2;
}
Der Ausdruck
Add(10)
erzeugt ein temporäres Objekt der Klasse Add und initialisiert dieses mit 10. Durch
transform (coll1.begin(),coll1.end(), // Quelle
std::back_inserter(coll2), // Ziel (einfügend)
Add(10)); // Operation
wird ein derartiges temporäres Objekt als Operation op an transform() übergeben. Innerhalb
von transform() wird die übergebene Operation aufgerufen:
op (aktuellesElement)
Handelt es sich bei op um eine Funktion, wird diese also mit dem aktuellen Element aufgerufen.
Handelt es sich wie hier um ein Funktionsobjekt, entspricht dies dem Aufruf:
op.operator () (aktuellesElement)
Dieser Operator ist bei Objekten vom Typ Add so definiert, dass der bei der Initialisierung über-
gebene Wert addiert wird.
9.2.3 Zusammenfassung
Man kann für Objekte die Operatoren * und -> definieren. Damit lassen sich Smart-Pointer
implementieren.
Man kann für Objekte den Operator () definieren. Damit lassen sich Funktionsobjekte im-
plementieren.
Sandini Bib
9.3.2 Placement-New
Übergibt man new als Parameter eine Adresse vom Datentyp void*, so wird dies als Aufruf von
new verstanden, bei dem der Speicherplatz bereits zur Verfügung steht. Damit kann man Objekte
in bereits vorhandenem Speicherplatz initialisieren. Das Erzeugen eines Objekts mit new
Bsp::Bruch* bp = new Bsp::Bruch(16,100);
kann man also wie folgt in zwei Schritten durchführen:
// uninitialisierten Speicherplatz anfordern:
void* p = ::operator new(sizeof(Bsp::Bruch));
// Objekt aufräumen:
bp->~Bsp::Bruch();
// Speicherplatz freigeben:
::operator delete((void*)bp);
9.3.3 New-Handler
Schlägt das Anfordern von Speicherplatz mit new fehl, wird in Normalfall eine Ausnahme vom
Typ std:bad_alloc ausgelöst.
Man kann sich allerdings in diese Situation einmischen. Kann new keinen Speicherplatz re-
servieren, wird nämlich eine Funktion aufgerufen, die die dazugehörige Ausnahme auslöst. Eine
derartige Funktion nennt man New-Handler.
Mit std::set_new_handler() kann man einen eigenen New-Handler installieren. Diese
Funktion wird in der speziellen C++-Headerdatei <new> deklariert.
Der New-Handler ist als globale Funktion zu deklarieren, die ohne Parameter aufgerufen
wird und auch keinen Rückgabewert zurückliefert. Das folgende Beispiel zeigt einen solchen
New-Handler und seine Verwendung:
// sonst/newhdl1.cpp
// Headerdatei für den New-Handler
#include <new>
// Standard-Headerdateien
#include <iostream>
#include <cstdlib>
/* eigenerNewHandler()
* - gibt Fehlermeldung aus und beendet das Programm
*/
void eigenerNewHandler ()
{
// Fehlermeldung auf Standard-Fehlerkanal ausgeben
std::cerr << "Speicherplatz alleeeeeeeeee..." << std::endl;
int main()
{
try {
Sandini Bib
4
Wie set_new_handler() intern implementiert ist, wird in Abschnitt 9.4.1 noch genauer erläutert.
Sandini Bib
Dieser erste New-Handler sollte allerdings noch ein wenig umgeschrieben werden. Es kann
nämlich passieren, dass über den New-Handler selbst versucht wird, neuen Speicherplatz an-
zufordern. Es ist nicht ausgeschlossen, dass im Rahmen des Ausgabeoperators << oder durch die
Erzeugung des Ausnahme-Objekts new aufgerufen wird. Dies würde eine rekursive Schleife von
New-Handler-Aufrufen zur Folge haben und schließlich zum Absturz des Programms führen.
Temporäre New-Handler
Die Funktion std::set_new_handler() liefert jeweils den vorher installierten New-Handler
zurück. Dies kann dazu verwendet werden, New-Handler nur für einen begrenzten Programmab-
schnitt zu installieren. Der zurückgelieferte New-Handler wird dazu später wieder installiert:
// sonst/newhdl3.cpp
void f ()
{
std::new_handler alterNewHandler; // Zeiger auf New-Handler
// Standard-Headerdateien
#include <iostream>
// Vorwärtsdeklaration
static void eigenerNewHandler();
// Reserve-Speicherplatz
static char* reserveSpeicherplatz;
void initNewHandler ()
{
// je nach Bedarf Speicherplatz allokieren
reserveSpeicherplatz = new char[100000];
// New-Handler installieren
std::set_new_handler(&eigenerNewHandler);
}
ser in einer Schleife immer wieder aufgerufen wird, bis der angeforderte Speicherplatz verfügbar
ist oder die Schleife durch eine Ausnahme oder einen Programmabbruch beendet wird.
Ein Programm kann also einen New-Handler installieren, der das Programm nicht beendet,
sondern aufräumt und dadurch Speicherplatz freigibt. Genauso ist es möglich, dass der erste
Aufruf des New-Handlers reservierten Speicherplatz freigibt und eine Warnung ausgibt, dass
der Speicher fast verbraucht ist. Das Programm läuft aber weiter und wird erst dann endgültig
beendet, wenn der Reserve-Speicher“ verbraucht ist:
”
// sonst/newhdl5.cpp
// Headerdatei für den New-Handler
#include <new>
// Standard-Headerdateien
#include <iostream>
// Vorwärtsdeklaration
static void eigenerNewHandler();
// Reserve-Speicherplatz
static char* reserveSpeicherplatz1;
static char* reserveSpeicherplatz2;
void initNewHandler ()
{
// je nach Bedarf Reserve-Speicherplatz anfordern
reserveSpeicherplatz1 = new char[1000000];
reserveSpeicherplatz2 = new char[100000];
// New-Handler installieren
std::set_new_handler(&eigenerNewHandler);
}
Man beachte auch hier, dass der Rückgabetyp immer void* (und nicht BspKlasse*) sein muss.
Es wird noch kein Objekt der Klasse angelegt, sondern erst Speicher dafür bereitgestellt.
Bei der Implementierung des Operators wird durch die Verwendung des Bereichsoperators ::
sichergestellt, dass innerhalb der Implementierung der globale New-Operator aufgerufen wird.
Sofern die eigene Implementierung nicht dazu dient, die Speicherverwaltung völlig umzustel-
len, ist es immer von Vorteil, sie auf den globalen New-Operator zurückzuführen. Damit wird
sichergestellt, dass im Fehlerfall der installierte New-Handler aufgerufen wird, so wie es vom
Anwender auch erwartet wird.
Zum Anfordern des Speicherplatzes muss immer size verwendet werden. Da der Operator
new vererbt wird, kann er nämlich auch für größere Objekte aus abgeleiteten Klassen aufgerufen
werden. Es ist also ein Fehler, Speicher nur für ein Objekt der entsprechenden Klasse bereitzu-
stellen:
return ::new char[sizeof(BspKlasse)]; // FEHLER
Genauso falsch ist es, einfach explizit das globale new für ein Objekt der Klasse aufzurufen:
return ::new BspKlasse; // FEHLER
Hier wird ebenfalls unter Umständen zu wenig Speicher angelegt und außerdem der Konstruktor
der Klasse bereits aufgerufen (was eigentlich erst nach dem Aufruf von new passieren sollte).
Wer den Operator new wirklich nur für Objekte dieser Klasse implementieren will, kann für
abgeleitete Klassen das globale new direkt aufrufen. Dazu wird einfach geprüft, ob sich die Größe
des angeforderten Speichers mit der Größe der Objekte dieser Klasse deckt:
void* BspKlasse::operator new (size_t size)
{
// für abgeleitete Klassen globales new aufrufen
if (size != sizeof(BspKlasse)) {
return ::new char [size];
}
...
}
Bei abgeleiteten Klassen ohne neue Komponenten wird der Test allerdings fehlschlagen.
Auf entsprechende Art und Weise kann für jede Klasse der Operator delete mit einer Aus-
gabeanweisung implementiert werden:
// sonst/mydelete.cpp
#include <cstddef>
9.3.6 Zusammenfassung
Mit std::nothrow kann man erreichen, dass new bei zu wenig Speicherplatz keine Ausnah-
me auslöst, sondern NULL zurückliefert.
Mit Placement-New kann man Objekte in vorhandenem Speicherplatz initialisieren.
Für den Fall, dass mit new kein Speicherplatz angefordert werden kann, kann ein New-
Handler definiert werden, der dann automatisch aufgerufen wird und die entsprechende Feh-
lerbehandlung durchführt.
Die Operatoren new und delete können überladen werden.
Beim Überladen von new sollte darauf geachtet werden, dass im Fehlerfall ein installierter
New-Handler aufgerufen wird.
Man kann eigene Versionen von new mit zusätzlichen Parametern definieren.
Sandini Bib
9.4.1 Funktionszeiger
Schon in C ist es möglich, Zeiger auf Funktionen zu definieren. Auf diese Weise werden z.B.
häufig Error-Handler verwaltet.
Error-Handler ( Fehler-Behandler“) sind Funktionen, die irgendwo installiert werden, um
”
dann aufgerufen zu werden, wenn Fehler auftreten. Dazu wird ein Error-Handler einem Funkti-
onszeiger zugewiesen. Wenn dann ein Fehler auftritt, wird die Funktion aufgerufen, auf die der
Funktionszeiger zeigt.
Ein Beispiel für derartige Error-Handler sind die in Abschnitt 9.3.3 vorgestellten New-Hand-
ler. Ein Funktionszeiger zeigt als globale Variable jeweils auf die Funktion, die im Fehlerfall
aufzurufen ist:
// Datentyp für Zeiger auf New-Handler definieren
namespace std {
typedef void (*new_handler) ();
}
// sonst/newimpl.cpp
// mögliche Implementierung von Operator new
void* operator new (std::size_t size)
{
void* p; // Zeiger für neuen Speicher
9.4.2 Komponentenzeiger
Sollen Elementfunktionen als Parameter verwendet werden, werden Zeiger auf Elementfunktio-
nen benötigt. C++ führt dazu Komponentenzeiger ein. Komponentenzeiger sind Zeiger auf Kom-
ponenten einer bestimmten Klasse. Durch Verwendung des Bereichsoperators bei der Deklarati-
on sind sie an eine bestimmte Klasse gebunden. Der Zugriff erfolgt mit speziellen Operatoren,
die nur für diesen Zweck eingeführt werden.
BspKlassenFunktion funcPtr;
Nach der Zuweisung einer Komponente muss zum Zugriff auf die Komponente ebenfalls der
C++-Operator .* verwendet werden:
BspKlasse obj;
...
// Elementfunktion, auf die funcPtr zeigt, für Objekt obj aufrufen
(obj.*funcPtr) ();
Da der Funktionsaufruf eine höhere Priorität hat, muss der Ausdruck mit dem Operator .* ge-
klammert werden.
Entsprechend wird für Zeiger auf Objekte der Operator ->* verwendet:
Sandini Bib
BspKlasse obj;
BspKlasse* objPtr = &obj;
...
/* Elementfunktion, auf die funcPtr zeigt, für Objekt aufrufen, auf das objPtr zeigt
*/
(objPtr->*funcPtr)();
Auf diese Art und Weise kann z.B. eine Elementfunktion als Parameter übergeben und in einer
Variablen verwaltet werden.
Das folgende Beispiel verdeutlicht die Verwendung von Komponentenzeigern:
// sonst/kompptr1.cpp
#include <iostream>
class BspKlasse {
public:
void func1() {
std::cout << "Aufruf von func1()" << std::endl;
}
void func2() {
std::cout << "Aufruf von func2()" << std::endl;
}
};
int main ()
{
// Funktionszeiger auf Elementfunktion der Klasse BspKlasse
BspKlassenFunktion funcPtr;
class BspKlasse {
public:
void func1() {
std::cout << "Aufruf von func1()" << std::endl;
}
void func2() {
std::cout << "Aufruf von func2()" << std::endl;
}
};
objPtr = op;
objfpPtr = fp;
}
int main ()
{
// Objekt der Klasse BspKlasse
BspKlasse x;
Umgekehrt können diese Adressen wieder als Objekte und Komponentenzeiger verwendet wer-
den:
void callBspKlassenFunc (void* op, void* fp)
{
// Typen zurückwandeln
BspKlasse* bspObjPtr = static_cast<BspKlasse*>(op);
BspKlassenFunktion* bspObjfpPtr
= static_cast<BspKlassenFunktion*>(fp);
9.4.4 Zusammenfassung
In C++ lassen sich Zeiger auf Klassenkomponenten definieren.
Die C++-Operatoren .* und ->* erlauben es, von Objekten oder Objektzeigern auf Verweise
auf Klassenkomponenten zuzugreifen.
Über Zeiger auf Elementfunktionen lassen sich Elementfunktionen parametrisieren oder für
einen Aufruf von außerhalb exportieren.
Sandini Bib
9.5.4 Zusammenfassung
C-Code kann mit C++-Code kombiniert werden.
Importierte und exportierte Funktionen (Typen, Variablen) müssen dazu mittels extern "C"
deklariert werden.
C++-Symbole unterliegen normalerweise dem Name-Mangling.
Code unterschiedlicher C++-Compiler kann normalerweise nicht kombiniert werden.
main() sollte immer mit einem C++-Compiler übersetzt werden.
In C++ ist grundsätzlich die Präprozessorkonstante __cplusplus definiert.
5
Um Namenskonflikte zu vermeiden, beginnen vom System vordefinierte Konstanten typischerweise mit
zwei Unterstrichen.
Sandini Bib
struct Eintrag {
WertArt art;
Wert wert;
};
Sandini Bib
if (...) {
wert.art = Adresse; // Adresse in Variante wert eintragen
wert.adr = "hallo";
}
else {
wert.art = Zahl; // Zahl in Variante wert eintragen
wert.zahl = 42;
}
...
switch (wert.art) {
case Adresse:
const char* t = wert.adr; // Adresse aus Variante wert auslesen
break;
case Zahl:
int i = wert.zahl; // Zahl aus Variante wert auslesen
...
break;
}
Man beachte, dass sowohl der Aufzählungstyp als auch die dazugehörigen Werte als Symbo-
le im jeweiligen Gültigkeitsbereich definiert werden. Man sollte also darauf achten, dass man
Aufzählungstypen nicht im globalen Namensbereich definiert.
Den Werten von Aufzählungstypen können auch ganzzahlige Werte zugeordnet werden:
enum Jahreszeiten { Fruehling=1, Sommer=2, Herbst=4, Winter=8 };
Ohne diese Zuordnung fangen die Werte bei null an.
Aufzählungstypen dürfen alle Werte zwischen null und dem auf die nächste Zweierpotenz
aufgerundeten größten ganzzahligen Wert annehmen. Hat ein Aufzählungswert einen negati-
ven Wert, gilt Entsprechendes (es werden immer so viele Bits verwendet, wie im Wertebereich
benötigt werden).
Entsprechend können ganzzahlige Werte explizit in Aufzählungswerte konvertiert werden:
enum Bereich { min = 0, max = 100 } // Bereich: 0 bis 127
Bereich b;
b = min; // OK
b = max; // OK
b = Bereich(17); // zwischen min und max
b = Bereich(120); // OK, gleiche Anzahl Bits wie 100
b = Bereich(200); // FEHLER (undefiniertes Verhalten)
Ein weiteres Beispiel für die Verwendung von Aufzählungstypen befindet sich auf Seite 425.
Sandini Bib
9.6.4 Zusammenfassung
Mit union kann man Varianten definieren. Das sind Objekte, die mit dem gleichen Speicher-
platz Daten unterschiedlichen Typs verwalten können.
Mit enum kann man Aufzählungstypen definieren.
Mit volatile kann man Bereiche, die außerhalb der Kontrolle des Compilers liegen, vor
allzu aggressiven Optimierungen schützen.
Sandini Bib
Kapitel 10
Zusammenfassung
Da die Sprachmittel von C++ unter didaktischen Gesichtspunkten nach und nach eingeführt wur-
den, sollen hier noch einmal die wichtigsten Eigenschaften geschlossen aufgelistet werden. Da-
zu gehören neben den Operatoren und ihren Eigenschaften auch sinnvolle Programmier- und
Design-Richtlinien.
571
Sandini Bib
Default-Konstr. – – – ja – ja1
Copy-Konstr. – – – ja – ja
andere Konstr. – – – ja – –
Destruktor – ja – ja – ja
Konvertierung ja ja –2 ja – –
= – ja ja ja – ja
(), [], -> ja ja ja ja – –
op= ja ja ja ja ja –
andere Operatoren ja ja ja ja ja –
andere Elementfkt. ja ja ja ja – –
friend – – ja – ja –
Literaturverzeichnis
In diesem Verzeichnis liste ich die Literatur auf, auf die ich mich in diesem Buch beziehe und die
ich für einen tieferen Einstieg in C++ für sinnvoll halte. Es handelt sich nicht um ein vollständiges
Literaturverzeichnis zu C++.
Diese Literaturliste wird auf der Webseite zum Buch unter
http://www.josuttis.de/cppbuch
auf dem Laufenden gehalten.
Der Standard
ISO
Information Technology – Programming Languages – C++
Document Number ISO/IEC 14882-1998
ISO/IEC, 1998
Dieses Dokument ist im Internet im Electronic Standards Store, ESS, unter
http://www.ansi.org
für $ 18,– als PDF-Datei erhältlich. In Deutschland ist der Standard bei DIN ebenfalls verfügbar,
kostet dort aber knapp DM 500,–.
579
Sandini Bib
580 Literaturverzeichnis
Scott Meyers
Effektiv C++ programmieren
50 Wege zur Verbesserung Ihrer Programme und Entwürfe
Addison-Wesley, München, 1998
Scott Meyers
Mehr Effektiv C++ programmieren
35 neue Wege zur Verbesserung Ihrer Programme und Entwürfe
Addison-Wesley, München, 1998
Herb Sutter
Exceptional C++
47 technische Denkaufgaben, Programmierprobleme und ihre Lösungen
Addison-Wesley, München, 2000
Andrei Alexandrescu
Modern C++ Design
Generic Programming and Design Patterns Applied
Addison-Wesley, Reading, 2001
Matthew H. Austern
Generic Programming and the STL
Using and Extending the C++ Standard Template Library
Addison-Wesley, Reading, 1998
Ulrich Breymann
Komponenten entwerfen mit der STL
Addison-Wesley, München, 1999
Sandini Bib
Literaturverzeichnis 581
C und C-Schnittstellen
Brian W. Kernighan, Dennis M. Ritchie
Programmieren in C
Hanser Verlag, 1983
Glossar
Dieses Glossar ist eine Zusammenstellung der wichtigsten Fachbegriffe dieses Buchs. Neben
kurzen Erklärungen enthält es sowohl die in diesem Buch verwendete deutsche als auch die
englische Bezeichnung, wie sie in der internationalen Literatur benutzt wird. Auf diese Weise
wird ein Übertragen der Begriffe in die jeweils andere Sprache erleichtert.
ANSI
American National Standard Institute, eines der Komitees, die an der Sprachspezifikation von
C++ arbeiten.
Attribut (attribute)
Eigenschaft, Datum, Datenelement eines konkreten Objekts einer Klasse. Jedes konkrete Objekt
einer Klasse besitzt für seine Attribute Speicherplatz, dessen Belegung den Zustand des Objekts
widerspiegelt.
Basisklasse
Eine Klasse, von der abgeleitete Klassen Eigenschaften erben. Basisklassen sind häufig abstrakte
Klassen.
Andere Bezeichnungen für Basisklasse sind Oberklasse oder Superklasse.
583
Sandini Bib
584 Glossar
Cast
Bezeichnung für eine mögliche Form einer erzwungenen Typumwandlung, die auch schon in C
existiert.
Datenelement
Attribut eines Objekts bzw. eine Komponente, die keine Elementfunktion ist.
Jedes konkrete Objekt einer Klasse besitzt für seine Datenelemente Speicherplatz, dessen
Belegung den Zustand des Objekts widerspiegelt.
Datenkapselung (encapsulation)
Konzept, über das der Zugriff auf Dinge bzw. Objekte eingeschränkt wird. Durch die Einschrän-
kung kann ein Objekt nur über eine wohldefinierte Schnittstelle manipuliert werden. Damit
können Schnittstellen besser verifiziert und Inkonsistenzen vermieden werden.
C++ unterstützt die Datenkapselung durch spezielle Zugriffsschlüsselwörter.
Destruktor (destructor)
Funktion, die für ein Objekt einer Klasse aufgerufen wird, wenn es zerstört wird.
Glossar 585
Initialisierungsliste
Zu einem Konstruktor gehörende Liste von Werten, mit der die Initialisierung eines Objekts
gesteuert wird. Die angegebenen Werte können zur Initialisierung von Komponenten einer Klasse
oder zum Aufruf des Konstruktors einer Basisklasse dienen.
Instanz (instance)
Objektorientierte Bezeichnung für ein konkretes Objekt einer Klasse. Eine Instanz besitzt die in
der Klasse angegebenen Daten (Attribute, Elemente) und kann mit den dafür definierten Opera-
tionen manipuliert werden.
In C++ und in diesem Buch ist der Begriff gleichbedeutend mit einem konkreten Objekt einer
Klasse. Im objektorientierten Sprachgebrauch wird mitunter zwischen Objekt und Instanz unter-
schieden. Objekt“ ist dann eher ein Oberbegriff für Klasse oder Instanz, da auch eine Klasse als
”
Objekt betrachtet werden kann.
ISO
International Organization for Standardization, eines der Komitees, die an der Sprachspezifika-
tion von C++ arbeiten.
Iterator
Objekt, das über Elemente iterieren kann.
Klasse (class)
Beschreibung einer bestimmten Art von Dingen oder Vorgängen (Objekten). In einer Klasse
werden alle Eigenschaften definiert, die für ein Objekt eine Rolle spielen. Dazu gehören sowohl
dessen Daten (Attribute, Elemente) als auch die durchführbaren Operationen (Methoden).
Man kann eine Klasse auch als abstrakten Datentyp oder als die Umsetzung eines abstrakten
Datentyps in eine Programmiersprache betrachten.
In C++ sind Klassen Struktur-Datentypen, deren Komponenten auch Funktionen sein können
und Zugriffsbeschränkungen unterliegen.
Komponente (member)
Element einer Klasse. Eine Komponente kann ein Datenelement (Attribut) sein, das ein Objekt
der Klasse jeweils besitzt und den Zustand des Objekts widerspiegelt, oder eine Elementfunktion
Sandini Bib
586 Glossar
(Methode) sein, die beschreibt, welche Operationen für ein Objekt der Klasse aufgerufen werden
können.
Ein Spezialfall sind statische Komponenten, die nicht Eigenschaften eines einzelnen Objekts,
sondern Eigenschaften der ganzen Klasse beschreiben.
konkrete Klasse
Klasse, die im Gegensatz zu einer abstrakten Klasse dafür vorgesehen ist, dass von ihr konkrete
Objekte (Instanzen) erzeugt werden.
Konstruktor (constructor)
Elementfunktion einer Klasse, die beim Erzeugen eines Objekts der Klasse aufgerufen wird und
dazu dient, das neu angelegte Objekt in einen sinnvollen Startzustand zu bringen.
Mengenklasse
Klasse, die dazu dient, eine Menge von Objekten zu verwalten. In C++ werden Mengenklassen
auch als Container bezeichnet.
Methode (method)
Objektorientierte Bezeichnung für die Implementierung einer Operation, die für ein Objekt einer
Klasse aufgerufen werden kann.
In C++ wird eine Methode als Elementfunktion (oder Memberfunktion) bezeichnet.
Nachricht (message)
Objektorientierte Bezeichnung für den Aufruf einer Operation für ein Objekt. Der Aufruf wird
als Nachricht (oder Botschaft) betrachtet, die an das Objekt gesendet wird und bei diesem Objekt
zu einer Reaktion führt.
Glossar 587
Objekt (object)
Zentraler Begriff der objektorientierten Programmierung. Ein Objekt ist ein Informationsträger,
der verschiedene Daten repräsentiert, die einen bestimmten Zustand (Wertebelegung) besitzen.
Ein Objekt kann dabei beliebig abstrakt, wie zum Beispiel ein Vorgang sein. Ein Objekt ist ein-
fach etwas“, das in einem Programm von Interesse ist, beschrieben wird und eine Rolle spielt.
”
Die Eigenschaften von Objekten (die Daten, die sie repräsentieren, und die Operationen, die
mit ihnen durchgeführt werden können) werden in Klassen beschrieben.
Persistenz (persistence)
Die Fähigkeit, dass Objekte mit ihren Zuständen beim Beenden und Neustarten eines Prozesses
erhalten bleiben. Persistente Objekte müssen also gesichert und wieder geladen werden können.
Polymorphie (polymorphism)
Die Fähigkeit, dass für verschiedenartige Objekte die gleiche Operation aufgerufen werden kann,
die aber je nach Objekt zu unterschiedlichen Reaktionen führt.
Im objektorientierten Sprachgebrauch bedeutet das, dass verschiedenartige Objekte die glei-
che Nachricht erhalten können, diese aber unterschiedlich interpretiert wird, da die Klassen un-
terschiedliche Methoden für die Nachricht besitzen.
In C++ wird damit insbesondere die Fähigkeit bezeichnet, dass unterschiedliche Objekte zeit-
weise unter einer gemeinsamen Klasse als Oberbegriff verwaltet werden können, wobei ein und
derselbe Funktionsaufruf in Abhängigkeit von der tatsächlichen Klasse unterschiedliche Ele-
mentfunktionen aufruft.
Referenz (reference)
Ein zweiter Name“, der für ein existierendes Objekt vergeben werden kann.
”
Referenzen dienen insbesondere dazu, bei der Übergabe von Parametern und Rückgabe-
werten keine Kopien anzulegen ( call-by-reference“).
”
Source-Datei siehe Quelldatei
Struktur (structure)
Zusammenstellung verschiedener Einzelteile zu einem gemeinsamen Gesamtobjekt.
Strukturen bieten eine Möglichkeit zu abstrahieren, indem z.B. ein Motor, eine Karosserie,
vier Räder usw. zu einem Auto zusammengefasst und verwaltet werden.
Sandini Bib
588 Glossar
Template
Schablone einer Funktion (Funktionstemplate) oder einer Klasse (Klassentemplate), die für un-
terschiedliche Typen verwendet werden kann.
Mit Templates müssen Funktionen oder Klassen, die sich nur in Hinblick auf verwendete
Datentypen unterscheiden, nur einmal implementiert werden.
Vererbung (inheritance)
Zurückführen eines Typs bzw. einer Klasse auf einen anderen Datentyp, indem nur die neu hin-
zugekommenen Eigenschaften zusätzlich definiert werden.
Die Vererbung bietet die Möglichkeit, gemeinsame Eigenschaften verschiedener Objektarten
zusammenzufassen und nur einmal zu implementieren. Damit wird auch das Abstraktionsmittel
des Oberbegriffs unterstützt, indem die gemeinsamen Eigenschaften der Objekte, die durch den
Oberbegriff ausgedrückt werden, als gemeinsame Basisklasse definiert werden, von denen die
einzelnen Varianten abgeleitet werden.
Verweis
Ein Oberbegriff für Referenz oder Zeiger, die beide dazu dienen, auf ein Objekt zu verweisen.
Whitespace
Gemeinsame Bezeichnung für die Standard-Trennzeichen unter C, C++ und UNIX. Dies sind in
der Regel das Zeichen Newline, das Leerzeichen und das Tabulator-Zeichen.
Zeiger (pointer)
Objekt bzw. Variable, dasbzw. die eine Programmadresse enthält und somit auf etwas anderes im
Programm zeigt. Ein Zeiger ist z.B. die Adresse einer Funktion oder eines Objekts, auf das dann
indirekt zugegriffen werden kann.
Sandini Bib
Index
590 Index A
Index B 591
592 Index C
Index D 593
594 Index E
Index F 595
596 Index G
Index H 597
598 Index I
Index K 599
600 Index K
Index L 601
602 Index M
Index N 603
N noshowpoint 494
noshowpos 491
Nachricht 13, 65, G586 noskipws 496
name() nothrow 548
für Locales 499 nothrow_t 548
für typeid 328 nounitbuf 496
Name-Mangling 566 nouppercase 491
Namensbereich 22, 59, 137, 183 npos
anonym 61 für Strings 68
Koenig-Lookup 184 nth_element() 531
using 183 NULL Konstante 112, 364
Using-Deklaration 183 und delete 122
Using-Direktive 94, 183 numeric limits 533
Namensgebung 22, 134 numeric_limits 533
Namenskonflikt 59 numerische Algorithmen 532
bei Mehrfachvererbung 338 numerische Ausgabe 490
Komponente und Elementfunktion 401 numerische Limits 533
namespace siehe Namensbereich, 59, 137
nested class 428
O
new[] 122, 572 Oberbegriff 304
nothrow 548 Oberklasse siehe Basisklasse, 261, G586
selbst implementieren 554 Objekt 11, 64, G587
überladen 554 Adresse 343, 367
new 120, 121, 572 als Komponenten 410
für Array ohne Elemente 124, 364 als Objekt der Basisklasse 277, 343
für Feld ohne Elemente 124, 364 Anzahl 418
Implementierung 552 Arrays 152
kontra malloc() 120 Begriff 22
mit Parametern 557 Felder 152
nothrow 548 global 151
Placement-New 548 IDs 418
Rückgabewert 549 statisch 151
selbst definierte Parameter 557 zerstören 121, 151, 248, 290
selbst implementieren 554 Objektdatei 54
Speicherplatzgröße ändern 557 oct 491, 492, 511
überladen 554 ofstream 500
<new> 549 oktale Ein-/Ausgabe 490
New-Handler 549, 559 op= 41, 164, 573
next_permutation() 531 open() 504
nicht-modifizierende Algorithmen 528 für Streams 504
noboolalpha 495 Operation
nocreate 503 abstrakt 307, 312
nontype Template-Parameter 452, 544 Operator 39, 571
noreplace 503 / 40, 572
noshowbase 492 -- 42, 171, 572
Sandini Bib
604 Index P
Index Q 605
606 Index S
Index S 607
608 Index S
Index S 609
610 Index T
Index U 611
612 Index W
Index Z 613
Copyright
Daten, Texte, Design und Grafiken dieses eBooks, sowie die eventuell angebotenen
eBook-Zusatzdaten sind urheberrechtlich geschützt. Dieses eBook stellen wir
lediglich als persönliche Einzelplatz-Lizenz zur Verfügung!
Jede andere Verwendung dieses eBooks oder zugehöriger Materialien und
Informationen, einschliesslich
• der Reproduktion,
• der Weitergabe,
• des Weitervertriebs,
• der Platzierung im Internet,
in Intranets, in Extranets,
• der Veränderung,
• des Weiterverkaufs
• und der Veröffentlichung
bedarf der schriftlichen Genehmigung des Verlags.
Insbesondere ist die Entfernung oder Änderung des vom Verlag vergebenen
Passwortschutzes ausdrücklich untersagt!
Bei Fragen zu diesem Thema wenden Sie sich bitte an: info@pearson.de
Zusatzdaten
Möglicherweise liegt dem gedruckten Buch eine CD-ROM mit Zusatzdaten bei. Die
Zurverfügungstellung dieser Daten auf unseren Websites ist eine freiwillige Leistung
des Verlags. Der Rechtsweg ist ausgeschlossen.
Hinweis
Dieses und viele weitere eBooks können Sie rund um die Uhr
und legal auf unserer Website
http://www.informit.de
herunterladen