C++ PROGRAMMIEREN IM KLARTEXT
MARKO MEYER
C++ PROGRAMMIEREN IM KLARTEXT
>>> NEW TECH
ein Imprint von Pearson Education München • Boston • San Francisco • Harlow, England Don Mills, Ontario • Sydney • Mexico City Madrid • Amsterdam
Bibliografische Information Der Deutschen Bibliothek Die Deutsche Bibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.ddb.de abrufbar. Die Informationen in diesem Buch 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 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 Produkt wurde auf chlorfrei gebleichtem Papier gedruckt.
10 9 8 7 6 5 4 3 2 1 07 06 05 04
ISBN 3-8273-7093-0
© 2004 Pearson Studium ein Imprint der Pearson Education Deutschland GmbH Martin-Kollar-Straße 10-12, D-81829 München/Germany Alle Rechte vorbehalten www.pearson-studium.de Lektorat: Dr. Isabel Schneider,
[email protected] Korrektorat: Barbara Decker, München Einbandgestaltung: h2design.de, München Herstellung: Monika Weiher,
[email protected] Satz: mediaService, Siegen (www.media-service.tv) Druck und Verarbeitung: Kösel, Kempten (www.KoeselBuch.de) Printed in Germany
C++ PROGRAMMIEREN IM KLARTEXT
INHALTSVERZEICHNIS KAPITEL 1 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 1.11
KAPITEL 2 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 2.10
KAPITEL 3 3.1 3.2
ZUR REIHE „IM KLARTEXT“ VORWORT GRUNDLAGEN VON C++
9 10 13
Ein einfaches C++-Programm Variablen Funktionen Eigene Headerdateien Überladung von Funktion Steuerstrukturen Zeiger und Felder Verschiedene Versionen der main-Funktion Zusammenfassung Übungen Literaturempfehlungen
13 17 22 27 30 31 34 38 40 40 41
KLASSEN
43
struct Kapselung Erzeugung, Kopie und Zerstörung von Objekten static-Member Zugriffsschutz class Operatoren Zusammenfassung Übungen Literaturempfehlungen
43 50 53 59 61 66 69 78 78 79
VERERBUNG
81
Bildung von einfachen Klassenhierarchien Vererbung in C++
81 82
3.3 3.4 3.5 3.6 3.7 3.8 3.9
KAPITEL 4 4.1 4.2 4.3 4.4 4.5 4.6
KAPITEL 5 5.1 5.2 5.3 5.4 5.5
KAPITEL 6 6.1 6.2 6.3 6.4 6.5 6.6
KAPITEL 7 7.1 7.2 7.3 7.4 7.5 7.6 7.7
6
Polymorphie Abstrakte Basisklassen protected-Zugriffsschutz Virtuelle Destruktoren Zusammenfassung Übungen Literaturempfehlungen
85 87 89 92 92 92 93
TEMPLATES
95
Generische Programmierung Templatefunktionen Templateklassen Zusammenfassung Übungen Literaturempfehlungen
95 95 97 104 104 105
EXCEPTIONS
107
Klassische Behandlung von Fehlern Ausnahmebehandlung Zusammenfassung Übungen Literaturempfehlungen
107 110 114 115 115
IOSTREAMS
117
Ein- und Ausgabestreams Dateiein- und -ausgaben Stringstreams Zusammenfassung Übungen Literaturempfehlungen
117 123 127 129 129 130
SEQUENZIELLE CONTAINER UND ITERATOREN
131
Container Iteratoren Standard-Container und ihre Eigenschaften Iteratorkategorien Zusammenfassung Übungen Literaturempfehlungen
131 132 132 141 148 148 149
INHALTSVERZEICHNIS
KAPITEL 8 8.1 8.2 8.3 8.4 8.5 8.6 8.7 8.8 8.9 8.10 8.11
KAPITEL 9 9.1 9.2 9.3 9.4 9.5
STRINGS
151
Konstruktion und Zuweisungsoperatoren von strings Zugriff auf die einzelnen Zeichen des strings Ermittlung der Länge von strings Anhängen und Einfügen Suchen und Ersetzen Löschen von Zeichen Erzeugung von Substrings Umwandlung in C-Strings Zusammenfassung Übungen Literaturempfehlungen
151 152 153 153 156 159 160 160 162 162 163
ASSOZIATIVE CONTAINER
165
Die Standardcontainer map und multimap Die Standardcontainer set und multiset Zusammenfassung Übungen Literaturempfehlungen
165 168 168 169 169
KAPITEL 10 ALGORITHMEN 10.1 10.2 10.3 10.4 10.5 10.6 10.7
Einteilung und Formulierung der Algorithmen Nichtmodifizierende Algorithmen Modifizierende Algorithmen Funktionen und Funktionsobjekte Zusammenfassung Übungen Literaturempfehlungen
KAPITEL 11 MANUELLE SPEICHERVERWALTUNG 11.1 11.2 11.3 11.4 11.5
Automatische Speicherverwaltung Verwendung von new und delete Zusammenfassung Übungen Literaturempfehlungen
ANHANG OPERATOREN IN C++ REGISTER INHALTSVERZEICHNIS
171 171 172 176 185 189 189 190
191 191 192 201 202 202
205 207 7
ZUR REIHE „IM KLARTEXT“ Sie halten einen Band der Reihe „Im Klartext“ in den Händen. Und das ist gut so. Denn mit dem Kauf oder dem Ausleihen dieses Buchs (Ersteres ist uns als Verlag natürlich lieber) haben Sie sich in die optimale Ausgangslage gebracht, um sich nun rasch und effektiv auf eine Vorlesung, Klausur oder mündliche Prüfung vorbereiten zu können. Oder um sich einen Überblick über ein bestimmtes, für Sie neues Thema zu verschaffen. Oder um schon fast vergessenes Grundwissen aufzufrischen. Oder ... Es gibt viele gute Gründe, zu einem „Im Klartext“-Buch zu greifen, und unterschiedliche Möglichkeiten, es durchzuarbeiten. Sie können das Buch ganz klassisch von der ersten bis zur letzten Seite durchlesen oder spontan einzelne Themen herausgreifen und sich nur das entsprechende Kapitel anschauen. Bei der Orientierung helfen Ihnen die Lernziele am Kapitelanfang und die Kapitelzusammenfassungen. Natürlich kann ein schmales Buch wie dieses nicht das gesamte Wissen eines komplexen Fachgebiets oder einer vierstündigen Vorlesung über zwei Semester enthalten. Deshalb gibt es in jedem „Im Klartext“ Hinweise auf weiterführende Literatur, mit der Sie das nun vorhandene Grundwissen anschließend vertiefen können. In den aus dem Englischen übersetzten „Im Klartext“-Büchern wurden die Literaturhinweise am Ende des Buchs um deutschsprachige Literatur ergänzt. Um eine bessere Lesbarkeit zu erreichen, wurde auf die Doppelnennung bei Personenbezeichnungen verzichtet, es sind aber selbstverständlich beide Geschlechter gemeint und angesprochen. Alle „Im Klartext“-Bände wurden von Dozenten geschrieben, die ihre Erfahrungen aus dem Unterricht mitbringen und wissen, wo Studierende oft Verständnisprobleme oder Wissenslücken haben. Durch die Übungsaufgaben im Buch können Sie testen, ob Sie alles Gelesene auch verstanden haben. Ausgewählte Lösungen und Beispielprogramme finden sie auf der Companion Website des Buchs unter www.pearson-studium.de. Und nun viel Spaß mit „Im Klartext“ Ihr Lektorat Pearson Studium
ZUR REIHE „IM KLARTEXT”
9
VORWORT Das vorliegende Buch soll eine Orientierungshilfe zum Studium der Programmiersprache C++ sein und eignet sich vor allem für Leser, die eine systematische Zusammenfassung der wichtigsten Teilgebiete von C++ benötigen; sei es studienbegleitend für die Vorbereitung von Prüfungen zum Thema, projektbegleitend als Kompendium und zum schnellen Nachschlagen, oder auch vertiefend zur Auffrischung bereits erworbener Kenntnisse. Grundsätzlich kann man den Inhalt in zwei Abschnitte einteilen. Die Kapitel 1 bis 5 führen in die in C++ verwendbaren Programmierparadigmen ein; in die Grundlagen der Programmentwicklung mit C++, prozedurale Konzepte (Funktionen), objektorientierte Konzepte (Klassen und Vererbung), Konzepte der generischen Programmierung (Templates), sowie in die Fehlerbehandlung mit Hilfe von Exceptions. Die Kapitel 6 bis 11 führen in Datentypen und Funktionen der C++-Standardbibliothek ein. Insbesondere diese Kapitel sind es, die erfahrungsgemäß auch für Programmierer nützlich sein könnten, die vor langer Zeit von C auf C++ umgestiegen sind, und die Konstrukte der erst seit relativ kurzer Zeit bei vielen Compilern stabilisierten Standardbibliothek noch nicht kennen gelernt haben. Der Leser kann das Buch von vorn nach hinten durcharbeiten; vor allem die ersten 5 Kapitel sind so geschrieben, dass eine aufeinanderfolgende Betrachtung der Inhalte unterstützt wird. Insbesondere die Kapitel über die C++-Standardbibliothek können jedoch auch jeweils einzeln betrachtet werden. Gelegentlich werden Beispiele aus vorderen Kapiteln weitergeführt, dies sollte jedoch auch einer eigenständigen Betrachtung keinen Abbruch tun. Das Buch versucht, in Kürze möglichst viele Konzepte darzustellen, um dem Leser eine Orientierung über die Fähigkeiten von C++ zu geben. Ausgehend von diesem Buch kann er dann mit Hilfe der in jedem Kapitel angebotenen Literaturhinweise das ihn interessierende Thema weiterverfolgen. Um die Auswahl der weiterführenden Literatur zu erleichtern, habe ich versucht, die Literaturhinweise so konkret wie möglich zu fassen. Das Bild, das ich dabei vor Augen hatte, war ein Student, der mit dem vorliegenden Buch in der Hand in die Bibliothek geht, um sich dort die entsprechenden weiterführenden Texte aus dem Regal zu suchen, die jeweils erwähnten Kapitel kurz anzulesen und dann zu entscheiden, welches der Werke er schließlich mitnimmt.
10
VORWORT
Ich habe auf folgende Bücher zurückgegriffen, die ich alle empfehlen kann: Bjarne Stroustrup: „Die C++-Programmiersprache“, 4. Auflage, Addison-Wesley,
2003. Andrew Koenig, Barbara Moo: „Intensivkurs C++“, Pearson Studium, 2003. Scott Meyers: „Effektiv C++ programmieren“, Addison-Wesley, 2003. Scott Meyers: „Mehr Effektiv C++ programmieren“, Addison-Wesley, 2003. Schader/Kuhlins: „Die C++-Standardbibliothek“, 2. Auflage, Springer, 2002. Angelika Langer und Klaus Kreft: „Standard C++ IOStreams and Locales“, Addison-Wesley, 2000.
Alle im Text dargestellten Beispielprogramme, sowie die Lösungen für einige der Übungsaufgaben sind elektronisch auf der Companion Website von Pearson Education Deutschland (http://www.pearson-education.de) verfügbar. Die Beispielprogramme wurden mit dem freien GNU C++-Compiler g++ in der Version 3.3 auf einem Linux-System getestet; sie sollten jedoch auch mit anderen zum C++-Standard konformen Compilern übersetzbar sein. Alle Programme sind Kommandozeilenanwendungen und stellen keine speziellen Anforderungen an graphische Oberflächen. Bevor wir nun zur Betrachtung der Grundlagen von C++ kommen, möchte ich die Gelegenheit nutzen und mich bei den Menschen bedanken, die mich bei der Herstellung dieses Werkes begleitet und unterstützt haben. Zu allererst muss ich dabei meine Freundin Romy Martinetz nennen, die auf viele gemeinsame Stunden verzichtet hat, und mich immer dann wieder aufgerichtet hat, wenn die Arbeit an diesem Buch nicht so voranging, wie ich es mir vorgestellt hatte. Ihr widme ich dieses Buch. Weiterhin danke ich Frank Grimm für seine hilfreichen Kommentare im Vorfeld des Projekts und bei der Korrektur des Textes und Inhalts, sowie Frau Dr. Schneider und den anderen beteiligten Mitarbeitern von Pearson Studium für die stets problemund reibungslose Zusammenarbeit. Erwähnen möchte ich auch meine Kollegen der foobar GmbH und der Forschungsgruppe an der Westsächsischen Hochschule Zwickau (FH), die mir immer wieder Gelegenheit geben, C++ anzuwenden, und meine Kenntnisse in dieser Sprache zu erweitern und verbessern. Ich wünsche dem Leser viel Spaß bei der Programmierung mit C++! Marko Meyer
VORWORT
Leipzig, Januar 2004
11
ül
1 GRUNDLAGEN VON C++ In diesem Kapitel behandeln wir alle grundlegenden Konzepte von C++. Wer dieses Kapitel durchgearbeitet hat, sollte folgende Kenntnisse und Fähigkeiten erworben haben:
Î Entwicklung einfacher Programme in C++, Î Verwendung grundlegender Einrichtungen der Standardbibliothek von C++, Î Erkennen von Programmstrukturen in fremden C++-Quelltexten, Î Strukturierung des Steuerflusses mit Hilfe von Funktionen. 1.1
EIN EINFACHES C++-PROGRAMM
Wir wollen die grundlegenden Konzepte von C++ anhand eines einfachen C++Programms kennen lernen: 1 2 3 4 5 6 7 8
// Einfaches C++-Programm #include
int main() { std::cout<<"Hallo, Welt!"<<std::endl; return 0; }
Dieses Programm ist vielen als „Hello, world!“-Programm bekannt; es leistet nichts anderes, als „Hallo, Welt!“ auf der Standardausgabe des Programms auszugeben. Wir wollen diesen Quelltext Zeile für Zeile betrachten, um die darin enthaltenen grundlegenden Strukturen von C++ zu betrachten. In Zeile 1 finden wir einen Kommentar. Kommentare werden in C++ durch die Zeichenfolge // eingeleitet und erstrecken sich bis zum Ende der aktuellen Zeile. Sollen mehrzeilige Kommentare verwendet werden, so kann der Anfang des Kommentars durch /* und das Ende des Kommentars durch */ dargestellt werden. Kommentare werden vom Compiler ignoriert und sollen menschlichen Lesern beim Verständnis des Quelltextes helfen.
1
GRUNDLAGEN VON C++
13
Zeile 2 enthält eine #include-Anweisung an den C++-Präprozessor. Sie dient dazu, den Inhalt des Standardheaders iostream innerhalb dieser Quelltextdatei verfügbar zu machen. Ein Standardheader enthält Deklarationen und Definitionen von Komponenten der Standardbibliothek von C++. Bevor wir diese Komponenten verwenden, müssen wir den entsprechenden Header mit Hilfe der #include-Anweisung einbinden. Der Name des Standardheaders erscheint dabei stets in Winkelklammern. Der Standardheader iostream enthält Deklarationen und Definitionen von Ein- und Ausgabekomponenten der C++-Standardbibliothek. Zeile 3 enthält eine Leerzeile. C++-Programme sind Freiform-Texte, deren Formatierung in der Regel keine Rolle beim Compiliervorgang spielt. Daher können Leerzeilen und -zeichen zur optischen Aufbereitung des Quelltextes dienen, um die Lesbarkeit zu erleichtern. Erfahrene Programmierer verwenden insbesondere Einrückungen, um die Programmstruktur übersichtlicher darzustellen. In Zeile 4 beginnt die Definition der Hauptfunktion main unseres Programmes. Die Hauptfunktion stellt den Punkt dar, an dem die Ausführungsumgebung die Ausführung des Programmes startet. Beim Linken des Quelltextes müssen dafür spezielle Vorkehrungen getroffen werden, weshalb der Linker den Startpunkt identifizieren muss. Daher ist festgelegt, dass die Hauptfunktion in C++ eine Funktion mit dem stets gleichen Namen main sein muss. Funktionen dienen der Strukturierung des Quelltextes. Man kann sie als Mikropro-
gramme zur Lösung von Teilproblemen auffassen. Jede Funktion hat einen Namen, mit dessen Hilfe man sie aufrufen kann, um die Abarbeitung ihres Codes zu starten. Jede Funktion hat außerdem die Möglichkeit, bei ihrem Aufruf Daten zu übernehmen und bei ihrer Beendigung Daten an die aufrufende Funktion zurückzugeben. Welche Daten eine Funktion von der aufrufenden Funktion übernehmen kann, wird bei der Definition der Funktion durch eine in runde Klammern gesetzte Parameterliste festgelegt. Gleichzeitig gibt eine Typbezeichnung, die vor dem Namen der Funktion steht, an, welchen Datentyp der Rückgabewert der Funktion hat. Unsere Funktion main ist so definiert, dass sie keine Daten von der aufrufenden Funktion (In diesem Fall ist dies natürlich der Programmlader des Betriebssystems, innerhalb dessen das Programm ausgeführt wird.) übernimmt und einen Wert des Datentyps int an die Ausführungsumgebung zurückgibt. Dieser Rückgabewert der Funktion main ist gleichzeitig der so genannte Exitcode des ausgeführten Programms, der in bestimmten Einsatzumgebungen des Programms (zum Beispiel in der Shellskript- bzw. Batchverarbeitung) Auskunft darüber geben kann, warum das Programm endete, bzw. insbesondere ob die Beendigung des Programms auf einen Fehler zurückzuführen ist. In Zeile 5 befindet sich eine öffnende geschweifte Klammer, die den Beginn des zur Funktion main gehörenden Codes signalisiert. Alle Anweisungen, die bis zur nächsten schließenden geschweiften Klammer (in unserem Fall auf Zeile 7) formuliert sind, gehören zur Funktion main und werden in der im Quelltext angegebenen Reihenfolge ausgeführt, sobald die Funktion main gestartet wird.
14
EIN EINFACHES C++-PROGRAMM
1
Zeile 6 enthält die einzige „Nutzanweisung“ unseres Programms – die einzige Anweisung, die einen sichtbaren Effekt erzeugt. Es handelt sich dabei um eine Ausgabeanweisung, die sich aus vier Komponenten zusammensetzt:
std::cout "Hallo, Welt!" std::endl <<
Die Komponente std::cout ist ein Objekt der C++-Standardbibliothek mit dem Namen cout, das den Standardausgabekanal des Programmes symbolisiert. Wo Ausgaben, die auf dem Standardausgabekanal durchgeführt werden, in der konkreten Arbeitsumgebung des Lesers sichtbar werden, ist davon abhängig, wie das Programm ausgeführt wird. In Textkonsolen (z.B. Windows-Eingabeaufforderung oder Unix-Shell) wird der Standardausgabekanal stets auf die gleiche Konsole geleitet, von der das Programm gestartet wurde. In grafischen Umgebungen entscheiden häufig Einstellungen des Compilers oder Programmladers, wohin Ausgaben dieses Kanals gelangen. Der Sprachstandard von C++ enthält dazu keinerlei Festlegungen. Die Sprache C++ gliedert sich in die Kern- oder Basissprache und die C++-Standardbibliothek. Bestandteil der Basissprache sind einfache Datentypen wie int, short, char, double, alle Schlüsselworte für grundlegende Steuerstrukturen (Alternativen und Schleifen), sowie die Definition der Syntax von C++-Programmen. Die Standardbibliothek enthält hingegen zahlreiche Komponenten, die die Lösung komplexer Probleme mit Hilfe von C++ unterstützen und erleichtern sollen. Alle Namen der Standardbibliothek sind in einem gemeinsamen Namespace zusammengefasst. Namespaces werden in C++ verwendet, um Konflikte zwischen verschiedenen Objekten mit gleichem Namen zu verhindern. Durch die Definition von cout in einem eigenen Namespace der Standardbibliothek ist es einem Programmierer möglich, den Namen cout auch andersweitig zu verwenden. Der Vorsatz std:: macht dies deutlich: Er sagt dem Compiler, dass wir mit std::cout die Komponente namens cout des Namespaces std wollen. Der Namespace std ist stets der Namespace der Standardbibliothek. Als Nächstes finden wir in Zeile 6 den Ausgabeoperator <<. Es handelt sich dabei um einen binären Operator, dessen linker Operand vom Typ std::ostream ist. Der rechte Operand kann unterschiedliche Datentypen aufweisen. Der Ausgabeoperator sorgt dafür, dass der Wert des rechten Operanden an den linken Operanden übergeben wird. Ein Operand vom Typ std::ostream wird den ihm übergebenen Wert ausgeben. Der Typ des rechten Operanden ist char const*. Wir werden auf diesen Datentyp später in diesem Kapitel zurückkommen. Es handelt sich bei diesem Operanden um ein so genanntes Stringliteral, also eine wörtlich aufgeschriebene Zeichenkette. Die Hochkommas dienen dabei der Abgrenzung der zur Zeichenkette gehörenden Zeichen.
1
GRUNDLAGEN VON C++
15
In Stringliteralen können eine Reihe von speziellen Zeichencodes verwendet werden, die stets mit einem Backslash (\) beginnen: \n:
Zeilenendezeichen
\t:
Tabulatorzeichen
\b:
Backspacezeichen
\":
Anführungszeichen als Teil der Zeichenkette
\’:
einfaches Anführungszeichen
\\:
ein \
Eine Kombination aus Operatoren 1 und Operanden wird als Ausdruck bezeichnet. Bei der Ausführung eines Ausdrucks wird in der Regel ein Objekt eines bestimmten Typs als Ergebnis erzeugt. Weiterhin können im Zuge der Ausführung Seiteneffekte auftreten. Welchen Typ das Ergebnis hat und welche Seiteneffekte auftreten wird dadurch bestimmt, welcher Operator und welche Operanden im Ausdruck kombiniert werden. Der Ausgabeoperator erzeugt ein Objekt des Typs std::ostream als Ergebnis, dessen Wert dem linken Operanden entspricht. In unserem Fall liefert er folglich nach der Ausführung von std::cout<<"Hallo, Welt!" das Objekt std::cout 2. Die Ausgabe der Zeichenkette „Hallo, Welt!“ auf dem Standardausgabekanal ist der Seiteneffekt dieser Operation. Nach der Zeichenkette „Hallo, Welt!“ schließt sich ein weiterer Ausgabeoperator << an. Dessen linker Operand ist das nach der Ausführung des vorderen Teils dieser Anweisung entstandene Objekt std::cout, der rechte Operand der Ausgabemanipulator std::endl. Dieser Manipulator fügt ein Zeilenendezeichen in die Ausgabe ein. Wie bereits zuvor, wird auch hier std::cout als Ergebnis erzeugt. Seiteneffekt ist die Ausgabe des Zeilenendes, also der sichtbare Zeilenvorschub in der Standardausgabe. Die Tatsache, dass der Ausgabeoperator als Ergebnis stets seinen linken Operanden hat, erlaubt die hier eingesetzte Verkettung von Ausgabeanweisungen: Die auszugebenden Werte können einfach hintereinander, getrennt durch Ausgabeoperatoren, aufgeführt werden. Das Ziel der Ausgabe muss nur einmal, ganz links, formuliert werden. Zeile 6 wird durch ein Semikolon ; abgeschlossen. Dieses dient der Abgrenzung einer Anweisung. Anweisungen enden stets mit einem Semikolon und können sich im Übrigen auch über mehrere Zeilen erstrecken. Die Anweisung in Zeile 7 dient der Rückgabe eines Wertes aus einer Funktion an die aufrufende Funktion, wobei die Funktion beendet wird und die Steuerung zur aufrufenden Funktion zurückkehrt. Diese Rückgabe wird durch das Schlüsselwort der Basissprache return eingeleitet. Hinter return muss ein Wert des Typs stehen, der als Typ der Rückgabedaten in der Definition der Funktion vermerkt ist. In unserem Fall 1 2
16
Eine Tabelle der Operatoren befindet sich im Anhang. Er liefert prinzipiell eine Referenz auf das Objekt cout; wir wollen uns jedoch erst zu einem späteren Zeitpunkt mit dem Referenzbegriff auseinander setzen.
EIN EINFACHES C++-PROGRAMM
1
handelt es sich um den Datentyp int, der stets als Rückgabetyp der Funktion main verwendet werden muss. Der zurückgegebene Wert wird, wie oben erläutert, vom Betriebssystem als Exitcode des Programms betrachtet. Ein Wert von 0 bedeutet dabei „erfolgreiche Beendigung“, während alle anderen Werte Fehlersituationen andeuten. Da die Funktion main stets einen Rückgabewert erzeugt, kann die Angabe der returnAnweisung hier entfallen; in diesem Fall wird vom Compiler return 0 angenommen. Zeile 8 enthält die zu Zeile 5 gehörige schließende geschweifte Klammer. Damit wird der Definitionsbereich der Funktion main abgeschlossen. In Funktionen, die keine Datenrückgabe erfordern, wird an dieser Position gleichzeitig die Kontrolle an die aufrufende Funktion zurückgegeben. Eine weitere Konsequenz ist, dass alle in der Funktion definierten Variablen an dieser Stelle ihre Gültigkeit verlieren. Der für sie reservierte Speicherplatz wird an das System zurückgegeben und ihr Inhalt geht verloren.
1.2
VARIABLEN
In nahezu jedem in der Praxis einsetzbaren Programm müssen Daten verwaltet und mit diesen Berechnungen angestellt werden. Während der Laufzeit des Programmes werden diese Daten in Variablen gespeichert. Variablen sind – einfach ausgedrückt – Speicherplätze, die einen Namen und einen Datentyp haben. Der Name dient uns dabei zur Bezugnahme auf die konkrete Variable (d.h. auf ihren Wert); der Datentyp legt fest, wie der auf dem Speicherplatz abgelegte Wert interpretiert wird, welche Speichermenge dafür erforderlich ist und – in logischer Konsequenz – welchen Wertebereich die Variable haben wird. Jede Variable hat außerdem eine implizite Lebenszeit. Die Lebenszeit einer Variable beginnt an der Stelle ihrer Definition im Quelltext und endet defaultmäßig am Ende des Blockes , in dem sie definiert wurde. Ein Block ist dabei ein Bereich des Codes, der durch öffnende und schließende geschweifte Klammern begrenzt ist. Es ist nicht möglich, auf den Speicherplatz einer Variablen vor Beginn und nach dem Ende ihrer Lebenszeit zuzugreifen. Im folgenden Beispiel wollen wir in unserer Funktion main eine Variable des Typs string anlegen, um darin den auszugebenden Text zu speichern: 1 2 3 4 5 6 7 8 9
#include #include <string> int main() { const std::string Ausgabe = "Hallo, Welt!"; std::cout << Ausgabe << std::endl; return 0; }
Der Datentyp string ist nicht in der Basissprache, sondern, genau wie ostream, in der Standardbibliothek definiert. Daher müssen wir in Zeile 2 den Standardheader 1
GRUNDLAGEN VON C++
17
<string> einbinden. Als erste Zeile unserer Hauptfunktion haben wir Zeile 6 neu eingefügt. Hier wird eine Variable des Typs std::string mit dem Namen „Ausgabe“ defi-
niert. Wenn wir eine Variable definieren, haben wir die Möglichkeit, dieser einen Anfangsoder Initialwert mitzugeben. Wir sprechen in diesem Fall von einer Initialisierung der Variablen. In unserem Programm initialisieren wir die Variable Ausgabe mit der Zeichenkette "Hallo, Welt!". Weiterhin haben wir das Schlüsselwort const in der Definition der Variablen verwendet. Es legt fest, dass der Wert der Variablen konstant sein soll. Wir können den Wert einer als const definierten Variablen nach der Initialisierung nicht mehr ändern – man spricht in diesem Fall anstelle von „Variable“ auch von einer „Konstanten“. Eine Konstante muss stets initialisiert werden, da wir ihren Wert später nicht mehr ändern können. Ansonsten ändert sich in unserem Programm nichts Wesentliches. Da wir eine Variable mit der auszugebenden Zeichenkette definiert haben, können wir den Namen der Variablen in der Ausgabeanweisung einsetzen. Bei der Auswertung des vorderen Ausgabeausdrucks std::cout << Ausgabe betrachtet der Compiler dabei den Wert (den Inhalt) der Variablen Ausgabe als rechten Operanden. Das Ergebnis der Ausführung des Programms ist das gleiche wie zuvor. Wir wollen nunmehr einen Schritt weitergehen und Daten in eine Variable einlesen, bevor wir sie zur weiteren Verarbeitung verwenden. Dazu wollen wir unser Programm so umbauen, dass es die Eingabe eines Vornamens erlaubt, so dass wir die Grußmeldung etwas persönlicher gestalten können. 1 2 3 4 5 6 7 8 9 10 11 12 13
#include #include <string> using std::string; using std::cout; using std::cin; using std::endl; int main() { string Name; cin >> Name; cout << "Hallo, " << Name << "!" << endl; return 0; }
Abgesehen von der Eingabe des Vornamens, die wir gleich diskutieren werden, haben wir hier auch die using-Direktive verwendet, die uns die Verwendung von Komponenten der Standardbibliothek in unseren Programmen etwas erleichtert. Indem wir zum Beispiel schreiben using std::string; sagen wir, dass wir jedes Mal, wenn wir in der entsprechenden Quelltextdatei den Namen string als Namen eines Typs verwenden, eigentlich std::string meinen. Auf diese Weise ersparen wir es uns,
18
VARIABLEN
1
bei jeder Benutzung des Datentyps string std::string hinzuschreiben – bei entsprechend häufiger Verwendung lohnt sich das! In der Hauptfunktion main können wir nunmehr das std:: weglassen. Wir sehen in Zeile 9 die Definition einer Variablen Name des Datentyps string. Hier liegt keine Initialisierung vor, denn anders als im vorigen Programm geben wir hinter dem Namen der Variablen keinen durch das Zeichen = getrennten Anfangswert an. Wenn eine Variable ohne Anfangswert definiert wird, hängt von der Art des Datentyps ab, welchen Wert die Variable unmittelbar nach der Initialisierung hat. Es gibt zwei grundsätzliche Arten von Datentypen: Klassentypen und Basistypen . Alle in der Standardbibliothek definierten Datentypen sind Klassentypen, während die Basistypen Bestandteil der Basissprache sind. Wenn Variablen von Basistypen nicht initialisiert werden, haben sie zu Beginn einen undefinierten Wert. Prinzipiell enthalten sie einfach den Wert, der zufällig an der Stelle im Speicher stand, an der der Compiler sie platziert hat. Bei Klassentypen kann der Entwickler des Datentyps festlegen, welchen Wert eine Variable des Typs nach der Definition haben soll. Wenn wir uns mit vordefinierten Typen – wie denen der Standardbibliothek – beschäftigen, sagt uns die Dokumentation (z.B. die Online-Hilfe des Compilers oder im Zweifelsfalle der C++-Standard), welchen Anfangswert entsprechende Variablen haben werden, bzw. auf welche Weise wir sie initialisieren können. Da wir im Kapitel 2 eigene Datentypen definieren, werden wir auf diese Problematik in Kürze einmal zurückkommen. Der Datentyp string ist ein Klassentyp, und es ist festgelegt, dass Variablen dieses Typs nach einer Definition die leere Zeichenkette ("") enthalten, sofern keine Initialisierung (z.B. mit einer anderen Zeichenkette) vorgenommen wurde. Für die Zwecke unseres Programms ist es jedoch uninteressant, welchen Anfangswert die Variable Name hat, denn bereits in Zeile 10 wird in diese Variable ein Wert vom Standardeingabekanal eingelesen. Dazu verwenden wir das Objekt cin, das im Standardheader definiert ist und seinerseits den Datentyp istream hat. Das Objekt cin ist mit dem Standardeingabekanal verbunden, der – genau wie der Standardausgabekanal – in einer von der Ausführungsumgebung abhängigen Art und Weise dem Nutzer des Programms zur Verfügung steht. Starten wir das Programm zum Beispiel in einer Textkonsole, so wird es zunächst gar nichts tun. Dies liegt daran, dass wir als erste „sichtbare“ Anweisung eine Eingabeanweisung haben. Das Programm wartet auf eine Nutzereingabe und blockiert so lange, bis wir einige Zeichen eingeben und die Eingabe mit der Enter-Taste abschließen. Danach wird auf dem Bildschirm die Grußmeldung erscheinen, und die Zeichen, die wir anfangs eingegeben haben, werden an der entsprechenden Stelle in die Ausgabe eingefügt sein. Natürlich wäre es besser, wenn das Programm unmittelbar nach dem Start darauf hinweisen würde, dass es eine Eingabe vom Nutzer erwartet. Wir wollen dies in der nächsten Version berücksichtigen und einen so genannten Prompt, also eine Eingabeaufforderung, hinzufügen: 1
GRUNDLAGEN VON C++
19
1 2 3 4 5 6 7 8 9 10 11 12 13 14
#include #include <string> using std::string; using std::cout; using std::cin; using std::endl; int main() { cout << "Bitte geben Sie Ihren Namen ein: "; string Name; cin >> Name; cout << "Hallo, " << Name << "!" << endl; return 0; }
Die neu eingeschobene Zeile 9 enthält nunmehr die Ausgabe der Aufforderung Bitte geben Sie Ihren Namen ein: . Wir geben kein Zeilenendezeichen aus, denn wir wollen, dass der Cursor nach dem Doppelpunkt stehen bleibt, so dass wir den Namen in der gleichen Zeile eingeben können. Beispielhaft sehen wir hier einen „normalen“ Programmablauf: [mme@endeavour Source]: ./gruss Bitte geben Sie Ihren Namen ein: Ulrich Hallo, Ulrich!
Wir können nun etwas experimentieren. Was passiert, wenn wir vor dem Namen Leerzeichen oder Tabulatoren eingeben? [mme@endeavour Source]: ./gruss Bitte geben Sie Ihren Namen ein: Hallo, Ulrich!
Ulrich
Offensichtlich beeinflusst dies die Ausgabe nicht. Tatsächlich werden beim Einlesen von Daten im allgemeinen Fall führende Leerzeichen (dazu zählen auch Tabulatoren und Zeilenendezeichen) ignoriert. Was passiert, wenn wir Vor- und Nachnamen eingeben? [mme@endeavour Source]: ./gruss Bitte geben Sie Ihren Namen ein: Ulrich Tester Hallo, Ulrich!
Wie wir sehen, wurde der Nachname, „Tester“, nicht mit in die Ausgabe aufgenommen. Offensichtlich enthält die Variable Name in unserem Programm nur den Vornamen. Die Ursache dafür ist, dass ein Einlesevorgang immer an einem Leerzeichen (bzw. Tabulator- oder Zeilenendezeichen) endet. In die Variable, in die wir einlesen, wandert jeweils nur der Teil der Eingabe, der sich vom ersten Nicht-Leerzeichen bis zum ersten Leerzeichen erstreckt. Die in die Variable eingelesenen Zeichen werden dabei im Eingabekanal gelöscht. Eine nachfolgende weitere Eingabeanweisung würde bei unserer Beispiel-Eingabe nur den Teil auswerten, der auf „Ulrich“ folgt.
20
VARIABLEN
1
Weitere Betrachtungen zu Ein- und Ausgaben machen wir im Kapitel 6, das sich dediziert mit dem iostream-Teil der Standardbibliothek beschäftigt. Die in unserem Programm definierte Variable Name hat eine Lebenszeit, die prinzipiell mit der Ausführungszeit des Programms übereinstimmt. Sie existiert ab dem Zeitpunkt, an dem der Code in Zeile 10 der Hauptfunktion ausgeführt wird, bis zu dem Zeitpunkt, an dem das Programm endet. In folgendem Beispiel haben wir zwei Variablen vom Basisdatentyp int, in denen wir zwei Werte speichern, die wir nachfolgend ausgeben: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
#include using std::cout; using std::endl; int main() { int a = 10; { int b = 20; cout << "a = " << a << endl; cout << "b = " << b << endl; } }
Die Variable b wird dabei in einem eigenen Block definiert, d.h. b ist nur innerhalb des Blocks gültig, in dem die Variable definiert wurde. Die Gültigkeit von a wird durch den äußeren Block, also die Hauptfunktion main begrenzt, so dass a sowohl im äußeren, als auch im inneren Block gültig ist. Wir wollen dies testen, indem wir den Code compilieren und ausführen. [mme@endeavour Source]: ./gueltig a = 10 b = 20
Wir ändern das Programm nunmehr so ab, dass beide Ausgabeanweisungen im unteren Bereich des äußeren Blocks stehen: 1 2 3 4 5 6 7 8 9 10 11 12
1
#include using std::cout; using std::endl; int main() { int a = 10; { int b = 20; } cout << "a = " << a << endl;
GRUNDLAGEN VON C++
21
13 14 15 }
cout << "b = " << b << endl;
Erneut wollen wir den Code compilieren und testen. Wir stoßen jedoch auf ein Problem: der Compiler meldet einen Fehler. Mein Compiler sagt zum Beispiel: ungueltig.cc:13: error: ‘b’ undeclared (first use this function)
Er meldet also, dass der in Zeile 13 verwendete Bezeichner „b“ undeklariert ist. Die Fehlermeldung kann bei dem vom Leser verwendeten Compiler natürlich abweichen; der Inhalt ist jedoch derselbe: der Compiler kann mit der in Zeile 13 verwendeten Variable b nichts anfangen, sie ist an dieser Stelle nicht definiert. Der Definitionsbereich von b ist also – wie unser Experiment gezeigt hat, tatsächlich auf den inneren Block beschränkt.
1.3
FUNKTIONEN
Eine weitere Grundlage der Strukturierung von Programmen in C++ sind Funktionen. Man kann sich eine Funktion als einen benannten Abschnitt des Programmsteuerflusses vorstellen; als ein Mikroprogramm, das einen bestimmten Teil der Gesamtaufgabe des Programmes löst. Jede Funktion hat vier Bestandteile: Namen Rückgabetyp Übergabeparameterliste Funktionskörper
Eine Funktion muss vor ihrer Benutzung, d.h. ihrem Aufruf im Programm, deklariert sein. Das bedeutet, dass der Compiler an der Stelle im Programm, an der die Funktion aufgerufen wird, wissen muss, welchen Namen, welche Übergabeparameter und welchen Rückgabetyp die Funktion haben wird. Er muss an dieser Stelle hingegen nicht wissen, welchen Code die Funktion enthält. Die Festlegung, welchen Code eine Funktion enthält, das heißt, wie sie intern abgearbeitet wird, wird als Definition der Funktion bezeichnet. Legen wir nur fest, welchen Namen, Rückgabetyp und welche Übergabeparameter eine Funktion enthält, so führen wir die Deklaration der Funktion durch. Betrachten wir uns einmal die Definition einer Funktion addiere, die zwei ganzzahlige Werte addiert und das Ergebnis zurückliefert: 1 int addiere (int a, int b) 2 { 3 return a + b; 4 }
Wie wir sehen, ist diese Funktion angesichts des Operators +, der jederzeit zur Addition zweier Werte verwendet werden kann, in der Praxis nicht sinnvoll. Wir können
22
FUNKTIONEN
1
anhand einer solchen einfachen Funktion jedoch sehr gut alle grundlegenden Eigenschaften einer Funktion erkennen: Den Namen addiere, den Rückgabetep int – vermerkt unmittelbar vor dem Namen der Funktion –, die Parameterliste, die zwei Werte vom Typ int enthält, auf die wir in der Funktion unter den Namen a und b zugreifen können, den Körper der Funktion, in dem die beiden übergebenen Werte addiert werden und das Ergebnis mit Hilfe der return-Anweisung zurückgeliefert wird.
Das Beispiel stellt – da ein Funktionskörper vorhanden ist – somit die Definition der Funktion addiere dar. Die entsprechende Deklaration der selben Funktion sieht folgendermaßen aus: 1 int addiere(int, int);
Hierbei wird lediglich der Rückgabetyp, Name und die Typen der Übergabeparameter angegeben – die Namen der Übergabeparameter dürfen zwar angegeben werden, haben an dieser Stelle jedoch keinerlei praktischen Nutzen. Am Ende der Deklaration steht ein Semikolon. Wie benutzen wir diese Funktion in einem Programm? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
#include using std::cin; using std::cout; using std::endl; int addiere(int a, int b) { return a + b; } int main() { cout << "Bitte erste Zahl eingeben: "; int erste_zahl, zweite_zahl; cin >> erste_zahl; cout << "Bitte zweite Zahl eingeben: "; cin >> zweite_zahl; cout << "Die Summe der beiden Zahlen ist " << addiere(erste_zahl, zweite_zahl) << "." << endl; }
Wie wir in obigem Programm sehen, wird die Funktion einfach dadurch aufgerufen, indem der Name der Funktion gefolgt durch die in Klammern angegebenen Übergabeparameter aufgerufen wird. Die Übergabeparameter müssen dabei bezüglich ihres Typs zu den in der Funktionsdeklaration angegebenen Übergabeparametertypen passen. 1
GRUNDLAGEN VON C++
23
Wie wir ebenfalls sehen, haben die in der Funktion verwendeten Namen mit den außerhalb der Funktion für die entsprechenden Parameter verwendeten Namen nichts zu tun. Der Wert der Variablen erste_zahl wird innerhalb der Funktion unter dem Namen a benutzt, der Wert der Variablen zweite_zahl unter dem Namen b. Wenn wir Variablen so übergeben, wie oben anhand der Funktion addiere dargestellt, werden die Werte der übergebenen Variablen kopiert. Man spricht in diesem Fall von call by value oder Wertübergabe. Ein Nebeneffekt von call by value ist, dass Änderungen der Parameterwerte innerhalb der Funktion keinerlei Auswirkungen auf die übergebenen Variablen außerhalb der Funktion haben. Wir können dies mit Hilfe einer Funktion überprüfen, die gezielt den ihr übergebenen Wert ändert: 1 void aendere(int wert) 2 { 3 wert += 42; 4 cout << "In der Funktion: [" << wert << "]" << endl; 5 }
Diese Funktion ändert den ihr übergebenen Parameter wert, indem sie mit Hilfe des Compound-Operators 42 zum vorherigen Inhalt von wert addiert. Anschließend wird der neue Wert auf die Standardausgabe ausgegeben. Wir wollen unsere Funktion aufrufen und den Wert der ihr übergebenen Variable vor und nach dem Aufruf kontrollieren. 1 int main() 2 { 3 int variable = 100; 4 cout << "Variable vor dem Funktionsaufruf: [" << variable 5 << "]" << endl; 6 7 aendere(variable); 8 9 cout << "Variable nach dem Funktionsaufruf: [" << variable 10 << "]" << endl; 11 }
Der Kürze halber wurden hier die #include-Anweisungen und using-Deklarationen weggelassen. Wenn wir das Programm ausführen sehen wir folgende Ausgaben: [mme@endeavour Source]: ./callbyvalue Variable vor dem Funktionsaufruf: [100] In der Funktion: [142] Variable nach dem Funktionsaufruf: [100]
24
FUNKTIONEN
1
Es gibt auch eine zweite Art der Übergabe von Daten an eine Funktion, die als call by reference oder Referenzübergabe bezeichnet wird. Mit ihrer Hilfe ist es möglich, übergebene Parameterwerte innerhalb der Funktion zu ändern, so dass die Änderung auch außerhalb der aufgerufenen Funktion sichtbar wird. Wir müssen dazu unsere Funktion aendere anders formulieren: 1 void aendere(int& wert) 2 { 3 wert += 42; 4 cout << "In der Funktion: [" << wert << "]" << endl; 5 }
Der Unterschied ist beim flüchtigen Hinschauen leicht zu übersehen. Er liegt in der Definition des Parameters wert im Kopf der Funktion. Anstatt int wert haben wir int& wert geschrieben. Das & signalisiert, dass wir keine Wert- sondern eine Referenzübergabe durchführen wollen. Eine Referenz ist dabei gewissermaßen ein andere Name für eine Variable. Bei der Übergabe einer Variable an die neue Funktion aendere wird deren Wert nicht mehr kopiert, sondern es wird einfach in der Funktion ein anderer Name dafür verwendet. Sowohl in der Funktion, als auch außerhalb, wird auf den gleichen Speicherplatz zugegriffen, weshalb sich Änderungen am Wert der Variablen sowohl innerhalb, als auch außerhalb der Funktion auswirken. Wir können dies mit unserer Hauptfunktion main oben testen. Da wir außer der Übergabeparameterdefinition nichts geändert haben, müssen wir an ihr keine Modifikationen vornehmen. Als Ausgaben sehen wir: [mme@endeavour Source]: ./callbyreference Variable vor dem Funktionsaufruf: [100] In der Funktion: [142] Variable nach dem Funktionsaufruf: [142]
Der Unterschied zu call by value sollte deutlich geworden sein: Nach dem Funktionsaufruf sehen wir hier einen veränderten Wert der Variablen variable. Die Referenzübergabe ist jedoch nicht nur dann nützlich, wenn innerhalb der Funktion Werte von Übergabeparametern verändert werden sollen; er ist auch dann interessant, wenn wir Parameter übergeben wollen, die sehr viel Speicher benötigen, weswegen wir nur ungern Kopien davon erzeugen wollen. In diesem Fall könnte es schädlich für unser Programm sein, wenn in der Funktion versehentlich die als Referenz übergebenen Parameter verändert werden. Die Sprache C++ sieht daher eine Möglichkeit vor, per Referenz übergebene Parameter vor Veränderungen zu schützen. Dazu dient das bereits bekannte Schlüsselwort const: 1 void aendere(int const& wert) 2 { 3 wert += 42; 4 cout << "In der Funktion: [" << wert << "]" << endl; 5 }
1
GRUNDLAGEN VON C++
25
Es wird einfach in die Übergabeparameterdefinition eingefügt; neben der Schreibweise int const& wert, die wir oben sehen, ist auch die Schreibweise const int& wert möglich. Beide Formulierungen sind in ihrer Wirkung äquivalent. Das Schlüsselwort const sorgt dafür, dass der Compiler prüft, ob Schreibzugriffe auf den damit ausgezeichneten Übergabeparameter erfolgen. Wenn dies der Fall ist, bricht der Compiler die Übersetzung des Quelltextes mit einem Fehler ab: /usr/bin/g++ callbyconstreference.cc -o callbyconstreference callbyconstreference.cc: In function ‘void aendere(const int&)’: callbyconstreference.cc:8: error: assignment of read-only reference ‘wert’
Auf diese Weise kann der Fehler also bereits zum Zeitpunkt der Übersetzung gefunden werden; dies ist im Allgemeinen günstiger, als den Fehler erst zum Einsatzzeitpunkt des Programms suchen zu müssen. Die im Beispiel gezeigt Übergabe eines Wertes vom Datentyp int als const& ist jedoch nicht sinnvoll. Der von int verwendete Speicher ist so gering, dass eine Wertübergabe hier eher verwendet wird, um zu verhindern, dass sich Änderungen an der Variablen innerhalb der Funktion auf die aufrufende Funktion auswirken. Um zu entscheiden, welche Art der Übergabe in einer Funktion angewendet werden soll, kann folgende Checkliste dienen: Soll eine Änderung des Wertes innerhalb der Funktion für die aufrufende Funk-
tion sichtbar werden? • ja → Referenzübergabe (call by reference) • nein → Ist der übergebene Datentyp einfach (Basisdatentyp) oder komplex (eigener Datentyp, Datentyp der Standardbibliothek)? • einfach → Wertübergabe (call by value) • komplex → Übergabe als const-Referenz In der obigen Funktion aendere haben wir als Typ des Rückgabewertes das Schlüsselwort void verwendet. Dieses zeigt an, dass die Funktion keinen Wert zurückgeben wird. Funktionen, deren Rückgabetyp void ist, benötigen keine return-Anweisung. Soll der Funktionsablauf einer solchen Funktion an einer beliebigen Stelle vorzeitig beendet werden, so kann auch die return-Anweisung ohne Rückgabeparameter verwendet werden: 1 void keine_rueckgabe() 2 { 3 // Code ... 4 return; 5 }
Gelegentlich ist es erforderlich, Funktionen zu entwickeln, bei denen einige Argumente mit einem bestimmten Wert vorbelegt werden. Der Aufrufer der Funktion kann diese Argumente dann weglassen, sofern er mit dem vordefinierten Wert einverstanden ist. Solche Argumente bezeichnet man als „Defaultargumente“:
26
FUNKTIONEN
1
1 void ausgabe(string const& Name, string const& Vorname, 2 string const& Anrede = "") 3 { 4 cout << Anrede << " " << Vorname << " " << Name << endl; 5 }
Beim Aufruf der Funktion können wir das letzte Argument weglassen: 1 2 3
// ... ausgabe("Müller", "Michael"); // Michael Müller ausgabe("Schulze", "Max", "Dr."); // Dr. Max Schulze
Es können beliebig viele Argumente als Defaultargumente vorbelegt werden, wobei diese stets ohne Lücke ganz rechts in der Argumentliste stehen müssen: void test(int a, int b = 10, int c = 20); // ok. void test(int a = 10, int b, int c = 20); // Fehler! void test(int a = 10, int b = 10, int c); // Fehler!
1.4
EIGENE HEADERDATEIEN
In größeren Programmierprojekten ist es oftmals sinnvoll, Funktionen zu gruppieren und getrennt voneinander in verschiedenen Dateien zu halten. Damit kann zum Beispiel sichergestellt werden, dass die Arbeit verschiedener Teams am gleichen Projekt keine negativen Wechselwirkungen zur Folge hat. Ein weiterer Grund für eine solche Aufsplittung des Projektes könnte sein, dass Funktionen in verschiedenen Projekten verwendet werden sollen. Gruppiert man diese in einer gemeinsamen Quelltextdatei, lässt sich diese Datei leichter zwischen den Projekten austauschen; man kann dann ebenfalls eine Bibliothek erzeugen, die die vielseitig verwendbaren Funktionen enthält. Damit erspart man sich die ständige Neucompilierung dieser Funktionen; außerdem werden Änderungen an ihnen sofort für alle Projekte verfügbar, in denen sie verwendet werden. Nehmen wir zum Beispiel an, wir teilen unser Additionsprogramm oben in zwei Quelltextdateien auf. Eine verwenden wir für die Funktion addiere, eine für die Hauptfunktion main. Verschiedene Entwicklungsumgebungen und Compiler stellen unterschiedliche Anforderungen an die Namen von C++-Quelltextdateien. Für den von mir verwendeten Compiler ist es zum Beispiel erforderlich, C++-Quelltextdateien die Dateiendung .C, .cc, .cpp oder .cxx zu geben. Für Beispiele in diesem Buch werde ich stets die Endung .cc verwenden; der Leser sollte in der Dokumentation seiner Entwicklungsumgebung nachschauen, welche Anforderungen diese stellt. Wir können also zwei Quelltextdateien entwickeln und diese main.cc und addition.cc nennen. Die Datei addition.cc sieht folgendermaßen aus: 1 2 3 4 5
1
// addition.cc -- Datei mit der Additionsfunktion. int addiere(int a, int b) { return a + b; }
GRUNDLAGEN VON C++
27
Die Datei main.cc hat folgenden Inhalt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// main.cc -- Das Hauptprogramm. #include using std::cin; using std::cout; using std::endl; int main() { cout << "Bitte erste Zahl eingeben: "; int erste_zahl, zweite_zahl; cin >> erste_zahl; cout << "Bitte zweite Zahl eingeben: "; cin >> zweite_zahl; cout << "Die Summe der beiden Zahlen ist " << addiere(erste_zahl, zweite_zahl) << "." << endl; }
Wie weiter oben beschrieben wurde, muss eine Funktion vor ihrer Benutzung deklariert sein. Das bedeutet, dass an der Stelle ihres Aufrufs dem Compiler bekannt sein muss, welche Übergabeparameter und welchen Rückgabeparametertyp sie besitzt. Da die Funktion addiere nicht in der selben Datei wie die Funktion main definiert ist, benötigen wir ein Hilfsmittel, um die Deklaration der Funktion addiere in der Datei main.cc bekannt zu machen. Dieses Hilfsmittel ist eine Headerdatei. Ähnlich wie die Standardheader, enthält eine Headerdatei Deklarationen von Funktionen und Definitionen von Datentypen, die wir (oder andere) selbst entwickelt haben. Im Unterschied zum Inhalt von Standardheadern, ist der Inhalt unserer eigenen Headerdateien nicht durch den C++-Sprachstandard festgelegt. Auch für Headerdateien gibt es Festlegungen zur Namenswahl: häufig wird die Endung .h oder .hpp vorgeschrieben. Wir nennen unsere Headerdatei für die Additionsfunktion analog zur Quelltextdatei addition.h. Sie hat folgenden Inhalt: 1 2 3 4 5 6
#ifndef _ADDITION_H__ #define _ADDITION_H__ int addiere(int, int); #endif
Wird eine Headerdatei in mehreren anderen Headerdateien eingebunden, und diese wiederum alle in einer Quelltextdatei, so werden die selben Deklarationen dem Compiler mehrfach vorgelegt. Dies ist laut C++-Standard verboten, weshalb jede Headerdatei dafür sorgen muss, dass sie lediglich einmal eingebunden wird. Dieses Verfahren wird als „#include-Guard“ bezeichnet und mit Hilfe der PräprozessorAnweisungen #ifndef, #define und #endif realisiert.
28
EIGENE HEADERDATEIEN
1
Wir finden die #ifndef-Anweisung in Zeile 1. Ausgesprochen bedeutet sie „if not defined“ und bezieht sich auf den dahinter stehenden Namen, in unserem Fall _ADDITION_H__. Dieser Name symbolisiert eine Präprozessor-Variable, und #ifndef prüft, ob diese Variable bereits definiert ist. Ist dies nicht der Fall, so wird der gesamte Quelltext, der zwischen der #ifndef-Anweisung und der nächsten #endifAnweisung steht an den Compiler weitergegeben. Anderenfalls wird dieser Quelltext ignoriert; der Compiler bekommt ihn nicht zu sehen. Die Anweisung in Zeile 2 gehört ebenfalls zum #include-Guard; es ist eine #defineAnweisung. Diese definiert eine Präprozessor-Variable mit dem dahinter angegebenen Namen. Zusammen mit der #ifndef-Anweisung sorgt sie dafür, dass der Inhalt der Headerdatei nur einmal eingebunden werden kann. Wenn er das erste Mal eingebunden wird, so ist die Präprozessorvariable _ADDITION_H__ noch nicht definiert. Daher wird der Quelltext bis zum #endif verarbeitet und an den Compiler weitergereicht. Zur Verarbeitung gehört dabei auch die Auswertung der #define-Anweisung in der nächsten Zeile. Diese sorgt dafür, dass die Präprozessorvariable _ADDITION_H__ nunmehr definiert ist. Wird die Headerdatei jetzt ein weiteres Mal eingebunden, so schlägt die #ifndefPrüfung fehl; der Inhalt wird nicht ein weiteres Mal an den Compiler weitergegeben. Dieses Verfahren nennt man #include-Guard. Wir können unsere eigene Headerdatei, addition.h, nunmehr in der Quelltextdatei main.cc einbinden, um die Deklaration der Funktion addiere dort bekannt zu machen: 1 2 3 4 5 6 7 8 9 10 11
// main.cc -- Das Hauptprogramm. #include #include "addition.h" using std::cin; using std::cout; using std::endl; int main() { // ... Code wie bisher ... }
Wichtig ist dabei zu beachten, dass im Unterschied zu den Namen der Standardheader die Namen der eigenen Headerdateien stets in Hochkomma eingeschlossen werden. Wir sollten die Headerdatei auch in addition.cc einbinden. Der Compiler kann dann erkennen, wenn unsere Definition der Funktion addiere von ihrer Deklaration abweicht: 1 2 3 4 5 6 7
1
// addition.cc -- Datei mit der Additionsfunktion. #include "addition.h" int addiere(int a, int b) { return a + b; }
GRUNDLAGEN VON C++
29
Die Compilierung des Gesamtprojekts erfolgt nun in drei Schritten: Compilierung von addition.cc ⇒ Objektdatei addition.o Compilierung von main.cc ⇒ Objektdatei main.o Linken der Objektdateien
In Entwicklungsumgebungen mit Projektverwaltung wird dieser Vorgang oftmals automatisiert, so dass diese Einzelschritte äußerlich wie ein einziger Vorgang aussehen.
1.5
ÜBERLADUNG VON FUNKTION
In C++ ist es erlaubt, mehrere Funktionen mit gleichem Namen, aber unterschiedlichen Übergabeparametern zu entwickeln. Diese Fähigkeit wird Überladung genannt. Stellen wir uns zum Beispiel vor, wir wollen mit Hilfe unserer Funktion addiere zwei Fließkommawerte addieren. Momentan sieht unsere Funktion lediglich die Addition von ganzzahligen Werten vor. Wir ändern unsere Hauptfunktion main also so ab, dass sie zwei Fließkommazahlen einliest. Dazu müssen wir lediglich den Typ von erste_zahl und zweite_zahl von int in double ändern. 1 2 3 4 5 6 7
// ... cout << "Bitte erste Zahl eingeben: "; double erste_zahl, zweite_zahl; cin >> erste_zahl; cout << "Bitte zweite Zahl eingeben: "; cin >> zweite_zahl; // ...
Wenn wir dieses Programm compilieren, warnt uns der Compiler gegebenenfalls, dass wir einen Wert des Typs double übergeben, wo ein Wert des Typs int erwartet wird. Dies ist jedoch kein Fehler, denn ein double lässt sich, wenn auch mit Datenverlust, in einen int umwandeln. Wir spüren das beim Test unseres Programms: [mme@endeavour Source]: ./addition_double Bitte erste Zahl eingeben: 12.34 Bitte zweite Zahl eingeben: 11.63 Die Summe der beiden Zahlen ist 23.
Bei der Übergabe an die Funktion addiere wurde aus 12.34 12 und aus 11.63 11. Der Nachkommateil der beiden Fließkommawerte wurde also einfach abgeschnitten. Wir können Abhilfe schaffen, indem wir eine Funktion für die Addition von Fließkommawerten anbieten: 1 double addiere(double a, double b) 2 { 3 return a + b; 4 }
30
ÜBERLADUNG VON FUNKTION
1
Natürlich ist der Funktionskörper dieser Funktion identisch mit dem für die Ganzzahladdition; der Additionsoperator + ist selbst in gewisser Weise überladen und erkennt, ob eine Ganzzahl- oder eine Fließkommaaddition vorzunehmen ist. Da der Name der Funktion ebenfalls addiere ist, brauchen wir in der Hauptfunktion main nichts zu ändern. Wir testen unser Programm wie zuvor: [mme@endeavour Source]: ./addition_double Bitte erste Zahl eingeben: 12.34 Bitte zweite Zahl eingeben: 11.63 Die Summe der beiden Zahlen ist 23.97.
In Kapitel 4 werden wir eine weitere Lösung für dieses konkrete Problem diskutieren. Zu beachten ist, dass Überladung immer nur anhand von Anzahl und Typ der Übergabeparameter stattfinden kann. Der Rückgabetyp spielt dafür keine Rolle.
1.6
STEUERSTRUKTUREN
Bislang haben wir in unseren einfachen Beispielen auf Steuerstrukturen, also Alternativen und Schleifen, verzichtet. Wir wollen nun darauf zu sprechen kommen.
1.6.1
ALTERNATIVEN MIT
if
UND
switch
Sollen im Programm in Abhängigkeit von einer Bedingung alternative Steuerflüsse realisiert werden, so helfen dabei die Schlüsselworte if und switch. Das Schlüsselwort if wird nach folgendem Schema verwendet: 1 if(Bedingung) { 2 // Code für Bedingung == wahr 3 } else { 4 // Code für Bedingung == falsch 5 }
Die Bedingung ist dabei ein Ausdruck, der einen Wahrheitswert zum Ergebnis hat. Dies kann entweder ein Wert des Typs bool sein, jedoch auch ein numerischer Wert oder ein Zeiger. Numerische Werte und Zeiger werden automatisch in den Wahrheitswert true („wahr“) umgewandelt, wenn sie ungleich 0 sind, anderenfalls in den Wahrheitswert false („falsch“). Soll kein spezieller Code für den Fall vorgesehen werden, dass die Auswertung der Bedingung den Wahrheitswert false ergibt, so kann der else-Block weggelassen werden. Dann wird die Verarbeitung beim Auftreten von false einfach hinter dem ifBlock fortgesetzt.
1
GRUNDLAGEN VON C++
31
Wir wollen ein kleines Programm schreiben, das feststellt, welche von zwei eingegebenen Zahlen größer ist. Der Einfachkeit halber soll hier nur die Hauptfunktion main angegeben werden: 1 int main() 2 { 3 cout << "Bitte erste Zahl eingeben: "; 4 int erste_zahl, zweite_zahl; 5 cin >> erste_zahl; 6 cout << "Bitte zweite Zahl eingeben: "; 7 cin >> zweite_zahl; 8 9 if(erste_zahl > zweite_zahl) { 10 cout << "Die erste Zahl ist größer als die zweite Zahl." 11 << endl; 12 } else { 13 if(zweite_zahl > erste_zahl) { 14 cout << "Die zweite Zahl ist größer als die erste Zahl." 15 << endl; 16 } else { 17 cout << "Die beiden Zahlen sind gleich groß." << endl; 18 } 19 } 20 }
Will man für eine Variable oder das Ergebnis eines Ausdrucks mehrere konkrete Alternativen prüfen, so kann man die switch-Anweisung benutzen: 1 2 3 4 5 6 7
char c; // ... switch(c) { case ’a’ : cout << "’a’ wurde eingegeben!" << endl; break; case ’b’ : cout << "’b’ wurde eingegeben!" << endl; break; default : cout << "Weder ’a’ noch ’b’ wurde eingegeben! << endl; }
Die Werte hinter den case-Schlüsselworten müssen zur Laufzeit konstant sein. Die switch-Anweisung prüft den Ausdruck in den Klammern hinter switch (in diesem Fall eine char-Variable c) gegen jede der mit case angegebenen Möglichkeiten. Trifft eine dieser Möglichkeiten zu, wird der Rest des Codes bis zum Ende des switch-Blocks oder bis zur Anweisung break ausgeführt; je nachdem, was eher erreicht wird. Trifft keine der Möglichkeiten zu, wird nach einer default-Angabe gesucht und der entsprechende Code ausgeführt.
1.6.2
SCHLEIFEN MIT
for
UND
while
Wiederholungen von Codeteilen können in C++ mit Hilfe der Schlüsselworte for und while erreicht werden.
32
STEUERSTRUKTUREN
1
Abweisende Schleifen können mittels while gestaltet werden: 1 2 3
while(Bedingung) { // Anweisungen }
So lange die Auswertung der als Ausdruck formulierten Bedingung den Wahrheitswert true ergibt, werden die Anweisungen ausgeführt. Die Bedingung wird vor der ersten Ausführung der Anweisungen geprüft. Daher ist es möglich, dass die Anweisungen überhaupt nicht ausgeführt werden. Analog kann eine abweisende Schleife konstruiert werden, deren Anweisungen mindestens einmal ausgeführt werden: 1 2 3
do { // Anweisungen } while(Bedingung);
Hier findet die Prüfung der Bedingung erst nach der Ausführung der Anweisungen statt. Eine dritte Art der Schleifen ist die for-Schleife: 1 2 3
for(Init-Anweisung Bedingung; Ausdruck) { // Anweisungen }
Die Init-Anweisung wird hierbei vor der ersten Bedingungsprüfung durchgeführt. Vor jeder Ausführung der Anweisungen im Schleifenkörper wird die Bedingung geprüft. Nach jeder Ausführung der Anweisungen im Schleifenkörper wird der Ausdruck ausgewertet. Es ist zu beachten, dass die Init-Anweisung – wie jede Anweisung – mit einem Semikolon endet. Da es Teil der Anweisung ist, wurde es oben nicht gesondert aufgeführt. Wir wollen ein Programm schreiben, das die Quadrate der Zahlen von 1 bis 100 berechnet und ausgibt. Erneut soll nur die Hauptfunktion main angegeben werden: 1 int main() 2 { 3 int i = 1; 4 while (i != 101) { 5 cout << "i = " << i 6 << "\ti^2 = " << i*i << endl; 7 ++i; 8 } 9 }
Wir setzen die Zählvariable i zunächst auf den Anfangswert 1. Danach prüfen wir, ob i bereits den Wert 101 erreicht hat. Solange dies nicht der Fall ist, geben wir i und den Wert von i zum Quadrat aus. Schließlich müssen wir am Ende jedes Schleifendurchlaufs noch die Zählvariable inkrementieren.
1
GRUNDLAGEN VON C++
33
Übersichtlicher kann man diese drei für die Schleife essenziellen Vorgänge: Initialisierung der Zählvariable, Prüfung der Bedingung, Inkrementierung (bzw. allgemeiner formuliert: Anpassung) der Zählvariablen nach jedem Schritt in einer forSchleife darstellen, die ansonsten das gleiche tut: 1 2 3 4
for(int i = 1; i != 101; ++i) { cout << "i = " << i << "\ti^2 = " << i*i << endl; }
Folgender Code enthält eine abbrechende Schleife, die ebenfalls die Quadrate der Zahlen von 1 bis 100 ausgibt: 1 2 3 4 5 6
int i = 1; do { cout << "i = " << i << "\ti^2 = " << i*i << endl; i++; } while (i != 101);
Jede while-Schleife ist in eine äquivalente for-Schleife umwandelbar; dies gilt auch umgekehrt. Welcher von beiden Schleifentypen angewendet wird, hängt vorrangig vom Geschmack des Programmierers ab. Die for-Schleife bietet in gewisser Hinsicht einen Vorteil, indem Sie Initialisierung, Bedingungsprüfung und Änderung der Zählvariablen in einer Zeile zusammenfasst.
1.7
ZEIGER UND FELDER
Jede Variable befindet sich an einer bestimmten Position im Speicher, deren Adresse wir mit Hilfe des Operators & erfragen können. Diese Adresse können wir wiederum in einer Variable speichern, deren genereller Typ Zeiger (engl.: Pointer) heißt. Die Definition einer Variablen x und die Speicherung ihrer Adresse in einer anderen Variablen px sehen wir in folgendem Beispiel: 1 int x = 100; 2 int* px = &x;
Die Variable px hat den Typ „Zeiger auf eine int-Variable“, was durch den * angezeigt wird. Damit ist klar, dass die Variable nicht selbst vom Typ int ist, sondern stattdessen die Adresse einer Variable enthält, die vom Typ int ist. Die folgende Grafik zeigt den Zusammenhang zwischen px und x: px
x 100
34
ZEIGER UND FELDER
1
Mit Hilfe eines Zeigers kann man den Inhalt der eigentlichen Variablen verändern, wie folgendes Beispiel zeigt: 1 int main() 2 { 3 int x = 42; 4 cout << "x = " << x << endl; 5 int *px = &x; 6 *px = 24; 7 cout << "x = " << x << endl; 8 cout << "px = " << px << endl; 9 cout << "*px = " << *px << endl; 10 }
Mit Hilfe des Operators * können wir auf den Speicherplatz zugreifen, auf den eine Zeigervariable verweist. Dieser Vorgang heißt Dereferenzierung des Zeigers. Wir können diesem Speicherplatz einen neuen Inhalt geben, wie Zeile 6 des Beispiels zeigt. Mit der Ausgabe in Zeile 7 dokumentieren wir, dass sich die Änderung tatsächlich auf die Variable x auswirkt. Die Zeilen 8 und 9 dienen dazu, die Eigenschaften des Zeigers noch einmal darzustellen; Zeile 8 gibt die in px gespeicherte Adresse aus, Zeile 9 den an dieser Stelle gespeicherten Wert. Hier wird erneut eine Dereferenzierung des Zeigers vorgenommen. Die Ausgabe sehen wir hier beispielhaft. (Die in px gespeicherte Adresse kann auf dem System des Lesers natürlich abweichen.) [mme@endeavour Source]: ./zeiger x = 42 x = 24 px = 0xbfffe964 *px = 24
Variablen von beliebigen Zeigertypen sind stets Konstrukte der Basissprache und sind damit nach der Definition uninitialisiert. Zeigervariablen sollten daher – genau wie Variablen der anderen Basisdatentypen – stets initialisiert werden. Eine mit 0 initialisierte Zeigervariable heißt Nullpointer (die deutsche Bezeichnung „Nullzeiger“ ist nicht gebräuchlich). Die Dereferenzierung eines Nullpointers führt stets zu einem Laufzeitfehler. Wir haben im Rahmen unserer Betrachtung von Funktionen bereits Referenzen kennen gelernt. Wir können Referenzen auch außerhalb der Übergabeliste von Funktionen verwenden. Referenzen sind alternative Namen für bereits andersweitig mit Speicher versehene Objekte. Im Unterschied zu Zeigern muss keine Dereferenzierung erfolgen, wenn über die Referenz auf den Wert der Variablen zugegriffen werden soll: 1 2 3 4 5 6 7
1
int main() { int x = 42; cout << "x = " << x << endl; int &rx = x; rx = 24; cout << "x = " << x << endl;
GRUNDLAGEN VON C++
35
8 9 10 11 }
cout << "rx = " << rx << endl; cout << "&x = " << &x << endl; cout << "&rx = " << &rx << endl;
Wie wir anhand der Ausgabe sehen, entspricht die Wirkung grundsätzlich der, die wir mit Zeigern erzeugt haben. Die Unterschiede sind im Quelltext oben deutlich: Im Zugriff auf den eigentlichen Wert gibt es bei Referenzen keinen Unterschied zum Zugriff über die originale Variable. Wenn wir die Adresse von Originalvariable und Referenz ausgeben, erkennen wir, dass es sich tatsächlich um zwei verschiedene Namen für dieselbe Speicherstelle handelt 3. [mme@endeavour Source]: ./referenz x = 42 x = 24 rx = 24 &x = 0xbfffe964 &rx = 0xbfffe964
Eine weitere, in der Basissprache von C++ enthaltene Konstruktion, ist das Feld (Array). Ein Feld ist eine Sequenz von Speicherplätzen eines bestimmten Typs, deren Länge beim Anlegen des Feldes festgelegt wird. 1 int main() 2 { 3 int quadrate[11]; 4 5 for(int i = 0; i != 11; i++) { 6 quadrate[i] = i * i; 7 } 8 }
In diesem Beispiel wird ein Feld mit dem Namen quadrate angelegt, für das genügend Speicherplatz vorgesehen wird, um 11 Werte des Typs int aufzunehmen. Die einzelnen Elemente des Feldes werden defaultinitialisiert, was bedeutet, dass für Basisdatentypen keine speziellen Vorkehrungen getroffen werden (siehe Kapitel 1.2) und für Klassentypen eine Defaultkonstruktion (siehe Kapitel 2.3) durchgeführt wird. In Zeile 3 wird das Feld angelegt. In der nachfolgenden for-Schleife werden die Elemente des Feldes nacheinander mit den Quadraten ihrer Indizes belegt. Die Zählung des Indexes beginnt immer bei 0. Wie man sieht, kann man mit Hilfe der eckigen Klammern ([]) auf die Feldelemente zugreifen.
3
36
Obwohl dies der Compiler des Autors (GNU gcc-3.3) so realisiert, ist dies keine Forderung des C++Standards. Dort ist lediglich festgelegt, dass alle Zugriffe auf Referenzen so wirken müssen, als ob es sich um Zugriffe auf die Originalvariable handelte.
ZEIGER UND FELDER
1
Indizes:
quadrate:
0
1
0
1
...
4
9 16
25 36 49
10
64 81 100
Die Größe eines solchen Feldes muss bei der Definition der Variablen feststehen und kann nicht mehr verändert werden. Der Name eines Feldes ist gleichzeitig ein Zeiger auf das erste Element des Feldes. Wir können mit Zeigern rechnen, wobei die Addition oder Subtraktion eines ganzzahligen Wertes n auf einen Zeiger dazu führt, dass der Zeiger nunmehr auf ein Element n Elemente hinter dem ursprünglichen Element verweist. Damit könnten wir obiges Programm auch folgendermaßen formulieren: 1 int main() 2 { 3 int quadrate[11]; 4 5 for(int i = 0; i != 11; ++i) { 6 *(quadrate + i) = i * i; 7 } 8 }
Die Ausdrücke quadrate + i und quadrate[i] sind folglich äquivalent. Daraus folgt auch, dass man auf Zeiger mit Hilfe der eckigen Klammern indiziert zugreifen kann. Wichtig ist dabei zu wissen, dass stets elementweise vorgegangen wird; betrachtet man die tatsächlichen Speicheradressen, so bewegt man sich stets in der Schrittweite voran, die der Breite des Datentyps entspricht, auf den der Zeiger verweist. Ein häufig verwendeter Zeiger- und auch Feldtyp ist char*. Dies ist der Datentyp, den Stringliterale aufweisen. Die einzelnen Zeichen des Stringliterals liegen dabei an aufeinander folgenden Speicheradressen; hinter dem letzten Zeichen des Stringliterals befindet sich ein Null-Byte, das das Ende der Zeichenkette anzeigt. Stringliterale können in Programmen nicht verändert werden, sie sind const. In folgendem Programm wird die gleiche Zeichenkette auf zwei verschiedene Arten angelegt, einmal als Zeiger und einmal als Feld: 1 int main() 2 { 3 char 4 char 5 6 cout 7 cout 8 }
hallo_feld[6] = {’H’, ’a’, ’l’, ’l’, ’o’, 0}; const* hallo = "Hallo"; << "Zeiger: [" << hallo << "]" << endl; << "Feld: [" << hallo_feld << "]" << endl;
Die Ausgaben sind in beiden Fällen identisch. Anhand der Darstellung als Feld kann man deutlich sehen, dass eine Zeichenkette stets ein Byte mehr Speicher verbraucht, als sie Zeichen enthält, und dass das letzte Zeichen stets eine Nullwert sein muss. 1
GRUNDLAGEN VON C++
37
Darauf aufbauend können wir eine Funktion schreiben, die uns die Länge einer Zeichenkette berechnet: 1 int strlen(char const* str) 2 { 3 int i = 0; 4 while(str[i] != 0) { 5 i++; 6 } 7 return i; 8 }
In der while-Schleife durchwandern wir die Zeichenkette so lange, bis wir auf die terminierende 0 stoßen. Die Anzahl der passierten Zeichen entspricht dann der Länge der Zeichenkette. Die hier entwickelte Funktion strlen existiert auch bereits in der Standardbibliothek, sie gehört dort zum Erbgut von C und ist im Standardheader definiert.
1.8
VERSCHIEDENE VERSIONEN DER
main-FUNKTION
Neben der von uns bislang verwendeten Version der Hauptfunktion main: 1 2 3 4
int main() { // ... }
gibt es eine weitere Version, mit deren Hilfe wir auf Parameter zugreifen können, die das Programm beim Start auf der Kommandozeile übergeben bekommt: 1 2 3 4
int main(int argc, char *argv[]) { // ... }
Das erste Argument beschreibt die Anzahl der auf der Kommandozeile übergebenen Parameter, das zweite ist ein Feld, in dem die einzelnen Parameter als Zeichenketten (vom Typ char*) abgelegt sind. Mit Hilfe dieser beiden Parameter können Programme entwickelt werden, deren Funktionsweise über die Kommandozeile steuerbar wird. Wir wollen ein Beispiel entwickeln, in dem wir alle auf der Kommandozeile übergebenen Argumente ausgeben: 1 int main(int argc, char *argv[]) 2 { 3 for(int i = 0; i != argc; i++) { 4 cout << "Argument " << i <<": [" 5 << argv[i] << "]" <<endl; 6 } 7 }
38
VERSCHIEDENE VERSIONEN DER MAIN-FUNKTION
1
An folgendem beispielhaftem Aufruf können wir erkennen, dass stets mindestens ein Argument übergeben wird: [mme@endeavour Source]: ./main_argumente Argument 0: [./main_argumente]
Es handelt sich dabei um den Aufrufnamen des gestarteten Programms. Dieser wird stets als Argument 0 übergeben. Im folgenden Beispiel übergeben wir einige weitere Argumente: [mme@endeavour Source]: ./main_argumente Dies ist ein Test Argument 0: [./main_argumente] Argument 1: [Dies] Argument 2: [ist] Argument 3: [ein] Argument 4: [Test]
Die folgende Grafik stellt noch einmal den Zusammenhang zwischen argc und argv und die Konfiguration von argv im Speicher dar: argc: argv:
./main argumente
0
Dies
1
ist
2
ein
3
Test.
4
Die Verwendung dieser Version von main ist plattformunabhängig und vom C++Standard garantiert.
1
GRUNDLAGEN VON C++
39
1.9
ZUSAMMENFASSUNG
In diesem Kapitel haben wir uns mit den Grundlagen von C++ beschäftigt: Datentypen, Definition von Variablen, Deklaration und Definition von Funktionen, Operatoren, Steuerstrukturen, Überladung, Aufteilung des Quelltextes auf Quelltext- und Headerdateien. Im Ergebnis sollte der Leser in der Lage sein, einfache C++-Programme zu erstellen sowie C++-Programme anderer Autoren in Syntax und Semantik zu verstehen.
1.10 ÜBUNGEN 1. Compilieren und testen Sie die in diesem Kapitel dargestellten Programme. 2. Entwickeln Sie ein Programm, das hintereinander Quadrate und Dreierpotenzen
der Zahlen von 1 bis 20 ausgibt! 3. Welcher Fehler befindet sich in folgenden Programmen? 1 2 3 4 5 6
#include int main() { // Eine einfache Ausgabe: cout << "Dies ist ein Test!" << endl; }
4. Schreiben Sie ein Programm, das verschiedene geometrische Parameter eines
Dreiecks, Quadrats und Kreises berechnet. Sie finden die für diese Aufgabe gegebenenfalls erforderlichen mathematischen Funktionen im Standardheader . Informieren Sie sich in der Dokumentation ihrer Entwicklungsumgebung oder in der Literatur über die verfügbaren mathematischen Funktionen! a. Der Nutzer soll anhand eines Text-Menüs auswählen, die Daten welches geometrischen Objektes er eingeben will. b. Entsprechend der Auswahl des Nutzers soll eine Funktion für das jeweilige geometrische Objekt gestartet werden, die alle erforderlichen Daten (Welche 40
ÜBUNGEN
1
sind das?) einliest, sowie alle weiteren Berechnungen und Ausgaben durchführt. c. Schreiben Sie für jeden der folgenden geometrischen Parameter eine Funktion zu seiner Berechnung: • Umfang • Flächeninhalt • Mittelpunkt Benutzen Sie diese Funktionen zur Berechnung der Parameter. d. Erweitern Sie das Programm so, dass es für Dreiecke die drei Innenwinkel berechnet.
1.11 LITERATUREMPFEHLUNGEN STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Im Kapitel 2 startet der Autor mit einem breiten Überblick über die Möglichkeiten von C++, die weit über die in diesem Kapitel besprochenen Techniken hinausgeht. Kapitel 4 geht dann auf Typen und Deklarationen ein und beschreibt im Detail Schlüsselworte und Prinzipien für Namespaces, Zugriffsbereiche, und Ähnliches. Kapitel 5 beschäftigt sich mit Zeigern und Feldern, Kapitel 6 mit Ausdrücken und Anweisungen, Kapitel 7 mit Funktionen. Dabei werden zahlreiche Beispiele angeboten, die insbesondere auch in Bezug auf die verwendeten algorithmischen und technologischen Kenntnisse eher anspruchsvoll sind.
KOENIG/MOO: „INTENSIVKURS C++“ In Kapitel 0 findet sich der erste Teil der Inhalte, die in diesem Kapitel vermittelt werden sollten. Insbesondere die grundlegenden Sprachmittel, Ausdrücke und Anweisungen, werden hier beschrieben. Im Kapitel 4 wird auf Funktionen eingegangen. Dem Prinzip folgend, dass Zeiger und Felder in der modernen C++-Anwendungsprogrammierung nur an speziellen Stellen erforderlich sind und längst durch wesentlich bessere Hilfsmittel der C++-Standardbibliothek ersetzt wurden, werden Zeiger erst im Rahmen der Beschreibung des manuellen Speichermanagements in Kapitel 10 eingeführt.
1
GRUNDLAGEN VON C++
41
2 KLASSEN Nachdem wir uns im ersten Kapitel mit grundlegenden Einrichtungen der Programmiersprache C++ beschäftigt haben, wollen wir uns in den folgenden beiden Kapiteln mit der objektorientierten Programmierung in C++ beschäftigen. Die erforderlichen #include- und using-Direktiven werden künftig nur noch angegeben, wenn komplette Header- oder Quelltextdateien zitiert werden. Anderenfalls sind diese selbstständig zu ergänzen. Wenn neue Einrichtungen der C++-Standardbibliothek vorgestellt werden, so ist jeweils aufgeführt welche(r) Standardheader dafür einzubinden ist/sind. Klassen sind ein Hilfsmittel der objektorientierten Programmierung, die eine Kapselung von Steuer- und Datenfluss in einer gemeinsamen Struktur erlauben. Wer dieses Kapitel durchgearbeitet hat, sollte folgende Kenntnisse besitzen:
Î Definition eigener Datentypen mit struct und class, Î Datenmember und Memberfunktionen: Definition und Einsatz, Î Bedeutung von const-Memberfunktionen, Î Verwendung spezieller Funktionen: Konstruktoren und Destruktoren, Î Implementierung von Operatoren 2.1
STRUCT
Bereits die Programmiersprache C erlaubte – wie prinzipiell alle prozeduralen Programmiersprachen – die Zusammenfassung von Daten zu einer gemeinsamen Datenstruktur. Der Nutzen dieser Zusammenfassung liegt in einer besseren Programmstruktur: logisch zusammengehörige Daten können als „Block“ betrachtet werden.
2
KLASSEN
43
Das Schlüsselwort, mit dessen Hilfe diese Aggregierung vorgenommen wird, ist struct. Im folgenden Beispiel sehen wir die Daten eines Kunden eines imaginären Unternehmens, zusammengefasst mit Hilfe einer struct: 1 struct Kunde 2 { 3 // Stammdaten 4 int ID; 5 std::string Name; 6 std::string Vorname; 7 std::string Wohnort; 8 9 // Umsatz und Anzahl der Einzeltransaktionen 10 double Umsatz; 11 int Transaktionen; 12 };
Das Schlüsselwort struct definiert in C++ einen neuen Datentyp, dessen Name dem Namen der struct, im Beispiel Kunde, entspricht. Wichtig ist an dieser Stelle das Semikolon, das die Definition der struct abschließt. Das Vergessen dieses Semikolons kann sehr unklare Compiler-Fehlermeldungen produzieren, aus denen die tatsächliche Fehlerursache nur schwer geschlossen werden kann! Wir können diesen Datentyp einsetzen, um Variablen zu definieren: 1 int main() 2 { 3 Kunde K; 4 // ... 5 }
An dieser Stelle der Funktion main wird eine Variable mit dem Namen K definiert, die vom Datentyp Kunde ist. Die Definition entspricht syntaktisch exakt der Definition von Variablen anderer Datentypen. Nach der Definition von K nimmt die Variable genau so viel Speicher ein wie die Summe der Bestandteile von Kunde einnehmen. Auf diese Bestandteile kann mit Hilfe des Operators . (Punkt) zugegriffen werden: 1 int main() 2 { 3 Kunde K; 4 5 K.ID = 20040001; 6 K.Name = "Helmut"; 7 K.Vorname = "Müller"; 8 K.Wohnort = "Musterdorf"; 9 K.Umsatz = 0; 10 K.Transaktionen = 0; 11 // ... 12 }
44
STRUCT
2
2.1.1
PROZEDURALER ANSATZ ZUR ARBEIT MIT
structs
Wir wollen nunmehr Funktionen entwickeln, die uns erlauben, einen Kunden zu initialisieren, eine Transaktion abzuwickeln und einen Kunden auszugeben. Entsprechend den in 1.5 dargestellten Prinzipien wollen wir diese Funktionen in einer anderen Quelltextdatei als die main-Funktion unterbringen. Wir nennen diese Quelltextdatei kunde.cc. Gleichzeitig entwickeln wir eine Headerdatei kunde.h, die die Definition der struct Kunde und die Deklarationen unserer Funktionen aufnehmen soll. Diese Headerdatei muss sowohl in der Quelltextdatei, die die main-Funktion enthält, als auch in der Quelltextdatei, die die Definitionen der Funktionen enthält, eingebunden werden.
HEADERDATEI
kunde.h
Die Headerdatei kunde.h sieht folgendermaßen aus: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
#ifndef __KUNDE_H__ #define __KUNDE_H__ #include <string> #include struct Kunde { // Stammdaten int ID; std::string Name; std::string Vorname; std::string Wohnort; // Umsatz und Anzahl der Einzeltransaktionen double Umsatz; int Transaktionen; };
Kunde erzeuge_kunde(std::string const& Name, std::string const& Vorname, std::string const& Wohnort); void kaufe(Kunde& K, double Umsatz); std::ostream& schreibe(std::ostream& out, Kunde const& K); #endif
Wir beachten hierbei, dass wir in einer Quelltextdatei niemals using-Direktiven verwenden. Das Einbinden der Headerdatei geschieht durch textuelle Ersetzung der #include-Anweisung mit dem gesamten Inhalt der Headerdatei durch den C++-Präprozessor. Eine using-Direktive innerhalb der Headerdatei wird damit automatisch auch Bestandteil der Quelltextdatei, in die diese Headerdatei eingebunden wird. 2
KLASSEN
45
Verwendet der Entwickler der Headerdatei nunmehr Funktionen oder Datentypen, deren Namen identisch mit Funktionen oder Datentypen sind, die wir in der Headerdatei verwenden, und für die wir using-Direktiven formuliert haben, so geschieht etwas für den Programmierer der Quelltextdatei absolut Überraschendes: Der Compiler nimmt an, dass die in der Headerdatei vermerkten using-Direktiven sich auch auf die Funktionen und Datentypen in der Quelltextdatei beziehen – und meldet Fehler, falls diese mit anderen Übergabeparametern verwendet werden, als für unsere in der Headerdatei verwendeten Funktionen und Datentypen vorgesehen! Das Einbinden einer für den Programmierer unter Umständen fremden Headerdatei führt dazu, dass sich die Semantik seines Quelltextes ändert – sogar ohne dass er eine Funktion oder einen Datentyp aus der Headerdatei verwendet. Das bloße Einbinden und die zufällige Übereinstimmung von Namen reichen aus. Solche Seiteneffekte müssen wir als Entwickler von Headerdateien vermeiden. Aus diesem Grund sind using-Direktiven in Headerdateien nicht angebracht. Wir verwenden daher in Headerdateien stets die vollständige Angabe des Namespaces vor jedem aus dem entsprechenden Namespace verwendeten Namen, so wie wir es ganz am Anfang von Kapitel 7 behandelt haben.
QUELLTEXTDATEI
kunde.cc
Die drei Funktionen erzeuge_kunde, kaufe und schreibe werden in unserer Quelltextdatei kunde.cc definiert: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
46
#include <string> #include #include "kunde.h" using std::string; using std::ostream; using std::endl; Kunde erzeuge_kunde(string const& Name, string const& Vorname, string const& Wohnort) { static int new_id = 0; Kunde K; K.Name = Name; K.Vorname = Vorname; K.Wohnort = Wohnort; K.ID = new_id++; K.Umsatz = 0; K.Transaktionen = 0; return K; }
STRUCT
2
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
void kaufe(Kunde& K, double Umsatz) { K.Umsatz += Umsatz; K.Transaktionen++; } ostream& schreibe(ostream& out, Kunde const& K) { out << "Kunde " << K.Vorname << " " << K.Name << " aus " << K.Wohnort << " hatte " << K.Transaktionen << " Transaktionen und " << K.Umsatz << " EUR Umsatz." << endl; return out; }
Die Funktion erzeuge_kunde schafft uns eine neue Instanz der struct Kunde und setzt die übergebenen Daten als Initialwerte. Weiterhin werden Umsatz und Transaktionen auf 0 gesetzt. Die Vergabe der ID geschieht mit Hilfe einer static-Variablen. Im Unterschied zu anderen lokalen Variablen einer Funktion, die bei jedem Aufruf der Funktion neu erzeugt und bei jedem Ende der Funktion wieder zerstört werden, wird eine als static deklarierte Variable nur beim ersten Aufruf der Funktion im Rahmen der Ausführung des Programms erzeugt und bleibt während der gesamten Ausführungszeit verfügbar – wenngleich sie lediglich innerhalb der Funktion sichtbar ist. Da die so deklarierte Variable new_id nur einmal angelegt wird, wird auch die Initialisierung auf den Wert 0 lediglich einmal durchgeführt. Wir verwenden den Wert dieser Variable als Anfangswert des Bestandteils ID unserer Kunde-struct. Bei jeder erzeugten Instanz wird die ID inkrementiert, so dass sichergestellt wird, dass aufeinander folgende Instanzen verschiedene ID’s erhalten. Am Ende der Funktion wird die so initialisierte Kunde-Instanz an den Aufrufer zurückgegeben. Die Funktion kaufe bekommt eine Referenz auf eine Kunde-Instanz übergeben. Wie wir im vorangegangenen Kapitel festgestellt haben, wirkt eine Referenz als zweiter Name für einen bereits existierenden Speicherplatz – wir können damit also direkt auf den Inhalt der übergebenen Variablen Einfluss nehmen. Dies tun wir, indem wir den Umsatz um den entsprechenden übergebenen Wert erhöhen und die Transaktionen inkrementieren. Die Funktion schreibe gibt eine Kunde-Instanz auf dem als ersten Parameter übergebenen ostream aus. Die Kunde-Instanz wird hier per const-Referenz übergeben, denn es handelt sich um einen komplexen Datentyp, bei dem wir unnötige Kopien vermeiden wollen, und gleichzeitig wollen wir mit const verhindern, dass sich in der Funktion durch Programmierfehler der Inhalt der übergebenen Instanz verändert.
2
KLASSEN
47
Nachdem wir die einzelnen Bestandteile des Kunden ausgegeben haben, liefern wir eine Referenz auf den übergebenen ostream zurück. Dies ist gebräuchliche Praxis. Spätestens im Kapitel 7 werden wir dazu einige weitere Worte verlieren.
QUELLTEXTDATEI
main.cc
Wir können nun die Funktionen in unserer main-Funktion verwenden, die wir in einer weiteren Quelltextdatei namens main.cc definieren. Wie oben dargestellt, müssen wir dabei die Headerdatei kunde.h einbinden, um die Deklarationen der Funktionen erzeuge_kunde, kaufe und schreibe bekannt zu machen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
#include #include "kunde.h" using std::cout; int main() { Kunde K = erzeuge_kunde("Müller", "Helmut", "Musterdorf"); kaufe(K, 99.95); schreibe(cout, K); }
TEST DES PROZEDURALEN ANSATZES Wenn wir das Programm übersetzen und starten, erhalten wir folgende Ausgabe: [mme@endeavour Kunde1]: ./kunde Kunde Helmut Müller aus Musterdorf hatte 1 Transaktionen und 99.95 EUR Umsatz.
Natürlich stört uns die Ausgabe „1 Transaktionen“, weshalb wir unsere Funktion schreibe überarbeiten sollten: 1 ostream& schreibe(ostream& out, Kunde const& K) 2 { 3 out << "Kunde " << K.Vorname << " " << K.Name 4 << " aus " << K.Wohnort << " hatte " 5 << K.Transaktionen 6 << ((K.Transaktionen == 1)?" Transaktion":" Transaktionen") 7 << " und " << K.Umsatz << " EUR Umsatz." << endl; 8 9 return out; 10 }
Nach der Ausgabe der Anzahl der Transaktionen verwenden wir das so genannte arithmetische if, um festzustellen, ob wir die Einzahl oder Mehrzahl verwenden müssen. 48
STRUCT
2
Das arithmetische if ist eine Kurzform der in Kapitel 1 diskutierten if-Anweisung. Der Operator besteht aus den beiden Zeichen ? und :, zwischen denen drei Operanden vorkommen. Der erste Operand ist die Bedingung, die auf einen Wahrheitswert untersucht wird. Ist dieser Wahrheitswert true, dann ist das Ergebnis des Operators der Ausdruck zwischen dem ? und dem :. Ist der Wahrheitswert der Bedingung vor dem ? false, so ist der Ausdruck hinter dem : das Ergebnis. Beide Ausdrücke müssen letztendlich ein Ergebnis des gleichen Datentyps liefern. Um Mehrdeutigkeiten bezüglich des auf das arithmetische if folgenden Ausgabeoperators zu vermeiden, setzen wir die gesamte Operation in Klammern. Zusätzlich verwende ich stets Klammern innerhalb der Ausdrücke des arithmetischen if’s, um die Gruppierung von Operanden offensichtlicher zu machen; auch wenn dies nicht unbedingt erforderlicher ist, macht es aus meiner Sicht diese komplexe Operation leichter verständlich.
2.1.2
PROBLEME MIT DEM PROZEDURALEN ANSATZ
Im Zusammenhang mit dem gezeigten prozeduralen Ansatz zur Arbeit mit Datenstrukturen, gibt es mehrere Probleme zu beobachten. Stellen wir uns vor, die Entwicklung der struct Kunde und der Funktionen, die auf diese struct zurückgreifen, liegen in den Händen verschiedener Entwickler. In diesem Fall kann es passieren, dass der Entwickler der struct Änderungen vornimmt, die dem Entwickler der Zugriffsfunktionen zunächst nicht bekannt werden. Erst beim Compilieren der veränderten struct-Definition zusammen mit seinen Funktionen wird das Problem in Form einer Compiler-Fehlermeldung sichtbar. Nehmen wir zum Beispiel an, der Entwickler der struct Kunde entschließt sich, das Element Name in Familienname umzubenennen: 1 2 3 4 5 6 7 8
// ... struct Kunde { // ... std::string Familienname; std::string Vorname; // ... };
Dies führt dazu, dass in folgenden Funktionen ein Compilierfehler auftritt: 1 2 3 4 5 6 7 8 9
2
Kunde erzeuge_kunde(string const& Name, string const& Vorname, string const& Wohnort) { static int new_id = 0; Kunde K; K.Name = Name;
KLASSEN
// <-- Fehler!
49
10 11 12 13 14 15 16 17 18 19 20
K.Vorname = Vorname; // ... } ostream& schreibe(ostream& out, Kunde const& K) { out << "Kunde " << K.Vorname << " " << K.Name // <-- Fehler! << " aus " << K.Wohnort << " hatte " // ... }
Da das Element Name nicht mehr existiert, kann darauf auch nicht mehr zugegriffen werden. Der Entwickler der Funktionen muss nunmehr mit mehr oder weniger Mühe alle Stellen in seinen Quelltexten suchen, in denen er auf das veränderte Element zugreift. Handelt es sich um mehr als nur ein isoliertes Element, bzw. ist die Menge an Quelltext, der sich auf die struct bezieht sehr hoch, so erhöht sich der Wartungsaufwand entsprechend stark. Eine kleine Änderung erzeugt einen gewaltigen Aufwand. Ein weiteres Problem ist, dass der ursprüngliche Entwickler der struct keinerlei Chance hat, bestimmte Regeln zur Benutzung seiner Datenstruktur durchzusetzen. Er kann zwar Zugriffsfunktionen anbieten, die diese Regeln umsetzen, aber er kann keinen anderen Entwickler zu ihrer Benutzung zwingen. Eine Regel im Zusammenhang mit der struct Kunde könnte zum Beispiel sein, dass der Inhalt jeder Instanz bei ihrer Zerstörung in eine Datei oder Datenbank gesichert werden soll. Der Entwickler der struct kann daraufhin eine Funktion sichere_kunde anbieten, die die entsprechende Aktion für ein übergebenes Objekt ausführt; jedoch ist am Ende der Entwickler, der die struct benutzt, dafür verantwortlich, diese Funktion auch aufzurufen. Der Entwickler der struct hat darauf keinen Einfluss.
2.2
KAPSELUNG
Eine Abhilfe für diese Probleme wurde durch die Einführung der Kapselung in der Programmierung geschaffen. Der damit entstandene Programmierstil heißt „objektorientierte Programmierung“, denn im Mittelpunkt der Entwicklung stehen nicht länger Datenstrukturen und Funktionen, sondern Objekte und ihr Verhalten. In diesem Zusammenhang ist es erforderlich, eine klare Terminologie zu verwenden, um Missverständnissen vorzubeugen. Eine Klasse im Sinne der Objektorientierten Programmierung ist dabei die Definition von Datenelementen und Funktionen, die in einem gemeinsamen Namensraum existieren. In diesem Sinn ist unsere struct Kunde eine Klasse. Ein Objekt im Sinne der objektorientierten Programmierung ist eine konkrete Realisierung einer Klasse. Man spricht auch von einer Instanz einer Klasse. In diesem Sinn ist unsere Variable K in der main-Funktion des Beispiels ein Objekt, konkreter: ein Objekt der Klasse Kunde. 50
KAPSELUNG
2
Wir können damit die Funktionen, die wir als Hilfsmittel zur Arbeit mit unserer struct Kunde entwickelt haben, der Klasse fest zuordnen: 1 struct Kunde 2 { 3 // Stammdaten 4 int ID; 5 std::string Name; 6 std::string Vorname; 7 std::string Wohnort; 8 9 // Umsatz und Anzahl der Einzeltransaktionen 10 double Umsatz; 11 int Transaktionen; 12 13 void kaufe(double Umsatz); // neu 14 std::ostream& schreibe(std::ostream& out) const; // neu 15 16 };
Wir haben der Definition der struct Kunde zwei Funktionen hinzugefügt, die nunmehr Bestandteil der Klasse geworden sind. Bestandteile einer Klasse bezeichnet man auch als Datenmember (oder kurz: Member) und Memberfunktionen. In unserem Beispiel sind kaufe und schreibe also Memberfunktionen der Klasse Kunde. Wir wollen uns die Implementierungen dieser Memberfunktionen anschauen, die wir in der Quelltextdatei kunde.cc vorgenommen haben: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
void Kunde::kaufe(double umsatz) { Umsatz += umsatz; Transaktionen++; } ostream& Kunde::schreibe(ostream& out) const { out << "Kunde " << Vorname << " " << Name << " aus " << Wohnort << " hatte " << Transaktionen << ((Transaktionen == 1)?" Transaktion":" Transaktionen") << " und " << Umsatz << " EUR Umsatz." << endl; return out; }
Wir bemerken hier mehrere Unterschiede zu den bisherigen Versionen. Die Funktion kaufe wird als Kunde::kaufe bezeichnet, da sie ja nunmehr zum Namensraum der Klasse Kunde gehört. Im Unterschied zur stand-alone-Version benötigen wir nur noch einen Übergabeparameter: den Parameter umsatz, der den vom Kunden beim Einkauf gemachten Umsatz enthält. Da wir Memberfunktionen stets an einem konkre2
KLASSEN
51
ten Objekt aufrufen, ergibt sich unser ehemaliger erster Übergabeparameter – der konkrete Kunde, dem wir den Umsatz zuschreiben wollen – automatisch durch den Aufruf. Dies werden wir im Anschluss an diese Betrachtung in unserer main-Funktion sehen. Innerhalb der Funktion greifen wir direkt auf den Datenmember Umsatz zu. Da die Funktion kaufe Bestandteil des Namensraums der Klasse Kunde ist, kann sie ohne weitere Angaben auf deren andere Member zugreifen. Um den Datenmember Umsatz vom Übergabeparameter umsatz zu unterscheiden, verwenden wir Groß- und Kleinschreibung des Anfangsbuchstabens. Analog sehen wir in der neuen Memberfunktion schreibe, dass hier ebenfalls die Übergabe des auszugebenden Objekts entfällt – stattdessen erfolgt die Ausgabe der entsprechenden Datenmember des Objektes, für das die Memberfunktion aufgerufen wird. Eine Besonderheit besteht hierbei in der Angabe des Schlüsselwortes const, sowohl in der Deklaration, als auch im Kopf der Definition von Kunde::schreibe. Dieses Schlüsselwort kennzeichnet die Memberfunktion schreibe als const-Memberfunktion. Für den Compiler überprüfbar ist es const-Memberfunktionen verboten, die Werte der Datenmember des Objektes, für das die Funktion aufgerufen wird, zu verändern. Wenn ein Objekt als const deklariert ist (zum Beispiel in einer Definition als Konstante oder bei einer Übergabe als const-Referenz), können auf diesem Objekt nur const-Memberfunktionen aufgerufen werden. Es ist daher sinnvoll, alle Memberfunktionen, die die Datenmember nicht verändern, als const zu deklarieren; selbst wenn man die Benutzung von als const deklarierten Objekten noch nicht vorhersehen kann. Die Verwendung von Memberfunktionen können wir in unserer neuen main-Funktion sehen: 1 int main() 2 { 3 Kunde K = erzeuge_kunde("Müller", "Helmut", "Musterdorf"); 4 5 K.kaufe(99.95); 6 7 K.schreibe(cout); 8 }
Wie wir sehen, verwenden wir den . (Punkt), um eine Verbindung zwischen dem Objekt und der für dieses Objekt aufzurufenden Funktion herzustellen. Die Zeile 1
K.kaufe(99.95);
ruft die Memberfunktion kaufe auf dem Objekt K auf, das zuvor mit Hilfe der Funktion erzeuge_kunde hergestellt wurde. Wie wir uns nach der Ausführung des Programms versichern können, ändert sich an der Funktionsweise des Programms nichts: [mme@endeavour Kunde2]: ./kunde Kunde Helmut Müller aus Musterdorf hatte 1 Transaktion und 99.95 EUR Umsatz.
52
KAPSELUNG
2
2.3
ERZEUGUNG, KOPIE UND ZERSTÖRUNG VON OBJEKTEN
Die Umwandlung der Funktionen kaufe und schreibe in Memberfunktionen im vorangegangenen Abschnitt brachte uns eine wichtige Verbesserung: der Autor der Klasse Kunde hat nunmehr diese beiden Funktionen in seiner Regie; sollte er Veränderungen an der Datenstruktur von Kunde vornehmen, so kann er ohne weiteres die entsprechenden Funktionen ändern. Ein anderer Programmierer, der diese Memberfunktionen benutzt (zum Beispiel so, wie wir dies in der Hauptfunktion main tun), muss seinen Quelltext nicht ändern, wenn sich in den Funktionen kaufe und schreibe etwas ändert. Alles was er tun muss, ist, sein Programm neu gegen die Objektdateien, die den Code der Klasse Kunde enthalten, zu linken1. Wir hatten jedoch noch ein weiteres Problem besprochen, das wir mit den Änderungen nicht beseitigt haben: Der Autor der struct Kunde soll selbst beeinflussen können, was passiert, wenn eine Instanz dieser struct erzeugt wird.
KONSTRUKTOREN Mit Hilfe eines Konstruktors kann der Autor einer Klasse festlegen, wie ein neues Objekt dieser Klasse initialisiert werden soll. Ein Konstruktor ist dabei einfach eine spezielle Memberfunktion, deren Name dem Namen der Klasse entspricht und die keinen Rückgabedatentyp (auch nicht void!) hat. Es können mehrere Konstruktoren definiert werden, die sich in der Parameterliste unterscheiden müssen. Ein besonders wichtiger Konstruktor ist derjenige, der keinerlei Übergabeparameter hat. Er wird als Defaultkonstruktor bezeichnet und wird verwendet, um ein Objekt mit sinnvollen Initialwerten in allen Datenmembern zu erzeugen. Wir wollen einen solchen Defaultkonstruktor für unsere Klasse Kunde definieren: 1 struct Kunde 2 { 3 // Datenmember wie bisher 4 5 Kunde() 6 : ID(0), Name(), Vorname(), Wohnort(), Umsatz(0), 7 Transaktionen(0) 8 {} 9 10 // Memberfunktionen wie bisher 11 };
Im Unterschied zu normalen Memberfunktionen besitzen Konstruktoren eine Initialisiererliste, die direkt im Anschluss an den Funktionskopf des Konstruktors angegeben wird. Darin werden die Datenmember der Klasse in der Reihenfolge ihrer Definition aufgeführt und in Klammern die entsprechenden Initialwerte festgelegt. Diese Art 1
2
In Umgebungen, die dynamisches Linken unterstützen, muss er noch nicht einmal dies tun.
KLASSEN
53
der Wertübergabe heißt Konstruktion; man kann sich vorstellen, dass für jeden Datenmember ein Konstruktor ausgeführt wird, dem der entsprechende Wert als Parameter übergeben wird. Obwohl einfache Datentypen wie int und double keine Konstruktoren haben können, ist diese Initialisierungsschreibweise auch für sie möglich. Die Datenmember Name, Vorname und Wohnort sind vom Typ string, der selbst ein Klassentyp ist. Die Klasse string definiert mehrere Konstruktoren, darunter auch einen Defaultkonstruktor, der das zu erzeugende Objekt mit der leeren Zeichenkette initialisiert. Wir können den Konstruktor noch einfacher schreiben: 1 struct Kunde 2 { 3 // Datenmember wie bisher 4 5 Kunde() 6 : ID(0), Umsatz(0), Transaktionen(0) 7 {} 8 9 // Memberfunktionen wie bisher 10 };
Wenn Datenmember in einem Defaultkonstruktor nicht erwähnt werden, so wird für diese Datenmember automatisch eine Defaultkonstruktion durchgeführt. Für Member mit Basisdatentypen wie int, double oder alle Zeigertypen bedeutet das, dass ihr Anfangswert undefiniert sind. Dies entspricht genau dem Initialisierungsstand von lokalen Funktionsvariablen dieser Datentypen. Für Member mit Klassentypen wird automatisch der Defaultkonstruktor der Klasse benutzt, um die Initialisierung durchzuführen. Da wir in der ersten Version des Konstruktors auch lediglich eine Defaultinitialisierung durchgeführt haben, können wir genauso gut auf die Erwähnung der Member verzichten. Dies führt unmittelbar zu der Frage, was passiert, wenn wir keinen Defaultkonstruktor definieren. In diesem Fall wird automatisch ein Defaultkonstruktor „synthetisiert“, der alle Member mit Basisdatentypen mit undefinierten Werten initialisiert, und für alle Member mit Klassentypen die Defaultkonstruktion durchgeführt. Wir sollten immer dann einen eigenen Defaultkonstruktor anbieten, wenn wir Datenmember mit Basisdatentypen haben; anderenfalls können Objekte entstehen, die beliebige Werte in diesen Datenmembern haben. Im Beispiel wurde der Konstruktor direkt innerhalb der Definition der Klasse struct definiert. Dies wird als Inline-Definition bezeichnet und ist prinzipiell für alle Memberfunktionen möglich. Vorteilhaft ist dieses Vorgehen insofern, als es nur auf diese Weise möglich ist, durch den Compiler entsprechende Memberfunktionen inline expandieren zu lassen, was den Aufwand für einen Funktionsaufruf während der Ausführung des Programms verringert. Der Nachteil ist, dass die Headerdatei, in der die Definition der Klasse untergebracht ist, dadurch stark aufgebläht wird. Da wir die Headerdatei für Benutzer unserer Klassen zugänglich machen müssen, wird damit außerdem auch
54
ERZEUGUNG, KOPIE UND ZERSTÖRUNG VON OBJEKTEN
2
der Code dieser Inline-Funktionen zugänglich. Die Inline-Definition kommt daher vor allem für kurze und einfache Memberfunktionen in Frage. Wir wollen einen weiteren Konstruktor, genau wie unsere bisherigen Memberfunktionen, außerhalb der Klassendefinition definieren. Dabei müssen wir jedoch, ebenfalls analog zu unseren Memberfunktionen, eine Deklaration des Konstruktors in der Klassendefinition vermerken: 1 struct Kunde 2 { 3 // Datenmember wie bisher 4 5 Kunde() 6 : ID(0), Umsatz(0), Transaktionen(0) 7 {} 8 9 Kunde(std::string const& name, 10 std::string const& vorname, 11 std::string const& wohnort); 12 13 // Memberfunktionen wie bisher 14 };
Wie wir sehen, entspricht der Kopf des zweiten Konstruktors unserer bisherigen Funktion erzeuge_kunde. Diese benötigen wir dann nicht mehr. Ihre Definition ersetzen wir in der Quelltextdatei kunde.cc durch die Definition dieses zweiten Konstruktors: 1 Kunde::Kunde(string const& name, string const& vorname, 2 string const& wohnort) 3 :ID(0), Name(name), Vorname(vorname), Wohnort(wohnort), 4 Umsatz(0), Transaktionen(0) 5 { 6 }
Wir verwenden in unseren Beispielen stets nur die Initialisiererliste der Konstruktoren; dies hat einen guten Grund. Prinzipiell könnte man den Konstruktor auch folgendermaßen formulieren: 1 Kunde::Kunde(string const& name, string const& vorname, 2 string const& wohnort) 3 { 4 ID = 0; 5 Name = name; 6 Vorname = vorname; 7 Wohnort = wohnort; 8 Umsatz = 0; 9 Transaktionen = 0; 10 }
Was passiert bei der Ausführung dieses Konstruktors? Zunächst wird vom System der Speicherplatz besorgt, der alle Datenmember des neuen Objektes enthalten 2
KLASSEN
55
wird. Zuerst wird die Initialisiererliste betrachtet. Da keine existiert, werden alle Datenmember der Reihe nach defaultinitialisiert; die Member mit Basisdatentypen behalten die Werte, die zufällig an der Stelle im Speicher standen, an der sie platziert werden; die Member mit Klassentypen werden durch den klassentypischen Defaultkonstruktor initialisiert. Danach wird der Körper des Konstruktors ausgeführt, wobei die Datenmember mit Basisdatentypen auf den Wert 0 gesetzt werden. Die Member mit Klassentypen erhalten jeweils den übergebenen Wert zugewiesen. Die Zuweisung von Werten an die Datenmember im Körper des Konstruktors hat also den Nachteil, dass diese Datenmember zunächst defaultinitialisiert werden, und danach die Initialisierung durch die Zuweisung eines Wertes überschrieben wird. Es spart also einen Arbeitsschritt ein, wenn wir – durch Angabe der Initialisiererliste – die Datenmember direkt mit den übergebenen Werten initialisieren. Der Körper des Konstruktors wird dann verwendet, wenn die Initialisierung eines Objektes mit umfangreicheren Aktionen, wie zum Beispiel der Kontaktierung einer Datenbank oder der Herstellung einer Netzwerkverbindung einhergeht, oder generell, wenn im Rahmen der Initialisierung des Objektes Funktionen aufgerufen werden müssen. Ein weiterer spezieller Konstruktor ist der Copykonstruktor. Wie die Bezeichnung nahe legt, wird er dafür verwendet, das neue Objekt als Kopie eines existierenden Objektes herzustellen. Zu diesem Zweck bekommt er eine Referenz auf das existierende Objekt übergeben: 1 struct Kunde 2 { 3 // Datenmember wie bisher 4 5 // Konstruktoren wie bisher 6 7 Kunde(Kunde const&); 8 9 // Memberfunktionen wie bisher 10 };
Wichtig ist an dieser Stelle, dass der Copykonstruktor eine Referenz auf das Objekt übergeben bekommt. Wird ein Objekt „by value“ übergeben, so wird für diese Übergabe eine Kopie angefertigt, wie wir in Kapitel 1.3 diskutiert haben. Diese Kopie wird natürlich bei Klassentypen mit Hilfe des Copykonstruktors angefertigt. Wenn nunmehr beim Aufruf des Copykonstruktor selbst eine Kopie angefertigt werden muss, so geraten wir in eine Rekursion: zum Aufruf des Copykonstruktors muss eine Kopie angefertigt werden, wozu der Copykonstruktor aufgerufen wird, wobei wiederum eine Kopie angefertigt werden muss ... und endlos so weiter. Wird eine Referenz übergeben, so wird keine Kopie angefertigt und alles läuft prima. Ich habe mir angewöhnt, stets eine const-Referenz zu übergeben, um zu dokumentieren (und den Compiler prüfen zu lassen), dass ich am übergebenen Objekt nichts ändern will.
56
ERZEUGUNG, KOPIE UND ZERSTÖRUNG VON OBJEKTEN
2
Wie sieht die Implementierung eines solchen Copykonstruktors aus? Seine Aufgabe ist es ja, alle Datenmember mit den Werten der entsprechenden Datenmember im übergebenen Objekt zu initialisieren: 1 Kunde::Kunde(Kunde const& K) 2 :ID(K.ID), Name(K.Name), Vorname(K.Vorname), Wohnort(K.Wohnort), 3 Umsatz(K.Umsatz), Transaktionen(K.Transaktionen) 4 { 5 }
Da es sich um einen Konstruktor handelt, können (und sollten wir, soweit möglich) die Initialisiererliste verwenden, um die Datenmember zu initialisieren. Müssen wir immer einen Copykonstruktor angeben? Dies hängt von verschiedenen Faktoren ab. Wenn wir keinen Copykonstruktor angeben, so synthetisiert der Compiler einen. Genau wie der synthetisierte Defaultkonstruktor führt auch dieser verschiedene Aktionen für verschiedene Arten von Datenmembern aus: Für Member mit Basisdatentypen wird eine bitweise Kopie der Member angefertigt. Bei Membern mit Klassentypen erfolgt der Aufruf des Copykonstruktors der entsprechenden Klasse für jeden solchen Member. Kritische Situationen können eintreten, wenn wir Zeiger als Member einer Klasse haben. Alle Zeigertypen sind per definitionem Basisdatentypen, folglich wird eine bitweise Kopie des Zeigers angefertigt, was bedeutet, dass nach der Kopie zwei Objekte einen Member haben, der auf die selbe Speicheradresse verweist. Dies ist – außer in gewissen Spezialfällen – fast nie eine gute Idee. Daher muss in diesem Fall mit hoher Wahrscheinlichkeit ein Copykonstruktor angegeben werden, der das Problem behebt. Wir werden uns im Abschnitt 11.2.1 im Rahmen der Diskussion von manueller Speicherverwaltung mit diesem Problem näher beschäftigen. Im Falle unserer Klasse Kunde hätten wir – beim momentanen Stand der Implementierung – auf die Definition eines Copykonstruktors verzichten können. Die bitweise Kopie der Werte von ID, Umsatz und Transaktionen, sowie die Kopie der strings mit Hilfe ihres Copykonstruktors ist genau das, was wir in unserem selbstdefinierten Copykonstruktor auch tun. Außer bei dem bereits diskutierten Fall der Übergabe von Objekten „by value“ an eine Funktion, wird der Copykonstruktor auch dann aufgerufen, wenn wir eine lokale Variable anlegen und gleichzeitig initialisieren, oder wenn wir einen von einer Funktion zurückgelieferten Wert in eine Variable kopieren: 1 int main() 2 { 3 Kunde K("Müller", "Herbert", "Leipzig"); // Konstruktor 4 Kunde K1(K); // Copykonstruktor 5 Kunde K2 = K1; // Copykonstruktor 6 7 // ... 8 }
2
KLASSEN
57
2.3.1
DESTRUKTOR
Der Entwickler einer Klasse hat nicht nur die Kontrolle darüber, was bei der Erzeugung von Objekten seiner Klasse passiert, er kann auch eigene Vorkehrungen bei der Zerstörung von Objekten seiner Klasse treffen. Ein Objekt wird zerstört, wenn der Programmfluss den Bereich der Gültigkeit des Objekts verlässt. Wurde ein Objekt zum Beispiel als lokale Variable einer Funktion oder eines Schleifenkörpers angelegt, so wird das Objekt automatisch zerstört, sobald der Schleifenkörper bzw. der Funktionskörper verlassen wird. Ein weiterer Weg, ein Objekt zu zerstören, wird in Abschnitt 11.2 im Rahmen der Diskussion manueller Speicherverwaltung diskutiert. Technisch gesehen ist anschließend das Objekt nicht mehr vorhanden: Sein Name ist nicht mehr gültig und sein Speicherplatz ist nicht mehr garantiert unter Kontrolle des Programms. Bevor der Speicherplatz des Objektes freigegeben wird, ruft die Implementierung den Destruktor der Klasse für das Objekt auf. Der Entwickler der Klasse hat damit die Chance, den Status des Objektes zu sichern, vom Objekt benutzte Ressourcen (Speicher, Dateien, ...) freizugeben, oder einfach nur die Zerstörung eines Objektes andersweitig zu vermerken. In jeder Klasse kann nur ein Destruktor definiert werden. Dieser trägt den gleichen Namen wie die Klasse, wobei dem Namen eine Tilde (~) vorangesetzt wird. Er hat keine Übergabeparameter und auch keinen Rückgabedatentyp (auch nicht void!). Für unsere Klasse Kunde könnte folgender Destruktor definiert werden: 1 struct Kunde 2 { 3 // Datenmember wie bisher 4 5 // Konstruktoren wie bisher 6 7 ~Kunde() 8 {} // Destruktor 9 10 // Memberfunktionen wie bisher 11 };
Wie wir sehen, ist der Körper des Destruktors leer, d.h. dieser Destruktor tut nichts. Ursache dafür ist, dass es in unserem konkreten Fall nichts zu tun gibt: Für alle Datenmember mit Klassentypen wird automatisch deren Destruktor aufgerufen; alle Datenmember von Basisdatentypen werden nicht speziell behandelt. Anders läge die Sache, wenn wir innerhalb unseres Objektes manuell Speicher verwaltet hätten, oder andersweitig Zeigervariablen halten würden, die bei der Zerstörung des Objektes besonders behandelt werden müssen. Die entsprechenden Behandlungsschritte können wir dann innerhalb des Destruktors erledigen. Ein Beispiel dafür sehen wir in Abschnitt 11.2.1.
58
ERZEUGUNG, KOPIE UND ZERSTÖRUNG VON OBJEKTEN
2
2.4
static-MEMBER
In der prozeduralen Version unseres Kunden-Programms verwendeten wir eine static-Variable, um die Kunden-ID bei jedem neuen Aufruf der Funktion erzeuge_kunde inkrementieren und der neuen Kunden-Datenstruktur zuweisen zu können. Da der Wert der static-Variable über mehrere Aufrufe der Funktion hinweg erhalten bleibt, wird für jedes neu erzeugte Kunden-Objekt eine jeweils neue ID vergeben. In der objektorientierten Version des Programms wird jedes neu erzeugte Objekt mit Hilfe eines Konstruktors initialisiert. Ein Konstruktor ist keine Funktion im herkömmlichen Sinn, weshalb eine lokale static-Variable in einem Konstruktor nicht funktioniert. Wir können dafür jedoch static-Member verwenden. Es handelt sich dabei um Datenmember oder Memberfunktionen, die mit dem Schlüsselwort static deklariert wurden: 1 struct Kunde 2 { 3 static int max_ID; 4 5 // Datenmember wie bisher 6 7 // Konstruktoren wie bisher 8 9 static int get_max_ID(); 10 11 // Memberfunktionen wie bisher 12 };
Solche static-Datenmember existieren nicht separat für jedes Objekt einer Klasse, sondern lediglich einmal pro Klasse. Memberfunktionen, die als static definiert wurden, können zum Zugriff auf static-Datenmember verwendet werden. Da die static-Datenmember nur einmal pro Klasse existieren, müssen sie unabhängig von der Erzeugung von Objekten definiert werden. Es bietet sich an, dies in der gleichen Quelltextdatei zu erledigen, in der sich auch die Definitionen der Memberfunktionen befinden. Weiterhin wollen wir die static-Memberfunktion get_max_ID dort definieren: 1 1 2 3 4 5
int Kunde::max_ID(0); int Kunde::get_max_ID() { return Kunde::max_ID; }
Die Angabe Kunde::max_ID weist den Compiler auf die Tatsache hin, dass es sich um eine Variable handelt, die zum Namensraum von Kunde handelt (und nicht um eine Membervariable der Klasse Kunde, die individuell für jedes Objekt existiert). 2
KLASSEN
59
Es ist vom C++-Standard garantiert, dass static-Datenmember einer Klasse zum Zeitpunkt der ersten Objekterzeugung dieser Klasse angelegt und mit dem Initialwert (sofern bei der Definition einer angegeben wurde) belegt sind. Wir können daher in unserem Konstruktor dafür sorgen, dass jedes erzeugte Objekt eine neue ID bekommt, indem wir jeweils den Wert von max_ID als aktuelle ID verwenden und diesen Wert anschließend inkrementieren: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// kunde.h: struct Kunde { // Datenmember und statische Member wie bisher Kunde() : ID(max_ID++), Umsatz(0), Transaktionen(0) {} // Konstruktoren und Memberfunktionen wie bisher };
// kunde.cc: Kunde::Kunde(string const& name, string const& vorname, string const& wohnort) :ID(max_ID++), Name(name), Vorname(vorname), Wohnort(wohnort), Umsatz(0), Transaktionen(0) { }
Der Defaultkonstruktor ist inline definiert, daher müssen wir eine Änderung in der Headerdatei und eine Änderung in der Quelltextdatei vornehmen. Wir setzen den Datenmember ID des neu erzeugten Objektes jeweils auf den aktuellen Wert von max_ID und inkrementieren max_ID anschließend. Da max_ID einmal pro Klasse vorhanden ist, sieht jedes neu erzeugte Objekt einen jeweils um Eins erhöhten Wert als das vorherige Objekt. Damit stellen wir sicher, dass keine zwei Objekte die selbe ID erhalten. Lediglich im Falle des Copykonstruktors erlauben wir weiterhin die parallele Existenz mehrerer Objekte mit der gleichen ID. Da es sich aber in diesem Fall um Kopien eines Objektes handelt, erscheint dies gerechtfertigt. Wir wollen unsere bisherigen Änderungen testen, indem wir die Memberfunktion schreibe so ändern, dass sie die ID des Kunden mit ausgibt: 1 2 3 4 5
60
ostream& Kunde::schreibe(ostream& out) const { out << "Kunde " << Vorname << " " << Name << " mit ID " << ID <<" aus " << Wohnort << " hatte " << Transaktionen
STATIC-MEMBER
2
6 7 8 9 10 }
<< ((Transaktionen == 1)?" Transaktion":" Transaktionen") << " und " << Umsatz << " EUR Umsatz." << endl; return out;
Weiterhin ändern wir die Hauptfunktion main, so dass zwei Kunden angelegt werden. Der zweite Kunde soll außerdem mehr als eine Transaktion durchführen: 1 int main() 2 { 3 Kunde K("Müller", "Helmut", "Musterdorf"); 4 Kunde K1("Meier", "Herbert", "Beispielstadt"); 5 6 K.kaufe(99.95); 7 K1.kaufe(19.95); 8 K1.kaufe(156.05); 9 10 K.schreibe(cout); 11 K1.schreibe(cout); 12 }
Das Ergebnis der Ausführung des entstandenen Programms ist: [mme@endeavour Kunde2]: ./kunde Kunde Helmut Müller mit ID 0 aus Musterdorf hatte 1 Transaktion und 99.95 EUR Umsatz. Kunde Herbert Meier mit ID 1 aus Beispielstadt hatte 2 Transaktionen und 176 EUR Umsatz.
2.5
ZUGRIFFSSCHUTZ
Obwohl wir in der bisherigen objektorientierten Version unseres Kunden-Programms die in 2.1.2 genannten Probleme mit der Verwendung von Datenstrukturen im prozeduralen Ansatz weitgehend ausgeräumt haben, ist dennoch unser aktueller Code in dieser Hinsicht nicht vollkommen zufrieden stellend. Nehmen wir an, ein anderer Programmierer benutzt unsere struct Kunde. Was sollte ihn daran hindern, direkt auf die Datenmember zuzugreifen? Auch wenn wir in einer struct Memberfunktionen definieren, ist trotzdem weiterhin ein solcher direkter Zugriff möglich. C++ bietet daher 3 Zugriffschutz-Marken an, die eingesetzt werden können, um Member von Klassen (Datenmember genauso wie Memberfunktionen) vor unerlaubten Zugriffen zu schützen. Die Angabe einer Zugriffsschutzmarke schützt alle Member zwischen dieser Angabe und der nächsten Zugriffsschutzmarke, bzw. dem Ende der Klassendefinition.
2
KLASSEN
61
Die folgenden Zugriffsschutzmarken sind definiert: public
Auf die Member kann ohne weiteres zugegriffen werden.
private
Auf die Member kann nur aus Memberfunktionen der Klasse und aus befreundeten Funktionen und Klassen zugegriffen werden.
protected
Auf die Member kann aus Memberfunktionen der Klasse und aus Memberfunktionen von abgeleiteten Klassen (siehe Kapitel 3.5) zugegriffen werden.
Der Zugriffsschutz bezieht sich dabei nicht auf ein Objekt, sondern auf alle Objekte der Klasse. Dies bedeutet, eine Memberfunktion der Klasse kann auch auf alle Member eines ihr übergebenen Objektes derselben Klasse zugreifen. Um einen Zugriff auf die Datenmember der struct Kunde zu verbieten, den Aufruf der Memberfunktionen jedoch zu erlauben, könnten wir in der Klassendefinition folgende Veränderungen vornehmen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
62
struct Kunde { private: static int max_ID;
// neu!
// Stammdaten int ID; std::string Name; std::string Vorname; std::string Wohnort;
// Umsatz und Anzahl der Einzeltransaktionen double Umsatz; int Transaktionen; public: // neu! Kunde() : ID(max_ID++), Umsatz(0), Transaktionen(0) {} Kunde(std::string const& Name, std::string const& Vorname, std::string const& Wohnort); Kunde(Kunde const& K); void kaufe(double Umsatz); std::ostream& schreibe(std::ostream& out) const; static int get_max_ID(); };
ZUGRIFFSSCHUTZ
2
Wenn wir nun versuchen, auf einen Datenmember direkt zuzugreifen ... 1 int main() 2 { 3 Kunde K("Müller", "Helmut", "Musterdorf"); 4 5 K.kaufe(99.95); 6 7 // Fehlerhafter Zugriff: 8 cout << "Umsatz von Herrn Müller: " << K.Umsatz << endl; 9 }
... meldet der Compiler einen Fehler bei der Compilierung: kunde.h:19: error: ‘double Kunde::Umsatz’ is private main.cc:10: error: within this context
Auf diese Weise haben wir effektiv die Forderung durchgesetzt, dass der Nutzer der Klasse niemals direkt auf die Datenmember zugreifen kann – wir können diese also nach unseren Bedürfnissen verändern, ohne dass ein Nutzer seinen Code ändern muss. Leider haben wir es den Nutzern von struct Kunde nun unmöglich gemacht, sich den Umsatz oder die Anzahl von Transaktionen eines Kunden anzuschauen. Wir haben die Nutzer auf die von uns in Form von Memberfunktionen bereitgestellte Schnittstelle der Klasse Kunde beschränkt. Infolgedessen ist es unsere Pflicht, dafür zu sorgen, dass diese Schnittstelle alle Aktionen enthält, die für die sinnvolle Benutzung von Objekten der Klasse erforderlich sein könnten. Wir bieten also Memberfunktionen an, mit deren Hilfe wir den Umsatz und die Anzahl der Transaktionen lesen können, sowie Namen, Vornamen und Wohnort: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// kunde.h:
2
KLASSEN
struct Kunde { private: // Datenmember public: // Memberfunktionen bisher std::string name() const; std::string vorname() const; std::string wohnort() const; double umsatz() const; int transaktionen() const; };
63
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
// kunde.cc: // Memberfunktionen wie bisher: string Kunde::name() const { return Name; } string Kunde::vorname() const { return Vorname; } string Kunde::wohnort() const { return Wohnort; } double Kunde::umsatz() const { return Umsatz; } int Kunde::transaktionen() const { return Transaktionen; }
Die Memberfunktionen werden nur dafür benutzt, Datenmember zu lesen, daher sollten wir sie als const deklarieren. Wie in Kapitel 2.2 erläutert, können wir damit erreichen, dass der Compiler uns meldet, wenn wir aufgrund eines Programmierfehlers versuchen sollten, die Datenmember zu verändern. Mit den angegebenen Funktionen erlauben wir dem Nutzer unserer Klasse lediglich den Lesezugriff. Wir möchten jedoch auch die Änderung von Name, Vorname und Wohnort des Kunden erlauben. Lediglich Umsatz und Transaktionszähler bleiben unter Kontrolle der Klasse – sie werden durch die Benutzung der Methode kaufe implizit verändert: 1 2 3 4 5 6 7 8 9 10
64
// kunde.h: struct Kunde { private: // Datenmember public: // Memberfunktionen wie bisher void name(std::string const&);
ZUGRIFFSSCHUTZ
2
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
void vorname(std::string const&); void wohnort(std::string const&); }; //kunde.cc: // Memberfunktionen wie bisher: void Kunde::name(string const& n) { Name = n; } void Kunde::vorname(string const& v) { Vorname = v; } void Kunde::wohnort(string const& w) { Wohnort = w; }
Wir verwenden die gleichen Namen für die Memberfunktionen zum Ändern der Werte wie für die Memberfunktionen zum Lesen der Werte. Der Compiler kann die verschiedenen Funktionen dann anhand der übergebenen Parameter unterscheiden – dies entspricht dem in Kapitel 1.5 dargestellten Prinzip der Überladung für einfache Funktionen, das folglich auch für Memberfunktionen verwendet werden kann. Memberfunktionen können zusätzlich noch anhand ihrer const-heit überladen werden. Eine const-Memberfunktion kann also neben einer nicht-const-Memberfunktion mit gleichem Namen und gleicher Parameterliste existieren. Die const-Memberfunktion wird verwendet, wenn die Funktion auf einem als const deklarierten Objekt aufgerufen wird. Wir wollen die soeben entwickelten Memberfunktionen in einer entsprechenden Hauptfunktion main testen: 1 int main() 2 { 3 // wie bisher 4 5 K1.wohnort("Musterdorf"); 6 7 cout << "Herr " << K1.name() << " wohnt jetzt in " << K1.wohnort() 8 << "." << endl; 9 }
2
KLASSEN
65
Ich habe das neue Programm kunde2 genannt und erhalte beim Start folgende Ausgabe: [mme@endeavour Kunde3]: ./kunde2 Kunde Helmut Müller mit ID 0 aus Musterdorf hatte 1 Transaktion und 99.95 EUR Umsatz. Kunde Herbert Meier mit ID 1 aus Beispielstadt hatte 2 Transaktionen und 176 EUR Umsatz. Herr Meier wohnt jetzt in Musterdorf.
Wir sehen, dass sich der Wohnort des zweiten Kunden nach dem Aufruf der Funktion Kunde::wohnort(string const&) geändert hat. Außerdem können wir das Funktionieren der Lesefunktionen verifizieren. Bei Klassen mit vielen Datenmembern ist es ratsam, genau zu prüfen, auf welche davon direkter Lese- und/oder Schreibzugriff gewährt werden soll. Da die Entwicklung der Zugriffsfunktionen stets nach dem gleichen Schema abläuft, gibt es in Entwicklungsumgebungen gegebenenfalls Möglichkeiten, Zugriffsfunktionen „auf Knopfdruck“ automatisch generieren zu lassen.
2.6
CLASS
Im vorhergehenden Abschnitt haben wir den Zugriffsschutz auf Datenmember und Memberfunktionen diskutiert. Wir haben ebenfalls bereits erkannt, dass innerhalb einer struct ohne Angabe von Zugriffsschutzmarken stets alle Member als public deklariert sind. In C++ existiert zur Definition einer eigenen Klasse auch das Schlüsselwort class. Dieses kann anstelle von struct verwendet werden. Dabei ändert sich der Zugriffsschutz: ohne Angabe von Zugriffsschutzmarken sind alle Member als private deklariert. Dies unterstützt den Autor der Klasse dabei, bewusst zu entscheiden, welche Member er als öffentlich nutzbare Schnittstelle verwenden will, und welche nicht. Wir werden künftig stets class zur Definition einer Klasse verwenden. Es sei aber noch einmal darauf hingewiesen, dass struct den selben Zweck erfüllt, wobei lediglich ein anderer Standard-Zugriffsschutz gegeben ist. Im Folgenden sehen wir noch einmal einen Überblick über die momentan zum Projekt gehörende Klasse und ihre Implementierung: 1 2 3 4 5 6 7
66
// kunde.h class Kunde { // Standard-Zugriffsschutz ist private static int max_ID;
CLASS
2
8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
2
// Stammdaten int ID; std::string Name; std::string Vorname; std::string Wohnort; // Umsatz und Anzahl der Einzeltransaktionen double Umsatz; int Transaktionen; public: Kunde() : ID(max_ID++), Umsatz(0), Transaktionen(0) {} Kunde(std::string const& Name, std::string const& Vorname, std::string const& Wohnort); Kunde(Kunde const& K); void kaufe(double Umsatz); std::ostream& schreibe(std::ostream& out) const; std::string name() const; std::string vorname() const; std::string wohnort() const; double umsatz() const; int transaktionen() const; void name(std::string const&); void vorname(std::string const&); void wohnort(std::string const&); static int get_max_ID(); }; // kunde.cc int Kunde::max_ID(0); Kunde::Kunde(string const& name, string const& vorname, string const& wohnort) :ID(max_ID++), Name(name), Vorname(vorname), Wohnort(wohnort), Umsatz(0), Transaktionen(0) {
KLASSEN
67
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
68
} Kunde::Kunde(Kunde const& K) :ID(K.ID), Name(K.Name), Vorname(K.Vorname), Wohnort(K.Wohnort), Umsatz(K.Umsatz), Transaktionen(K.Transaktionen) { } void Kunde::kaufe(double umsatz) { Umsatz += umsatz; Transaktionen++; } ostream& Kunde::schreibe(ostream& out) const { out << "Kunde " << Vorname << " " << Name << " mit ID " << ID <<" aus " << Wohnort << " hatte " << Transaktionen << ((Transaktionen == 1)?" Transaktion":" Transaktionen") << " und " << Umsatz << " EUR Umsatz." << endl; return out; } int Kunde::get_max_ID() { return Kunde::max_ID; } string Kunde::name() const { return Name; } string Kunde::vorname() const { return Vorname; } string Kunde::wohnort() const { return Wohnort; } double Kunde::umsatz() const { return Umsatz; }
CLASS
2
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
int Kunde::transaktionen() const { return Transaktionen; } void Kunde::name(string const& n) { Name = n; } void Kunde::vorname(string const& v) { Vorname = v; } void Kunde::wohnort(string const& w) { Wohnort = w; }
2.7
OPERATOREN
Basisdatentypen wie int, double oder char können mit Operatoren verknüpft werden. Für eigene Klassen kann der Entwickler dieses Verhalten durch Definition spezieller Funktionen und Memberfunktionen erreichen. Diese Funktionen tragen im Funktionsnamen das Schlüsselwort operator, sowie das Operatorenzeichen selbst. Operatoren können sowohl als Member- als auch als eigenständige Funktionen implementiert werden, wobei für bestimmte Operatoren vorzugsweise die eine oder andere Implementationsweise verwendet wird. Wir werden uns im Folgenden mit der Implementierung einiger ausgewählter Operatoren beschäftigen. Weitere Hinweise können dann der Literatur entnommen werden.
2.7.1
ZUWEISUNGSOPERATOR (operator=)
Der Zuweisungsoperator = wird benutzt, um den Wert eines Objekts (einer Variable) in ein anderes Objekt (Variable) zu übertragen. Der Wert des Zielobjektes geht dabei verloren. Beispiele für Zuweisungen: 1 2 3 4 5 6
2
// Definition von Variablen/Objekten: int i; int j; Kunde K1; Kunde K2;
KLASSEN
69
7 8 9 10 11 12 13
// ... Modifikation der Objekte ... // Zuweisungen: i = j; K2 = K1; // ...
Zuweisungen können verkettet werden: 1 2 3 4
int i, j, k; // ... i = j = k;
Dabei haben am Ende sowohl i, als auch j den Wert von k. Dies erfordert, dass das Ergebnis einer Zuweisungsoperation der zugewiesene Wert sein muss. Da die Zuweisung – wie alle anderen Operationen auch – als Funktion dargestellt wird, können wir aus den bisherigen Darstellungen folgende Forderungen an diese Funktion ableiten: Die Rückgabe einer Zuweisungsoperation ist der zugewiesene Wert
(Verkettung), übergeben wird der Wert, der zugewiesen werden soll
(rechte Seite der Zuweisung), die Zuweisung ist eine Memberfunktion, denn der Wert eines Objektes
(also seine Datenmember) soll geändert werden. Eine weitere Forderung, deren Hintergrund sich in Abschnitt 11.2.1 zeigen wird, ist die Zusicherung, dass Zuweisungen eines Objektes an sich selbst erkannt und behandelt werden sollen. Wir wollen für unsere Kunden-Klasse einen Zuweisungsoperator entwickeln: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
70
// kunde.h: class Kunde { // Datenmember wie bisher public: // Konstruktoren wie bisher Kunde& operator=(Kunde const&); // Memberfunktionen wie bisher };
OPERATOREN
2
15 16 17 18 19 20 21 22 23 24 25 26 27 28
// kunde.cc: Kunde& Kunde::operator=(Kunde const& K) { if(this != &K) { ID = K.ID; Name = K.Name; Vorname = K.Vorname; Wohnort = K.Wohnort; Umsatz = K.Umsatz; Transaktionen = K.Transaktionen; } return *this; }
In der Implementierung des Zuweisungsoperators vergleichen wir zunächst, ob wir das Objekt an sich selbst zuweisen. Dazu verwenden wir das Schlüsselwort this, das einen Zeiger auf das Objekt darstellt, für das die betreffende Funktion gerade aufgerufen wird. Wir vergleichen this mit der Adresse des (als const-Referenz) übergebenen Objektes, das wir zuweisen wollen. Nur wenn ein Unterschied zwischen this und der Adresse des übergebenen Objektes besteht, haben wir zwei Objekte an verschiedenen Speicherplätzen vorliegen (und damit zwei unterschiedliche Objekte) und wir weisen jeden Datenmember des übergebenen Objektes an den jeweiligen Datenmember des Objektes, auf dem die Funktion aufgerufen wird, zu. Am Ende der Funktion geben wir das eigene Objekt als Rückgabewert zurück. Damit stellen wir sicher, dass die Verkettung von Zuweisungsoperationen möglich ist. Um den Zusammenhang zwischen den verschiedenen an der Zuweisung beteiligten Objekten darzustellen, wollen wir die Anweisung 1
K1 = K2;
aus dem obigen Beispiel auseinandernehmen. Wir nehmen dabei an, dass K1 und K2 Objekte der Klasse Kunde sind. Der Compiler versteht die obige Zuweisung wie den Aufruf einer Memberfunktion namens operator=: 1
K1.operator=(K2);
Wenn wir nun die Implementierung des Zuweisungsoperators betrachten, so sehen wir, dass this in diesem Fall der Adresse von K1 entspräche, während K2 das übergebene Objekt K repräsentiert. Das Ergebnis der Zuweisung (bzw. des Aufrufs der Memberfunktion operator=) wird in diesem Fall verworfen. Stellen wir uns vor, wir hätten zusätzlich zu den beiden eben erwähnten Objekten ein drittes, nämlich K3, ebenfalls eine Instanz der Klasse Kunde. Wir schreiben nun folgende Zuweisung: 1
K3 = K2 = K1;
Der Compiler sieht dies als zweifachen Aufruf der Memberfunktion operator=: 1
2
K3.operator=(K2.operator=(K1));
KLASSEN
71
Dies bedeutet, dass das Ergebnis des Aufrufs von K2.operator=(K1) diesmal nicht verworfen, sondern als Übergabeparameter für die zweite Zuweisungsoperation verwendet wird. Wir erkennen hieran, wie wichtig es ist, dass Operatoren Ergebnisse zurückliefern, damit sie verkettbar sind. Genau wie der Defaultkonstruktor, der Destruktor und der Copykonstruktor, wird für jede von uns entwickelte Klasse ein Zuweisungsoperator automatisch definiert, wenn wir dies nicht tun. Dieser erledigt prinzipiell ähnliche Arbeiten wie der Copykonstruktor: Für alle Datenmember mit Basisdatentypen wird der Wert des zuzuweisenden
Datenmembers übertragen, für alle Datenmember mit Klassentypen wird der Zuweisungsoperator der jewei-
ligen Klasse mit dem zuzuweisenden Datenmember als Parameter aufgerufen. In denselben Fällen, in denen wir gezwungen sind, einen eigenen Copykonstruktor und einen eigenen Destruktor anzugeben, sind wir auch veranlasst, einen Zuweisungsoperator zu schreiben. Dies führt zur Dreierregel: Wenn eine Klasse einen eigenen Copykonstruktor benötigt, so benötigt sie stets auch einen eigenen Destruktor und einen eigenen Zuweisungsoperator.
2.7.2
AUSGABEOPERATOR (operator<<)
Der Ausgabeoperator dient der Ausgabe von Objekten der Klasse auf einem Ausgabekanal. Er wird stets als eigenständige Funktion implementiert, denn die Angabe als Memberfunktion ist aus technischen Gründen unmöglich. Betrachten wir dazu folgende Gegenüberstellung: 1 2 3 4
Kunde K1, K2; // ... K1 = K2; cout << K1;
Wir sehen hier zunächst die Definition von zwei Objekten der Klasse Kunde. Danach folgt eine Zuweisung, wobei der Compiler selbige in folgender Form auffasst: 1
K1.operator=(K2);
Per Analogieschluss können wir feststellen, dass der Compiler die Ausgabe des Objektes K1 auf dem Ausgabekanal cout folgendermaßen interpretiert: 1
cout.operator<<(K1);
Wie wir wissen, ist cout ein Objekt des Typs (der Klasse) ostream aus dem Namespace std. Da es sich dabei um den Namensraum der C++-Standardbibliothek handelt, können wir nicht einfach in der Klasse ostream eine Memberfunktion ergänzen. Vielmehr versucht der Compiler, sofern er keine Memberfunktion der oben angegebenen Form findet, eine stand-alone-Funktion der folgenden Form zu verwenden: 1
72
operator<<(cout, K1);
OPERATOREN
2
Da es sich um eine stand-alone-Funktion handelt, müssen natürlich beide Operanden als Parameter übergeben werden; die Regel ist hierbei, dass die Operanden stets von links nach rechts übergeben werden. Da wir schreiben könnten 1
cout << K1 << K2;
ergibt sich die Forderung, dass auch dieser Operator verkettbar sein muss. Wandeln wir diese Sequenz in die Funktionsschreibweise um, so können wir erkennen, welche Datentypen wir verwenden müssen: 1
operator<<(operator<<(cout,K1),K2);
Daraus können wir schlussfolgern, dass das Ergebnis des Operators als erster Parameter für einen weiteren Operatoraufruf verwendbar sein muss. Wir leiten folgende Funktionsdeklaration ab: 1 2 3 4 5 6 7 8
// kunde.h: class Kunde { // Definition der Member wie bisher. }; std::ostream& operator<<(std::ostream& Aus, Kunde const& K);
Wenn wir uns nun der Implementierung zuwenden, erkennen wir ein weiteres Problem: 1 2 3 4 5 6 7 8 9 10
// kunde.cc: ostream& operator<<(ostream& Aus, Kunde const& K) { // funktioniert so nicht! Aus << "Name: " << K.Name << "," << K.Vorname << " \tID:" << K.ID << " \tUmsatz:" << K.Umsatz << " \tTransakt.: " << K.Transaktionen << endl; return Aus; }
Da die Klasse Kunde alle Datenmember in der private-Sektion enthält, können wir von der Funktion operator<< aus, die ja keine Memberfunktion der Klasse ist, nicht auf diese zugreifen. Der Code oben würde nicht compilieren. Eine einfache Lösung für dieses Problem wäre, auf die Member über die von uns in Kapitel 2.5 vereinbarten Zugriffsfunktionen zuzugreifen. Wir könnten dann zum Beispiel K.vorname() anstelle von K.Vorname schreiben. Leider haben wir keine Zugriffsfunktion für die ID geschrieben; möglicherweise wollen wir auch keinen allgemeinen Lesezugriff auf dieses (in gewisser Weise ja interne) Datum erlauben; dennoch könnte es sinnvoll sein, die ID zum Bestandteil der Ausgabe des Objektes zu machen (zum Beispiel für Debugging-Zwecke). Die Möglichkeit, ein Datum in einem Stück Programmcode zu verwenden ist ja etwas anderes, als ein Datum auf einem Ausgabekanal auszugeben. Es haben sich daher zwei Methoden herausgebildet, derartige Probleme zu lösen. 2
KLASSEN
73
friend-DEKLARATIONEN Eine dieser Methoden ist die Deklaration als friend. Dadurch wird eine Funktion oder eine Klasse in die Lage versetzt, auf alle private-Member der deklarierenden Klasse zuzugreifen. Die friend-Deklaration muss an einer beliebigen Stelle der Klassendefinition stehen, wobei die Zugriffsschutz-Sektionen dafür keine Rolle spielen: 1 class Kunde 2 { 3 friend std::ostream& operator<<(std::ostream&, Kunde const& K); 4 5 // restliche Definition wie bisher 6 };
Die friend-Deklaration enthält neben dem Schlüsselwort friend den kompletten Funktionskopf der als friend zu deklarierenden Funktion, bzw. den Namen der als friend zu deklarierenden Klasse. Aus der dort genannten Funktion, bzw. aus allen Memberfunktionen der als friend deklarierten Klasse, kann dann uneingeschränkt auch auf private- und protectedMember der Klasse, in der die Deklaration formuliert ist, zugegriffen werden. Der oben angegebene Code sollte damit compilieren und funktionieren.
SUPPORTFUNKTION INNERHALB DER KLASSE Während die erste Methode ein Feature von C++ verwendet, verwenden wir hier eher eine spezielle Codingpraxis, als ein Sprachmittel. Die zugrunde liegende Überlegung für diese zweite Methode ist, dass wir den operator<< nur aus dem Grund als eigenständige Funktion implementieren müssen, weil der Compiler einen Ausdruck der Form A << B stets als A.operator<<(B) bzw. operator<<(A,B) umsetzt. Wäre diese Interpretation flexibler, könnten wir den Ausgabeoperator ohne weiteres als Memberfunktion der Klasse Kunde gestalten, und unsere Zugriffsprobleme auf die Datenmember würden nicht bestehen. Nichts hindert uns nun aber daran, eine Ausgabefunktion zu schreiben, mit deren Hilfe wir ein entsprechendes Objekt auf einem Ausgabekanal ausgeben können. Dies haben wir in Form der Funktion schreibe bereits getan: 1 2 3 4 5 6 7 8 9 10
74
// kunde.h: class Kunde { // Datenmember, Konstruktoren, Memberfunktionen std::ostream& schreibe(std::ostream& out) const; // weitere Memberfunktionen };
OPERATOREN
2
11 12 13 14 15 16 17 18 19 20 21
// kunde.cc: ostream& Kunde::schreibe(ostream& out) const { out << "Kunde " << Vorname << " " << Name << " mit ID " << ID <<" aus " << Wohnort << " hatte " << Transaktionen << ((Transaktionen == 1)?" Transaktion":" Transaktionen") << " und " << Umsatz << " EUR Umsatz." << endl; return out; }
Nun können wir aus dem operator<< natürlich ohne weiteres diese Memberfunktion aufrufen: 1 2 3 4 5
// kunde.cc: ostream& operator<<(ostream& Aus, Kunde const& K) { return K.schreibe(Aus); }
Wie wir am Typ des Übergabeparameters sehen, zahlt es sich hier aus, dass wir die Memberfunktion const gemacht haben; auf einer const-Referenz können ja lediglich const-Memberfunktionen aufgerufen werden. Damit brauchen wir keine friend-Deklaration für den operator<<, denn die Memberfunktion schreibe ist im public-Bereich der Klasse Kunde definiert. Welche der beiden Methoden wir für die Lösung des Problems verwenden, bleibt dem persönlichen Geschmack des Programmierers und den Umständen des Projekts überlassen. Wir können auf dieser Basis jedenfalls unsere Ausgaben in der Hauptfunktion main unseres Beispielprogramms etwas mehr im Sinne von C++ formulieren: 1 int main() 2 { 3 // ... 4 5 // bisher: K.schreibe(cout); 6 // K1.schreibe (cout); 7 8 cout << K << K1; // neu 9 }
2.7.3
EINGABEOPERATOR (operator>>)
1 // kunde.h: 2 3 std::istream& operator>>(std::istream& Ein, Kunde& K);
Ein weiterer Unterschied ist hier, dass wir das einzulesende Objekt nicht als constReferenz übergeben bekommen. Da wir im Rahmen der Abarbeitung des Operators 2
KLASSEN
75
die Datenmember des Objektes anhand der eingegebenen Daten ändern wollen, ist dies sinnvoll. Die Implementierung stellt uns grundsätzlich vor die gleichen Probleme wie die Implementierung des Ausgabeoperators. Erneut können wir den Eingabeoperator als friend deklarieren und damit innerhalb des Eingabeoperators direkt die Datenmember des (als Referenz) übergebenen Objektes ändern. Genauso gut können wir jedoch auch eine Supportmethode entwickeln, die wir dann aus dem Operator heraus aufrufen. Wir wollen diesen Weg gehen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
// kunde.h class Kunde { // Datenmember public: // Konstruktoren std::istream& lese(std::istream& in); // weitere Memberfunktionen }; // kunde.cc: istream& Kunde::lese(istream& in) { if(in){ in >> ID >> Name >> Vorname >> Wohnort >> Umsatz >> Transaktionen; } return in; } istream& operator>>(istream& Ein, Kunde& K) { return K.lese(Ein); }
Für das Einlesen nehmen wir an, dass die Werte für die einzelnen Datenmember unseres Objektes in der aufgeführten Reihenfolge am Eingabekanal anliegen. Zwischen den einzelnen Werten kann eine beliebige Menge Whitespace 2 stehen, denn wie in Kapitel 1.2 erläutert, wird bei der Eingabe von einem istream Whitespace ignoriert. Bevor wir mit dem Einlesen beginnen, prüfen wir, ob Eingaben anliegen. Wir können dafür das istream-Objekt direkt als Bedingung verwenden. Abschließend geben wir das istream-Objekt als Ergebnis zurück; dies ist analog zum Vorgehen im Ausgabeoperator.
2
76
Whitespace entspricht allen druckbaren Zeichen, die Leerstellen erzeugen, als Leerzeichen, Tabulator, Zeilenvorschub, ...
OPERATOREN
2
Die Implementierung des Eingabeoperators entspricht ebenfalls der des Ausgabeoperators. Auch hier delegieren wir die eigentliche Arbeit an die soeben entwickelte Memberfunktion lese.
2.7.4
LOGISCHE OPERATOREN
Logische Operatoren dienen dem Vergleich von Objekten. Ihr Ergebnis ist stets ein Wert vom Typ bool. Logische Operatoren können sowohl als Memberfunktionen, als auch als eigenständige Funktionen implementiert werden. Sind sie als Memberfunktion implementiert, so wird die Funktion auf dem linken Operanden aufgerufen. Der rechte Operand wird als Parameter übergeben. Bei der Implementierung als eigenständige Funktion werden der linke und der rechte Operand in dieser Reihenfolge als Parameter übergeben. Gegebenenfalls müssen eigenständig implementierte logische Operatoren als friend deklariert werden; anderenfalls sind sie nicht in der Lage, auf private Datenmember der Objekte zuzugreifen. Wir wollen den Vergleichsoperator „kleiner als“ definieren, der eine aufsteigende Ordnung der Kunden anhand ihrer ID ermöglicht: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// kunde.h: class Kunde { // Datenmember public: // Konstruktoren, Destruktor, Zuweisungsoperator bool operator<(Kunde const& K) const; }; // kunde.cc: bool Kunde::operator<(Kunde const& K) const { return ID < K.ID; }
Wir vergleichen also die Datenmember ID der beiden Objekte. Ist die ID des linken Operanden kleiner als die des rechten Operanden, so wird der Wahrheitswert true zurückgeliefert; anderenfalls der Wahrheitswert false.
2.7.5
ARITHMETISCHE OPERATOREN
Grundsätzlich entsprechen arithmetische Operatoren in ihrer Implementierungsweise den logischen Operatoren. Der Unterschied liegt vorrangig darin, dass das Ergebnis eines arithmetischen Operators in der Regel ein Objekt der Klasse ist, für das der Operator entwickelt wird.
2
KLASSEN
77
Es ist nur dann sinnvoll, arithmetische Operatoren zu definieren, wenn die Klasse selbst sich wie ein arithmetischer Typ oder ein Mengentyp verhalten soll. Ein Beispiel wäre ein Datentyp für die Darstellung komplexer Zahlen oder eine Klasse zur Verwaltung von Objekten (Container).
2.8
ZUSAMMENFASSUNG
Dieses Kapitel beschäftigte sich mit der Möglichkeit der objektorientierten Programmierung in C++ mit Hilfe des Konzepts der „Klasse“. Eine Klasse fasst den Daten- und Steuerfluss zusammen, indem in einer sprachlichen Struktur sowohl Möglichkeiten zum Speichern von Daten („Datenmember“), als auch Möglichkeiten zur Verarbeitung dieser Daten („Memberfunktionen“) angeboten werden. Verschiedene Sprachmittel erlauben die selektive Freigabe von Datenmembern und Memberfunktionen für den Zugriff durch den Programmierer. Andere Sprachmittel erlauben dem Ersteller einer Klasse, spezielle Aktionen bei der Erzeugung von Objekten der Klasse („Konstruktor“), der Zerstörung von Objekten der Klasse („Destruktor“), sowie bei der Durchführung verschiedener Operationen mit Objekten der Klasse zu definieren.
2.9
ÜBUNGEN
1. Implementieren, compilieren und testen Sie die Programme und Konzepte dieses
Kapitels! 2. Implementieren Sie eine Klasse Geldwechsler, die zur Steuerung eines Geldwech-
selautomates verwendet werden könnte. Die Klasse muss die Anzahlen von Münzen in der Staffelung von 10 Cent bis 2 Euro enthalten, sowie einen Zähler für die eingegebenen Banknoten. Es sollen Münzen ab 50 Cent und Banknoten in Höhe von 5 und 10 Euro gewechselt werden können. Der Nutzer gibt eine entsprechende Note ein und erhält daraufhin das Wechselgeld in „sinnvoller“ Stückelung. Der Automat soll außerdem auf Anfrage die Anzahlen der momentan im System befindlichen Münzen zurückliefern. Eine weitere Funktion ist zu implementieren, die diese Informationen für die Banknoten angibt. 3. Implementieren Sie eine Klasse zur Erzeugung von Zufallszahlen. Diese soll mit
den Angaben für den Zahlenbereich (Minimum und Maximum), aus dem die Zufallszahlen stammen sollen, initialisiert werden. Ein weiterer Initialisierungsparamter ist der „Seedwert“ für den Zufallszahlengenerator. Der Nutzer soll mit Hilfe einer Memberfunktion ziehe() nacheinander verschiedene Zufallszahlen zurückgeliefert bekommen. Machen Sie sich im Vorfeld der Implementierung mit 78
ÜBUNGEN
2
den Funktionen rand und srand aus dem Standardheader cstdlib bekannt, die von der C-Standardbibliothek geerbt werden und zur Erzeugung von Zufallszahlen zur Verfügung stehen. Testen Sie den Zufallszahlengenerator! 4. Wandeln Sie die Funktionen zur Berechnung geometrischer Parameter aus Kapi-
tel 3 in eine Klasse für jedes geometrische Objekt um. Entscheiden Sie selbst, welche Memberfunktionen Sie benötigen. Testen Sie Ihre Implementierung!
2.10 LITERATUREMPFEHLUNGEN STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ In einem sehr ausführlichen Stil führt Stroustrup in Kapitel 10 in Klassen ein. Er behandelt alle auch hier angeschnittenen Themen und darüberhinaus auch einige, die hier erst in späteren Kapitel erörtert werden. Operatoren werden dann in Kapitel 11 wesentlich tiefergehend behandelt, als dies hier möglich wäre. Als Komplexbeispiel wird dabei insbesondere eine string-Klasse entwickelt.
KOENIG/MOO: „INTENSIVKURS C++“ Beginnend mit Kapitel 9 wird anhand eines Beispieles in die Entwicklung von Klassen eingeführt. Betrachtungen zu Operatoren und Klassen, werden in Kapitel 12 durchgeführt.
MEYERS: „EFFEKTIV C++ PROGRAMMIEREN“ Insbesondere die Abschnitte 12, 13, 15-28 sind interessant für den Entwurf von Klassen und die Ausgestaltung von Konstruktoren und Zuweisungsoperatoren. In den Abschnitten 29-34 wird auf Implementierungsstrategien eingegangen, wobei die Themen teilweise den Stoff dieses Kapitels übersteigen.
2
KLASSEN
79
ö
3 VERERBUNG Wir werden uns in diesem Kapitel mit der Bildung von Klassenhierarchien beschäftigen. Wer dieses Kapitel durchgearbeitet hat, sollte folgende Fähigkeiten erlangt haben:
Î Erkennen von Hierarchien in programmiererisch umzusetzenden Zusammenhängen Î Erzeugung von abgeleiteten Klassen Î Nutzung von Polymorphie 3.1
BILDUNG VON EINFACHEN KLASSENHIERARCHIEN
Vererbung ist eine Möglichkeit, Zusammenhänge zwischen Klassen darzustellen. Stellen wir uns anhand unseres bisherigen Beispiels vor, wir wollten zwei Arten von Kunden in unserem Programm abbilden: eine Art, die bei einem Einkauf von uns Rabatt gewährt bekommt, und eine Art, die keinen Rabatt erhält. Bis auf die Information, inwiefern Rabatt gewährt wird (und in welcher Höhe), sind beide Arten identisch: sie beinhalten Namen und Vornamen des Kunden, seinen Wohnort, den aktuellen Umsatz und die Anzahl der durchgeführten Transaktionen. Im Zusammenhang mit dem Rabatt benötigen wir zusätzlich die Höhe des Rabatts; weiterhin müssen wir bei der Berechnung des Umsatzes natürlich den jeweils gewährten Rabatt mit verrechnen. Wir wollen diese neue Klasse Rabattkunde nennen.
Anhand dieser Betrachtung sehen wir, dass wir eine sehr große Ähnlichkeit zwischen beiden Klassen haben. In solchen Fällen liegt es immer nahe, festzustellen, inwiefern wir eine Vererbungsbeziehung zwischen den Klassen vorliegen haben. Dabei hilft uns die „ist ein“-Regel. Wir können einfach versuchen, unsere beiden Klassen mit den Worten „ist ein“ zu verbinden: Ein Rabattkunde ist ein Kunde. Wenn diese Formulierung Sinn ergibt, können wir uns fast sicher sein, eine Vererbungsbeziehung vorliegen zu haben. Begrifflich ist die Klasse hinter dem „ist ein“ die Basisklasse, die Klasse davor die abgeleitete Klasse.
3
VERERBUNG
81
3.2
VERERBUNG IN C++
Vererbung drücken wir in C++ aus, indem wir bei der Definition der abgeleiteten Klasse angeben, welche Klasse als Basisklasse verwendet werden soll. Dabei kommen erneut die drei Schlüsselworte public, private und protected zum Einsatz, die wir bereits als Zugriffsschutzmarken (siehe Kapitel 2.5) kennen gelernt haben. Welches davon im konkreten Fall verwendet wird, hängt von der Art der Vererbung ab. Am häufigsten kommt public vor; die Literatur gibt nähere Auskünfte über die anderen Vererbungsformen. Wir definieren also unsere Klasse Rabattkunde als abgeleitete Klasse von Kunde. Dazu verwende ich eine separate Headerdatei rabattkunde.h. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
class Rabattkunde : public Kunde { double Rabatt; public: Rabattkunde() : Kunde(), Rabatt(0) {} Rabattkunde(std::string const& Name, std::string const& Vorname, std::string const& Wohnort, double rabatt) : Kunde(Name, Vorname, Wohnort), Rabatt(rabatt) {} void kaufe(double Umsatz); std::ostream& schreibe(std::ostream& out) const; std::istream& lese(std::istream& in); void rabatt(double r); double rabatt() const; };
In Zeile 1 der neuen Klassendefinition sehen wir hinter einem Doppelpunkt und dem Schlüsselwort public, das den Vererbungstyp angibt, den Namen der Basisklasse. Damit wird der Compiler darüber informiert, dass die Klasse Rabattkunde von der Basisklasse Kunde abgeleitet ist, die Klasse Rabattkunde alle Datenmember und Memberfunktionen der Basisklasse
übernimmt, aus Memberfunktionen der Klasse Rabattkunde auf alle von Kunde geerbten Member mit Zugriffsschutz public oder protected zugegriffen werden darf.
Der Datenmember Rabatt ergänzt die geerbten Datenmember.
82
VERERBUNG IN C++
3
Neben den Datenmembern erbt die Klasse Rabattkunde alle Memberfunktionen. Der Autor kann sich nun entscheiden, ob er den Ablauf dieser Memberfunktionen verändern will. Falls dies geplant ist, müssen die Memberfunktionen erneut in der Klassendefinition angegeben werden; sollen die Memberfunktionen nicht verändert werden, so unterbleibt diese Angabe. Im Beispiel wollen wir die Memberfunktion kaufe verändern (die Einrechnung des Rabatts vor der Summierung im Datenmember Umsatz ist erforderlich); ebenso die Memberfunktionen lese und schreibe (die nunmehr den neuen Member Rabatt mit ausgeben bzw. einlesen müssen). Die Memberfunktionen name, vorname, ... bleiben jedoch unverändert erhalten, weswegen sie in der Klassendefinition nicht erneut erwähnt werden. In den Konstruktoren der abgeleiteten Klasse müssen die geerbten Komponenten der Basisklasse initialisiert werden. Dies wird generell durch Aufruf eines geeigneten Basisklassenkonstruktors in der Initialisiererliste des Konstruktors der abgeleiteten Klasse erledigt. Wir sehen dies in beiden inline definierten Konstruktoren. Zusätzlich erhält die neue Klasse die beiden Memberfunktionen mit dem Namen rabatt, die zum Lesen und Schreiben des Datenmembers Rabatt dienen werden. Im nachfolgenden Codebeispiel sehen wir die Implementierung dieser neuen Funktionen, die in einer Quelltextdatei mit dem Namen rabattkunde.cc entsteht: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
3
void Rabattkunde::kaufe(double Umsatz) { Kunde::kaufe(Umsatz * (1 - Rabatt)); } istream& Rabattkunde::lese(istream& in) { Kunde::lese(in); if(in){ in >> Rabatt; } return in; } ostream& Rabattkunde::schreibe(ostream& out) const { out << "Rabatt-"; Kunde::schreibe(out); return out; } void Rabattkunde::rabatt(double r) { Rabatt = (r > 1)?r/100:r; }
VERERBUNG
83
27 double Rabattkunde::rabatt() const 28 { 29 return Rabatt; 30 }
Wenn man die Memberfunktionen der abgeleiteten Klasse implementiert, kann man auf die bereits definierten Memberfunktionen der Basisklasse zurückgreifen. Ein Beispiel hierfür sehen wir in der Memberfunktion Rabattkunde::kaufe. Hier wird die geerbte Memberfunktion kaufe benutzt, wobei der übergebene Einzelumsatz um den Rabatt verringert an diese weiter übergeben wird. Dies verschafft uns den Vorteil, dass Veränderungen am Umsatz-Handling innerhalb der Basisklasse unmittelbare Auswirkungen auch auf unsere abgeleitete Klasse haben. Sollte zum Beispiel in künftigen Versionen der Klasse Kunde ein weiterer Datenmember eingeführt werden, der innerhalb des Codes der Memberfunktion Kunde::kaufe verändert werden muss, so überträgt sich dieses Verhalten automatisch auch auf die Klasse Rabattkunde, denn durch den Aufruf der geerbten Memberfunktion führen wir ja deren Code aus. Die Memberfunktionen lese und schreibe greifen ebenfalls auf die entsprechenden geerbten Funktionalitäten zu; lese ruft die geerbte Memberfunktion zuerst auf, um danach den hinter der Anzahl von Transaktionen vermerkten Rabattsatz einzulesen. Die Memberfunktion schreibe gibt zuerst auf dem ostream out die Zeichenkette "Rabatt" aus, so dass bei der Ausgabe eines Rabattkunden anstatt „Kunde“ nunmehr „Rabatt-Kunde“ ausgegeben wird. Wir können unsere neue Klasse Rabattkunde innerhalb einer neuen main-Funktion benutzen: 1 int main() 2 { 3 Kunde K("Müller", "Helmut", "Musterdorf"); 4 Rabattkunde K1("Meier", "Herbert", "Beispielstadt", 0.1); 5 6 K.kaufe(99.95); 7 K1.kaufe(10); 8 K1.kaufe(20); 9 10 K.schreibe(cout); 11 K1.schreibe(cout); 12 }
Diese unterscheidet sich nur insofern von der main-Funktion in Kapitel 2.4, dass wir die Variable K1 nunmehr als Rabattkunde definieren. Wir vergeben dabei einen Rabattsatz von 10%. Die Ausgabe bei der Ausführung dieses Programms ist folglich: [mme@endeavour Kunde5]: ./kunde Kunde Helmut Müller mit ID 0 aus Musterdorf hatte 1 Transaktion und 99.95 EUR Umsatz. Rabatt-Kunde Herbert Meier mit ID 1 aus Beispielstadt hatte 2 Transaktionen und 27 EUR Umsatz.
84
VERERBUNG IN C++
3
Wie wir sehen, wird die Ausgabe des zweiten Kunden um den Begriff „Rabatt“ ergänzt. Gleichzeitig erkennen wir auch, dass die 10% Rabatt, die wir dem Kunden Meier gewähren, tatsächlich berücksichtigt wurden: Sein Einkauf im Gesamtwert von 30 Euro schlägt nur mit 27 Euro zu Buche.
3.3
POLYMORPHIE
In der main-Funktion in Kapitel 2.7.2 hatten wir den für die Ausgabe von Objekten der Klasse Kunde definierten Operator << verwendet, um die Objekte K und K1 direkt auszugeben. Wir haben bislang für Objekte der Klasse Rabattkunde keinen entsprechenden Operator definiert; was passiert, wenn wir trotzdem in unserer letzten main-Funktion anstelle der Aufrufe der Memberfunktionen schreibe auf K und K2 die Zeile 1
cout << K << K1 << endl;
einfügen würden? Wir erhalten folgende Ausgabe: [mme@endeavour Kunde5]: ./kunde2 Name: Müller,Helmut ID: 0 Umsatz: 99.95 Name: Meier,Herbert ID: 1 Umsatz: 27
Transakt.: 1 Transakt.: 2
Wie wir sehen, funktioniert diese Art der Ausgabe, obwohl wir für Rabattkunde keinerlei Ausgabeoperator definiert haben. Offensichtlich wird der Ausgabeoperator verwendet, der als zweites Argument eine Referenz auf ein Objekt der Klasse Kunde verwendet. Scheinbar ist es möglich, auf Objekte der Klasse Rabattkunde über eine Referenz auf ein Objekt der Klasse Kunde zuzugreifen. Dies ist in der Tat der Fall. Auf Objekte abgeleiteter Klassen kann über Referenzen und Zeiger auf Objekte der Basisklassen zugegriffen werden. Funktioniert dies in jedem Fall? Wir testen dies mit einer dritten Version unserer main-Funktion: 1 int main() 2 { 3 Kunde K("Müller", "Helmut", "Musterdorf"); 4 Rabattkunde K1("Meier", "Herbert", "Beispielstadt", 0.1); 5 6 K.kaufe(99.95); 7 8 Kunde& RK1 = K1; 9 10 RK1.kaufe(10); 11 RK1.kaufe(20); 12 13 cout << K << K1 << endl; 14 15 }
3
VERERBUNG
85
Wie wir sehen, weisen wir einer Referenz auf ein Objekt der Klasse Kunde RK1 unser Rabattkunden-Objekt K1 zu. Wir verwenden dann RK1, um die Umsätze des Kunden zu vermerken. Technisch gesehen machen wir nichts anderes als beim Aufruf des Ausgabeoperators im vorangegangenen Beispiel: wir betrachten ein Objekt der Klasse Rabattkunde als ein Objekt der Klasse Kunde. Wie wir anhand der Ausgabe beim Start des Programms sehen, [mme@endeavour Kunde5]: ./kunde3 Name: Müller,Helmut ID: 0 Umsatz: 99.95 Name: Meier,Herbert ID: 1 Umsatz: 30
Transakt.: 1 Transakt.: 2
funktioniert dies jedoch nicht wie gewünscht: Zwar werden die EUR 30 Umsatz für den Kunden vermerkt, jedoch wird der Rabatt nicht berechnet. Offensichtlich wurde beim Aufruf der Funktion kaufe nicht die Version von Rabattkunde, sondern die Version von Kunde verwendet. Es gibt jedoch eine Möglichkeit, dafür zu sorgen, dass in unserem Szenario trotz des Zugriffs über eine Referenz auf ein Objekt der Basisklasse die Funktion der abgeleiteten Klasse verwendet wird. Der Begriff, mit dem diese Funktionalität verbunden ist, lautet Polymorphie, was aus dem Griechischen stammt und so viel wie „Vielgestaltigkeit“ bedeutet. Zur Unterstützung polymorphischer Zugriffe verwendet C++ das Schlüsselwort virtual. Dieses muss in der Basisklasse bei der Deklaration aller Memberfunktionen
angegeben werden, deren Aufruf nötigenfalls (d.h. wenn über eine Referenz auf ein Basisklassenobjekt tatsächlich auf ein Objekt der abgeleiteten Klasse zugegriffen wird) in einen Aufruf der Memberfunktion der abgeleiteten Klasse umgeleitet wird. Abgesehen von dieser Ergänzung der Memberfunktionsdeklarationen in der Basisklasse sind keine weiteren Codeänderungen notwendig. Wir wollen also unsere Basisklasse polymorphisch gestalten: 1 2 3 4 5 6 7 8 9 10 11 12 13
// kunde.h: class Kunde { // Datenmember public: // Konstruktoren, Operatoren virtual void kaufe(double Umsatz); virtual std::ostream& schreibe(std::ostream& out) const; virtual std::istream& lese(std::istream& in); // weitere Memberfunktionen };
Wir setzen den virtual-Status nur für die Funktionen, die wir in der Klasse Rabattkunde überschreiben.
86
POLYMORPHIE
3
Nachdem wir neu compiliert haben, führen wir unser Programm erneut aus: [mme@endeavour Kunde5]: ./kunde3 Name: Müller,Helmut ID: 0 Umsatz: 99.95 Name: Meier,Herbert ID: 1 Umsatz: 27
Transakt.: 1 Transakt.: 2
Wie wir sehen, war unsere Änderung erfolgreich; bei der Berechnung des Umsatzes wurde nunmehr der Rabatt in Höhe von 10% berücksichtigt.
3.4
ABSTRAKTE BASISKLASSEN
Im Zusammenhang mit Vererbung können wir in Basisklassen auf die Angabe einer Implementierung virtueller Memberfunktionen verzichten. Wenn wir das tun, erklären wir die entsprechende Basisklasse zur abstrakten Basisklasse. Die Konsequenz ist, dass man keine Objekte von abstrakten Basisklassen erzeugen kann. Sie können also nur dazu dienen, eine Schnittstelle vorzugeben, die abgeleitete Klassen dann implementieren müssen. Stellen wir uns vor, wir wollen außer unseren Kunden auch die Mitarbeiter des Unternehmens in unserem Verwaltungssystem behandeln. Mitarbeiter und Kunden unterscheiden sich in vielen Punkten – während wir zum Beispiel für Kunden Umsatz und Transaktionen verwalten, müssen wir für Mitarbeiter das Gehalt und die Urlaubstage berücksichtigen. Dennoch gibt es eine Reihe von Operationen, die wir in unserem Verwaltungssystem mit beiden Arten von Datensätzen durchführen können; darunter zum Beispiel das Einlesen und Anzeigen der Datensätze. Wir wollen daher eine abstrakte Basisklasse Verwaltungsdaten entwickeln, die diese beiden Operationen festlegt: 1 2 3 4 5 6 7 8
// verwaltungsdaten.h: class Verwaltungsdaten { public: virtual std::istream& lese(std::istream&) = 0; virtual std::ostream& schreibe(std::ostream&) const = 0; };
Die Memberfunktionen lese und schreibe werden durch die Angabe von = 0 als pure virtual-Memberfunktionen deklariert. Durch das Vorhandensein mindestens einer solchen puren virtual-Memberfunktion wird die Klasse Verwaltungsdaten zur abstrakten Basisklasse, von der keine Objekte erzeugt werden können. Diese Klasse können wir nunmehr als Basis unserer bereits existierenden Klasse Kunde verwenden: 1 class Kunde : public Verwaltungsdaten 2 { 3 // wie bisher 4 };
3
VERERBUNG
87
An der Implementierung der Klasse müssen wir dabei nichts ändern, denn wir implementieren ja bereits die Memberfunktionen lese und schreibe. Wir können Verwaltungsdaten gleichzeitig als Basisklasse für die Entwicklung einer Klasse Mitarbeiter verwenden: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
// mitarbeiter.h: class Mitarbeiter : public Verwaltungsdaten { public: Mitarbeiter(std::string const& name, double gehalt); std::ostream& schreibe(std::ostream&) const; std::istream& lese(std::istream&); private: std::string Name; double Gehalt; }; // mitarbeiter.cc: Mitarbeiter::Mitarbeiter(string const& name, double gehalt) : Name(name), Gehalt(gehalt) { } istream& Mitarbeiter::lese(istream& in) { if(in) in >> Name >> Gehalt; return in; } ostream& Mitarbeiter::schreibe(ostream& os) const { os << "Mitarbeiter " << Name << " hat ein Gehalt von " << Gehalt << " EUR." << endl; return os; }
Die folgende main-Funktion testet den entsprechenden Code: 1 2 3 4 5 6
88
int main() { Kunde K("Müller", "Helmut", "Musterdorf"); Rabattkunde K1("Meier", "Herbert", "Beispielstadt", 0.1); Mitarbeiter M1("Schulze", 1500);
ABSTRAKTE BASISKLASSEN
3
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 }
K.kaufe(99.95); Kunde& RK1 = K1; RK1.kaufe(10); RK1.kaufe(20); Verwaltungsdaten& VD1 = K; Verwaltungsdaten& VD2 = K1; Verwaltungsdaten& VD3 = M1; VD1.schreibe(cout); VD2.schreibe(cout); VD3.schreibe(cout);
Die Besonderheit hier ist die Verwendung von Verwaltungsdaten: Obwohl wir keine Objekte der Klasse Verwaltungsdaten anlegen können, ist es möglich, VerwaltungsdatenReferenzen einzusetzen, und mit diesen sowohl auf Objekte der abgeleiteten Klassen Kunde und Rabattkunde, sowie Mitarbeiter zuzugreifen. Wie wir an der Ausgabe nach dem Starten des Programms sehen, wird stets die für das konkrete Objekt angemessene Memberfunktion schreibe aufgerufen: [mme@endeavour Kunde5]: ./kunde4 Kunde Helmut Müller mit ID 0 aus Musterdorf hatte 1 Transaktion und 99.95 EUR Umsatz. RabattKunde Herbert Meier mit ID 1 aus Beispielstadt hatte 2 Transaktionen und 27 EUR Umsatz. Mitarbeiter Schulze hat ein Gehalt von 1500 EUR.
Sinnvoll ist diese Art der Benutzung unserer Objekte dann, wenn wir Objekte aus einer Klassenhierarchie nur im Sinne ihrer gemeinsamen Funktionalität benutzen wollen; zum Beispiel, um sie in einem Container für einen gemeinsamen Datentyp zu speichern (siehe Kapitel 7.1), oder um sie an eine Funktion zu übergeben, die lediglich die allen Klassen der Hierarchie gemeinsame Schnittstelle benutzt und daher Referenzen auf Basisklassenobjekte übergeben bekommt. Solche Funktionen sind im Umgang mit Klassenhierarchien sehr nützlich, denn sie müssen nicht neu entwickelt werden, wenn neue Klassen in die Klassenhierarchie Einzug finden.
3.5
protected-ZUGRIFFSSCHUTZ
Neben den Zugriffsschutzmarken public und private gibt es noch die dritte Form protected. Die so markierten Datenmember oder Memberfunktionen können nur aus Memberfunktionen der Klasse selbst und aus Memberfunktionen abgeleiteter Klassen heraus benutzt werden. Dies ist nützlich, wenn man Memberfunktionen oder Datenmember schaffen will, die in verschiedenen Ebenen der Klassenhierarchie verschiedentlich behandelt werden sollen. 3
VERERBUNG
89
In unserem Beispiel stellen wir fest, dass sowohl Mitarbeiter als auch Kunden den Member Name haben. Wir können daher – in Analogie zu den Memberfunktionen schreibe und lese – diesen Datenmember in die Basisklasse Verwaltungsdaten übernehmen. Der Member muss allerdings die Zugriffsschutzmarke protected erhalten, denn in den Implementierungen von lese und schreibe in den beiden abgeleiteten Klassen Kunde und Mitarbeiter wollen wir direkt auf diesen Member zugreifen. Neben dem Datenmember Name können wir auch die Memberfunktionen name in die Basisklasse übernehmen. Da wir bereits wissen, dass diese Memberfunktionen den Datenmember Name setzen bzw. zurückliefern sollen, können wir auch deren Implementierung bereits angeben. Dadurch ändert sich nichts an der Eigenschaft der Klasse Verwaltungsdaten, eine abstrakte Basisklasse zu sein, denn immer noch bleiben lese und schreibe pure virtual-Memberfunktionen. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
90
// verwaltungsdaten.h: class Verwaltungsdaten { public: Verwaltungsdaten() {} Verwaltungsdaten(std::string const& name) : Name(name) {} virtual std::istream& lese(std::istream&) = 0; virtual std::ostream& schreibe(std::ostream&) const = 0; protected: std::string Name; std::string name() const { return Name; } void name(std::string const& n) { Name = n; } }; // kunde.cc: Kunde::Kunde(string const& name, string const& vorname, string const& wohnort) :Verwaltungsdaten(name), ID(max_ID++), Vorname(vorname), Wohnort(wohnort), Umsatz(0), Transaktionen(0) { } Kunde::Kunde(Kunde const& K) :Verwaltungsdaten(K), ID(K.ID), Vorname(K.Vorname), Wohnort(K.Wohnort), Umsatz(K.Umsatz), Transaktionen(K.Transaktionen) { } Kunde& Kunde::operator=(Kunde const& K) { Verwaltungsdaten::operator=(K); if(this != &K) { ID = K.ID;
PROTECTED-ZUGRIFFSSCHUTZ
3
36 37 38 39 40 41 42 43 44 45 46 47 48 49
Vorname = K.Vorname; Wohnort = K.Wohnort; Umsatz = K.Umsatz; Transaktionen = K.Transaktionen; } return *this; } // mitarbeiter.cc: Mitarbeiter::Mitarbeiter(string const& name, double gehalt) : Verwaltungsdaten(name), Gehalt(gehalt) { }
Wie wir sehen sind einige weitere Änderungen erforderlich, um den Datenmember Name in die Basisklasse zu übernehmen: Wir müssen in Verwaltungsdaten einen Konstruktor schaffen, der den neuen Datenmember Name initialisiert. Jeder Konstruktor der abgeleiteten Klassen kann
nämlich nur die Datenmember der eigenenen Klasse initialisieren, sowie einen Basisklassenkonstruktor aufrufen, so dass die Datenmember der Basisklasse ebenfalls initialisiert werden. Wir müssen in Verwaltungsdaten einen Defaultkonstruktor angeben, denn sobald
wir einen Konstruktor für eine Klasse entwickeln, wird kein Defaultkonstruktor mehr synthetisiert. Da wir in den abgeleiteten Klassen gegebenenfalls Defaultkonstruktoren verwenden wollen (hier in Kunde), ist es sinnvoll, auch in der Basisklasse einen Defaultkonstruktor anzugeben. Unser Verwaltungsdaten-Defaultkonstruktor tut nichts, da der Defaultkonstruktor von Name gegebenenfalls automatisch aufgerufen wird. Wir ändern den Konstruktor von Kunde so, dass er den Konstruktor der Basisklasse
verwendet. Wir ändern den Copykonstruktor von Kunde, damit der Copykonstruktor der Basisklasse verwendet wird, um den dortigen Datenmember Name zu kopieren. Wir müssen dafür keinen Copykonstruktor in Verwaltungsdaten definieren, denn
ein Copykonstruktor wird stets synthetisiert, sofern keiner angegeben wird. Wir ändern den Zuweisungsoperator von Kunde, um auch hier den Zuweisungs-
operator der Basisklasse einzusetzen. Wir müssen keinen Zuweisungsoperator für Verwaltungsdaten angeben, da dieser bei Bedarf synthetisiert wird. Wir ändern den Konstruktor der Klasse Mitarbeiter, so dass er den Basisklassen-
konstruktor verwendet. In allen Konstruktoren und Operatoren, die bislang den Datenmember Name direkt verwendeten, können wir die Zuweisungen oder Initialisierungen von Name
entfernen, denn wir rufen ja nunmehr den jeweiligen Konstruktor/Operator der Basisklasse auf, der sich dann um den entsprechenden Operator kümmert. 3
VERERBUNG
91
Mit Hilfe unserer main-Funktion aus Kapitel 3.4 können wir die Funktionsfähigkeit dieser Änderungen testen. Wir sollten dieselben Ausgaben erhalten wie in Kapitel 3.4.
3.6
VIRTUELLE DESTRUKTOREN
Damit beim Löschen eines Objektes einer abgeleiteten Klasse über einen Basisklassenzeiger die tatsächlichen Datenmember des Objekts freigegeben werden, muss die Basisklasse einen als virtual deklarierten Destruktor haben. Dieser kann – sofern nicht aus anderen Gründen ein Destruktor erforderlich ist – leer sein: 1 class Verwaltungsdaten 2 { 3 public: 4 // ... 5 virtual ~Verwaltungsdaten() {} 6 7 // ... 8 };
3.7
ZUSAMMENFASSUNG
Vererbung ist ein wichtiges Konzept der objektorientierten Programmierung, das den Aufbau von Klassenhierarchien erlaubt. Klassenhierarchien dienen der Abbildung von hierarchischen Strukturen, wie sie in den verschiedensten fachspezifischen Anwendungsbereichen für Programmierung auftreten. In diesem Kapitel wurden die Sprachmittel vorgestellt, die C++ für die Benutzung von Vererbung zur Verfügung stellt.
3.8
ÜBUNGEN
1. Implementieren, compilieren und testen Sie die Beispiel in diesem Kapitel! 2. Instrumentieren Sie die Klassen Mitarbeiter, Verwaltungsdaten, Kunde und Rabattkunde, so dass die Destruktoren und Konstruktoren jeweils Meldungen nach cout
ausgeben, wenn sie gestartet werden. Verfolgen Sie damit den Ablauf der verschiedenen Beispielcodes. Entwickeln Sie eigene Tests, bei denen Sie zum Beispiel anhand der Ausgaben nachvollziehen können, welche Konstruktoren/Destruktoren bei der Übergabe von Objekten an Funktionen per Referenz bzw. durch Wertübergabe ausgeführt werden!
92
VIRTUELLE DESTRUKTOREN
3
3. Ergänzen Sie die Klassenhierarchie durch eine Klasse Lieferant. Diese soll einen
Zähler für die Einkäufe von diesem Lieferanten, sowie die Summe der an den Lieferanten beglichenen Rechnungen enthalten. Die Schnittstelle der Klasse besteht aus den üblichen Zugriffsfunktionen für die internen Daten, sowie aus der Funktion kaufe, die eine Transaktion mit einem bestimmten Rechnungsbetrag vermerkt. • Prüfen Sie, ob es Abstraktionsmöglichkeiten zwischen Lieferant und Kunde gibt. • Wir haben bei Lieferanten eine Kundennummer. Schaffen Sie eine Möglichkeit, diese Kundennummer zu verwalten. 4. Erzeugen Sie eine abstrakte Basisklasse GeoObjekt, die als Basis für alle geometri-
schen Objekte aus Aufgabe §3 dienen kann. Welche Memberfunktionen und Datenmember werden in die Basisklasse übernommen?
3.9
LITERATUREMPFEHLUNGEN
STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Im Anschluss an die grundlegende Beschreibung des Klassenkonzeptes und die Ausführungen zu Operatoren geht Stroustrup in Kapitel 12 auf abgeleitete Klassen ein. Man findet dort auch Erläuterungen zur Mehrfachvererbung, die aus Platzgründen hier nicht betrachtet wurde. Es werden mehrere Beispiele zu Klassenhierarchien und ihrer programmiererischen Umsetzung betrachtet.
KOENIG/MOO: „INTENSIVKURS C++“ Ausgehend von den verschiedenen Komplexbeispielen des Buches werden in Kapitel 13 Vererbung und dynamische Bindung gemeinsam betrachtet. Dabei wird ebenfalls das Handle-Konzept eingeführt, das dann in den nachfolgenden Kapiteln vertieft wird. Kapitel 15 enthält ebenfalls ein Beispiel, bei dem Vererbung Anwendung findet.
MEYERS: „EFFEKTIV C++ PROGRAMMIEREN“ In den Abschnitten 35 bis 44 werden zahlreiche Ratschläge zur Fehlervermeidung bei Vererbungsbeziehungen in C++ gegeben. Insbesondere wird auf das Vererben von Schnittstellen und das Vererben von Implementationen eingegangen, und einige Modellierungshinweise für verschiedene klassische objektorientierte Nutzungsbeziehungen gegeben.
MEYERS: „MEHR EFFEKTIV C++ PROGRAMMIEREN“ Hier wird an verschiedenen Stellen Vererbung eingesetzt, um bestimmte Effekte zu erzielen, darüberhinaus wird jedoch auch in Abschnitt 5.7 auf einige besondere Punkte in Bezug auf virtuelle Funktionen eingegangen.
3
VERERBUNG
93
4 TEMPLATES In diesem Kapitel soll es um generische Programmierung in C++ gehen. Wer dieses Kapitel durchgearbeitet hat, sollte folgende Fähigkeiten erlangt haben:
Î Nutzung von Templates zur generischen Programmierung, Î Definition von Templatefunktionen und -klassen, Î Erkennen der Einsatzvoraussetzungen für Templates. 4.1
GENERISCHE PROGRAMMIERUNG
Von vielen Programmierern wird eine wichtige Eigenschaft von C++ als großer Vorteil angesehen: das Typkonzept und die damit verbundenen Typprüfungen von Operationen oder Funktionsaufrufen. Gelegentlich kann es jedoch erforderlich sein, einen Algorithmus oder ein Konzept umzusetzen, die für Daten unterschiedlicher Typen verwendet werden können. Beispiele hierfür sind numerische Algorithmen, die für alle numerischen Datentypen (int, double, ...) eingesetzt werden können, aber auch Containerklassen, die als Alternative zu Arrays zahlreiche Werte eines bestimmten Typs speichern können. Die Operationen auf Containerklassen – Lese- und Schreibzugriffe auf die Werte im Container, Anhängen oder Löschen von Werten – unterscheiden sich dabei nicht in Abhängigkeit vom Typ der Werte. Daher genügt es, erst bei der Benutzung eines Containers zu wissen, Werte welchen Typs darin gespeichert werden sollen – der Aufbau des Containers ist generisch. C++ bietet zur Unterstützung der generischen Programmierung das Konzept des Templates an. Wir wollen dies anhand von Funktionen und Klassen kennen lernen.
4.2
TEMPLATEFUNKTIONEN
Stellen wir uns vor, wir wollen eine Funktion schreiben, die uns die Summe der Werte eines Feldes berechnet, von dem wir einen Zeiger auf den Anfangspunkt und die Anzahl der Elemente übergeben. Es soll sich zunächst um ganzzahlige Werte handeln: 1 int summe(int* start, int anzahl) 2 { 3 int s = 0;
4
TEMPLATES
95
4 5 6 7 }
for(int i = 0; i != anzahl; ++i) s += start[i]; return s;
Stellen wir uns die gleiche Funktion für double-Werte vor: 1 double summe(double* start, int anzahl) 2 { 3 double s; 4 for(int i = 0; i != anzahl; ++i) 5 s += start[i]; 6 return s; 7 }
Wir erkennen, dass die Implementierung der Funktion abgesehen von der Verwendung des Datentyps double anstelle von int keinerlei andere Unterschiede aufweist. Dies führt unmittelbar zu dem Wunsch, die Funktion ohne Erwähnung des Datentyps zu formulieren. Dafür kann in C++ das Templatekonzept verwendet werden: 1 2 3 4 5 6 7 8
template T summe(T* start, int anzahl) { T s = 0; for(int i = 0; i != anzahl; ++i) s += start[i]; return s; }
Das Schlüsselwort template leitet die Definition einer Templatefunktion (oder einer Templateklasse) ein, dahinter wird in Winkelklammern angegeben, welcher Templateparameter verwendet werden soll. Dabei wird das Schlüsselwort typename eingesetzt, um dem Compiler mitzuteilen, dass es sich bei dem Parameter T um einen Datentyp handelt. Wenn die Funktion summe nunmehr im Code verwendet wird, instanziiert der Compiler bei der Übersetzung des Codes die Funktion für den beim Aufruf anstelle von T eingesetzten Typ: 1 int main() 2 { 3 double df[] = {1.5, 2.5, 3.5, 4}; 4 cout << "Summe: " << summe(df, 4) << endl; 5 }
Die Ausführung zeigt das Ergebnis: [mme@endeavour Source]: ./templatefun Summe: 11.5
96
TEMPLATEFUNKTIONEN
4
Um nachzuweisen, dass die Templatefunktionalität tatsächlich mit mehreren verschiedenen Datentypen verwendet werden kann, wollen wir unsere main-Funktion um ein ganzzahliges Feld ergänzen: 1 int main() 2 { 3 double df[] = {1.5, 2.5, 3.5, 4}; 4 cout << "Summe (double): " << summe(df, 4) << endl; 5 int gf[] = {1, 2, 3, 4, 5, 6}; 6 cout << "Summe (int) : " << summe(gf, 6) << endl; 7 }
Wie wir bei der Ausführung sehen, wurde die Funktion summe tatsächlich zweimal mit verschiedenen Datentypen benutzt: [mme@endeavour Source]: ./templatefun Summe (double): 11.5 Summe (int) : 21
4.3
TEMPLATEKLASSEN
Analog zu Algorithmen, die bei ihrer Entwicklung typunabhängig gestaltet werden können, gibt es auch Konzepte, die ohne spezielle Rücksichtnahme auf Datentypen codiert werden können. Solche Konzepte können durch Templateklassen ausgedrückt werden. Wir wollen im Folgenden eine Templateklasse Array entwickeln, die zwei Templateparameter erhalten wird: den Datentyp der zu speichernden Daten, sowie die Anzahl der Elemente des Arrays: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
4
template class Array { public: Array(T const& t = T()) { for(size_t i = 0; i != E; ++i) daten[i] = t; } ostream& print(ostream& os) const { for(size_t i = 0; i != E; ++i) os << daten[i] << " "; os << endl; } size_t size() const { return E; }
TEMPLATES
97
22 23 void set(size_t e, T t) 24 { 25 daten[e] = t; 26 } 27 28 T get(size_t e) const 29 { 30 return daten[e]; 31 } 32 33 private: 34 T daten[E]; 35 };
Bei der Deklaration des Dimensionsparameters wurde ein neuer Datentyp verwendet, der in C++ für Größenangaben üblich ist: size_t. Dieser Datentyp ist im Standardheader definiert und muss natürlich mittels using-Deklaration als Name aus der Standardbibliothek bekannt gemacht werden. Da ein Feld wie in Kapitel 1.7 erläutert per default mit undefiniertem Inhalt belegt ist, haben wir einen Defaultkonstruktor definiert, der alle Elemente des Feldes mit einem Defaultwert initialisiert. Wird kein Defaultwert übergeben, so verwenden wir den Defaultkonstruktor des zu speichernden Datentyps. Sollte der Typ T einem Basisdatentyp entsprechen, so führt dies zu einer Nullinitialisierung. Wir haben weiterhin eine Memberfunktion print vorgesehen, die den Inhalt des Arrays in einer Zeile ausgibt. Darüberhinaus können wir die Memberfunktionen set und get verwenden, um uns die Elemente des Arrays zurückliefern zu lassen oder diese zu ändern. Durch die Angabe des Schlüsselwortes const an der Definition der Memberfunktionen print und get haben wir dafür gesorgt, dass beide auch auf const-Array-Objekten aufgerufen werden können. Die Memberfunktion size kann verwendet werden, um die Größe des Arrays zurückzuliefern. Ganz offensichtlich ist auch diese Memberfunktion const. Wir wollen eine main-Funktion angeben, um die Klasse zu testen: 1 int main() 2 { 3 Array intA; 4 5 intA.print(cout); 6 cout<<endl; 7 8 intA.set(0,1); 9 intA.set(1,2); 10 intA.set(2,3); 11 12 intA.print(cout); 13 cout << endl; 14 }
98
TEMPLATEKLASSEN
4
Diese main-Funktion definiert ein Array für int-Daten mit 3 Elementen unter dem Namen intA. Das neu definierte Array wird zunächst ausgegeben, danach werden die einzelnen Elemente mit einem Wert versehen und das Array erneut ausgegeben. Wir sehen die folgende Anzeige bei der Ausführung des Programms: [mme@endeavour Source]: ./array 0 0 0 1 2 3
4.3.1
INDEX-OPERATIONEN
In der oben verwendeten main-Funktion zum Testen des Programms verwenden wir unsere Memberfunktion set, um auf die einzelnen Elemente des Arrays zuzugreifen. Intern verwenden wir die Index-Operatoren, um an die Elemente des Feldes daten zu gelangen. Wir wollen nun dafür sorgen, dass auch Benutzer unserer Array-Klasse Index-Operatoren nutzen können. Da wir ein Array nachbilden wollen, sollten wir den Index-Operator [] definieren: 1 2 3 4 5 6 7 8 9 10 11 12 13
template class Array { public: // ... T operator[](size_t e) const { return daten[e]; } // ... };
Dieser Operator gibt eine Kopie des an der Stelle [e] in dem Array befindlichen Elementes zurück. Da eine Modifikation des entsprechenden Elementes durch die angefertigte Kopie nicht möglich ist, können wir die Operation als const definieren. Wir haben damit eine Alternative zur Memberfunktion get geschaffen. Können wir parallel dazu auch noch eine Alternative zur Memberfunktion set herstellen? Da wir Schreibzugriff auf das entsprechende Element des Arrays haben wollen, können wir den Operator nicht als const deklarieren. Um den Schreibzugriff zu realisieren, müssen wir außerdem den Rückgabeparameter als Referenz gestalten: 1 2 3 4 5 6
4
template class Array { public: // ...
TEMPLATES
99
7 8 9 10 11 12 13 };
T& operator[](size_t e) { return daten[e]; } // ...
Wenn wir die Funktionsköpfe der beiden Operatorversionen betrachten, so erkennen wir, dass der einzige Unterschied in der Angabe von const liegt. Wir können uns merken, dass eine Überladung von Memberfunktionen nicht nur anhand ihrer unterschiedlichen Parameterliste, sondern auch anhand der Definition als constoder nicht-const-Memberfunktion möglich ist. Als letzte Verfeinerung unserer Index-Operatoren können wir dafür sorgen, dass der const-Index-Operator anstelle einer Kopie eine const-Referenz auf das Element zurückgibt. Auf diese Weise vermeiden wir das Kopieren des Feldinhaltes, während wir gleichzeitig sicherstellen, dass kein Schreibzugriff erfolgen kann: 1 2 3 4 5 6 7 8 9 10 11
template class Array { public: // ... T const& operator[](size_t e) const { return daten[e]; } // ... };
Wir können die Index-Operatoren nunmehr testen, indem wir die Aufrufe von set in der main-Funktion ersetzen: 1 int main() 2 { 3 Array intA; 4 5 intA.print(cout); 6 cout<<endl; 7 8 intA[0] = 1; 9 intA[1] = 2; 10 intA[2] = 3; 11 12 intA.print(cout); 13 cout << endl; 14 }
Die Ausgabe entspricht hierbei exakt der vorigen Version unseres Programms.
100
TEMPLATEKLASSEN
4
4.3.2
ARITHMETISCHE OPERATIONEN
Abgesehen von der print-Memberfunktion und der automatischen Nullinitialisierung der Array-Elemente gibt es bislang keinen Unterschied in der Benutzung von Objekten unserer Array-Klasse zur Benutzung von einfachen C++-Feldern. Wir wollen nunmehr arithmetische Operationen definieren, um die Benutzung der Array-Objekte im Kontext mathematischer Anwendungen benutzen zu können. Wir wollen dies anhand eines Beispiels diskutieren: der Multiplikation eines skalaren Wertes mit dem Array. Diese skalare Multiplikation wollen wir auf zwei Arten implementieren: einerseits so, dass als Ergebnis der Operation ein neues Objekt entsteht, das die veränderten Elemente enthält; andererseits so, dass das Array-Objekt direkt verändert wird. Die erste Version wird die operator*-Funktion verwenden, die zweite die Funktion operator*=. Die erste Frage, die man sich bei der Definition von arithmetischen Operationen stellen sollte, ist, ob die Implementierung als eigenständige Operation oder als Memberfunktion erfolgen sollte. Eine Regel, die wir in Kapitel 2.7 diskutiert haben, lautete, dass eine Operation immer dann eine Memberfunktion sein sollte, wenn sie den Datenmember des Objekts verändert. Die erste Version der skalaren Multiplikation soll das Objekt nicht verändern, daher hilft uns bei ihrer Implementierung diese Regel nicht weiter. Eine weitere Überlegung kann dahin gehend geführt werden, wie Nutzer der Klasse die Multiplikation formulieren werden. Nehmen wir folgenden Code als Beispiel: 1 2 3 4 5
// ... Array intA; // ... Array intB = intA * 3; Array intC = -10 * intB;
Da der skalare Wert auf beiden Seiten des Multiplikationsoperators auftreten kann, genügt die Angabe einer Memberfunktion nicht. Eine Memberfunktion kann nur die Fälle abdecken, bei denen das Klassenobjekt auf der linken Seite des Operators auftritt. Folglich sollten wir die erste Version der Skalarmultiplikation als eigenständige Operation definieren. Die zweite Version der Skalarmultiplikation verändert das Objekt, auf der sie aufgerufen wird, und muss daher als Memberfunktion implementiert werden. Wir wollen uns daher zunächst um diese Version kümmern: 1 2 3 4 5
4
template class Array { public: // ...
TEMPLATES
101
6 7 8 9 10 11 12 13 14 };
Array operator*=(T t) { for(size_t i = 0; i != E; ++i){ daten[i] *= t; } return *this; } // ...
Um eine Verkettung von Operationen zu erlauben, liefert die Memberfunktion eine Kopie des Objektes nach erfolgter Multiplikation zurück. Wir können nunmehr unsere beiden eigenständigen Multiplikationsoperatoren auf dieser Memberfunktion aufbauen: 1 2 3 4 5 6 7 8 9 10 11 12
template Array operator*(T const& t, Array const& TE) { Array A=TE; return A *= t; } template Array operator*(Array const& TE, T const& t) { return operator*(t,TE); }
Wenn wir den oben angegebenen Testcode in unsere main-Funktion einfügen und die entstehenden Arrays intB und intC ausgeben, erhalten wir folgende Ausgabe zum Beweis der Funktionsfähigkeit: [mme@endeavour Source]: ./array 0 0 0 1 2 3 3 6 9 -30 -60 -90
4.3.3
WELCHE DATENTYPEN KÖNNEN WIR IN UNSERER SPEICHERN?
Array-KLASSE
Wir haben in Kapitel 4.3 angekündigt, dass wir die Array-Klasse ohne Rücksicht auf den Typ der zu speichernden Daten gestalten und damit zum Ausdruck gebracht, dass prinzipiell beliebige Datentypen in der Array-Klasse gespeichert werden können. Ist dies tatsächlich der Fall? Als einzige, sehr allgemeingültige, Antwort auf diese Frage müssen wir feststellen, dass derzeit nur Elemente von Datentypen gespeichert werden können, für die wir entweder einen Wert bei der Konstruktion des Array-Objekts festlegen, oder die über 102
TEMPLATEKLASSEN
4
einen Defaultkonstruktor verfügen. Da jedoch fast alle Datentypen der Standardbibliothek einen Defaultkonstruktor besitzen und auch viele selbstdefinierte Datentypen mit einem solchen ausgestattet werden, ist dies eine recht geringfügige Einschränkung. Alle weiteren Einschränkungen hängen von der Benutzung der Array-Klasse ab. Soll zum Beispiel die von uns im vorangegangenen Abschnitt implementierte Skalarmultiplikation verwendet werden, so muss für den Elementdatentyp die Multiplikation definiert sein. Nehmen wir folgende Verwendung an: 1 2 3 4
// ... Array<string, 5> stringA; // ... stringA *= string("Test"); // Problem: string * string
so werden wir den entsprechenden Code nicht compilieren können, denn für Objekte des Datentyps string ist der Multiplikationsoperator nicht definiert und – wie man sich leicht vorstellen kann – auch nicht sinnvoll zu entwickeln. Diese Beschränkungen erwachsen nicht direkt aus der Definition unserer ArrayKlasse, sondern vielmehr aus ihrer Verwendung durch den Nutzer. Solange der Nutzer den Multiplikationsoperator nicht benutzt, kann er ebenso gut auch string oder andere Datentypen, die die Multiplikation nicht unterstützen, als Elementdatentypen benutzen.
4.3.4
PROBLEME UNSERER
Array-KLASSE
Bevor wir die Probleme vorstellen wollen, die unserer Klasse Array anhaften, sollten wir erwähnen, dass sie in vielen Punkten besser ist, als die in C++ eingebauten und in Kapitel 1.7 vorgestellten Felder: Objekte der Array-Klasse können unmittelbar durch Aufruf der Memberfunktion print ausgegeben werden. Wir können mit Hilfe der Memberfunktion size die Anzahl der Elemente eines Array-Objektes ermitteln. Array-Objekte verhalten sich wie Werte, d.h. wir können das Verhalten dieser
Objekte bei der Anwendung von Operatoren mit Hilfe von Member- und eigenständigen Funktionen implementieren. Dies ist mit einfachen Feldern niemals möglich. Durch den Defaultkonstruktor wird eine Nullinitialisierung der Elemente eines Array-Objektes durchgeführt. Elemente von C++-Feldern sind dagegen uninitia-
lisiert. Da unsere Implementierung intern auf dem Feldkonzept von C++ aufbaut, gibt es jedoch noch eine Reihe von Schwachstellen, die von diesem Feldkonzept übernommen wurden, jedoch mit anderen Sprachmitteln von C++ behoben werden können:
4
TEMPLATES
103
Jedes Array-Objekt hat nach seiner Erstellung eine feste Größe, es kann nicht be-
liebig wachsen, wenn weitere Elemente eingestellt werden sollen. In Kapitel 7.3.2 werden wir dafür eine erste Abhilfe kennen lernen; in Kapitel 11.2.1 eine weitere. Ein Zugriff hinter die Grenzen des internen C++-Feldes führt zu undefiniertem
Verhalten des Programms. Mit derartigen Problemen werden wir uns im nächsten Kapitel beschäftigen. Unsere Implementierung des Konstruktors setzt voraus, dass der als Elementtyp
verwendete Datentyp einen Defaultkonstruktor definiert. Wie wir bereits festgestellt haben, muss dies nicht der Fall sein. Eine Abhilfe dafür werden wir in Kapitel 11.2.1 diskutieren. Arrays mit dem gleichen Inhaltstyp aber verschiedenen Größen haben unter-
schiedliche Datentypen. Dies liegt daran, dass beide Templateparameter in den endgültigen Datentyp übergehen. Dies ist ein ernst zu nehmendes Problem, wenn wir ein Array-Objekt an eine Funktion übergeben wollen. Wir werden mit unserer ersten Verbesserung in Kapitel 7.3.2 eine Abhilfe schaffen, indem wir dort die Größe des Arrays als Templateparameter komplett eliminieren.
4.4
ZUSAMMENFASSUNG
Neben der objektorientierten Programmierung, die in den vergangenen beiden Kapiteln mit den Konzepten der Klasse und der Vererbung disktutiert wurde, unterstützt C++ die generische Programmierung mit Hilfe des hier erläuterten Sprachmittels „Template“. Die Verwendung von Templates dient der typunabhängigen Formulierung von Algorithmen und Klassen. Anhand eines Beispiels wurde diese Art der Programmierung im Komplex dargestellt. Templates spielen eine große Rolle innerhalb der C++-Standardbibliothek, wie in nachfolgenden Kapiteln zu sehen sein wird.
4.5
ÜBUNGEN
1. Implementieren, compilieren und testen Sie die Beispiele aus diesem Kapitel! 2. Entwickeln Sie eine Templateklasse Matrix, die drei Templateparameter akzeptiert: den zu speichernden Datentyp, sowie die x- und y-Dimension.
a. Betrachten Sie sowohl ein zweidimensionales C++-Feld, als auch ein Array, Y> als mögliche interne Datenspeicher. b. Entwickeln Sie die Zugriffsfunktionen set und get. 104
ÜBUNGEN
4
c. Welche Schwierigkeiten gibt es bei der Entwicklung der Indexoperatoren zum Zugriff? d. Entwickeln Sie den skalaren Multiplikationsoperator analog zur Klasse Array. e. Entwickeln Sie Operatoren zur Multiplikation von Matrizen gleicher Dimensionen! f. Entwickeln Sie Operatoren zur Multiplikation von Matrizen und Arrays. Legen Sie entsprechend fest, welche Dimension der Matrix mit der Länge des Arrays übereinstimmen muss.
4.6
LITERATUREMPFEHLUNGEN
STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ In Kapitel 13 leitet Stroustrup zur Betrachtung von Templates über. Er geht dabei beispielhaft von einer templatisierten String-Implementierung aus, und stellt im Weiteren Untersuchungen von Templateklassen und Templatefunktionen an, wobei er insbesondere auf Fragen der Typgleichheit und -ähnlichkeit, Überladung von Funktionstemplates, sowie die Spezialisierung von Templates eingeht, die es erlaubt, konkrete Implementierungen für bestimmte Templateparameter vorzugeben. Weiterhin betrachtet er Zusammenhänge zwischen Vererbung und Templates.
KOENIG/MOO: „INTENSIVKURS C++“ An zwei Stellen, in Kapitel 8 und Kapitel 11, gehen Koenig und Moo auf Templates ein: Kapitel 8 beschäftigt sich mit Templatefunktionen, Kapitel 11 mit Templateklassen. Letztere werden am Beispiel einer Klasse Vec erläutert, die einen sequenziellen Datencontainer ähnlich dem von der Standardbibliothek bereitgestellten (und hier in Kapitel 7 betrachteten) vector darstellt. Die Betrachtung von Templatefunktionen bezieht sich auf Algorithmen der Standardbibliothek (die hier in Kapitel 10 diskutiert werden) und führt auch das Konzept des Iterators (hier in Kapitel 7 erstmals erwähnt) ein.
4
TEMPLATES
105
5 EXCEPTIONS Die Behandlung von Fehlern, die beim Ablauf eines Programmes auftreten können, ist ebenso wichtig wie die Codierung des eigentlichen Programmablaufes. Daher wollen wir uns in diesem Kapitel diesem wichtigen Thema widmen. Wer dieses Kapitel durchgearbeitet hat, sollte mit folgenden Punkten vertraut sein:
Î Klassische Fehlerbehandlung, Î Funktionsweise des C++-Exception-Konzepts, Î Unterschiede zwischen klassischer Fehlerbehandlung und Exceptions, Î Einsatz des Exception Handlings in eigenen Projekten. 5.1
KLASSISCHE BEHANDLUNG VON FEHLERN
Für die Behandlung von Fehlern in höheren Programmiersprachen gibt es grundsätzlich zwei Möglichkeiten, die grundsätzlich ohne spezielle Unterstützung von Compiler und Entwicklungsumgebung auskommen. Die erste Möglichkeit wird häufig von Systemaufrufen der Unix-Systemprogrammierungsschnittstelle eingesetzt und sieht die Rückgabe eines speziellen, nicht als legale Rückgabe einer Funktion möglichen Rückgabewertes (sog. Bottomwert) vor. Ein Beispiel dafür ist die Funktion open zum Öffnen einer Datei: OPEN(2)
System calls
OPEN(2)
NAME open - open a file or device SYNOPSIS #include <sys/types.h> #include <sys/stat.h> #include int open(const char *pathname, int flags); ...
5
EXCEPTIONS
107
RETURN VALUE open returns the new file descriptor, or -1 if an error occurred (in which case, errno is set appropriately). ...
Wie in diesem (gekürzten und leicht veränderten) Auszug aus dem Online-Manual zu sehen ist, gibt open im Erfolgsfalle einen Dateideskriptor mit dem Datentyp int zurück. Da nur positive Dateideskriptoren (inklusive dem Wert 0) definiert sind, kann der Wert -1 als Bottom-Wert verwendet werden, der einen Fehler anzeigt. Wie dem Text ebenfalls zu entnehmen ist, wurde die Art des Fehlers darüber hinaus in einer globalen Variable errno übermittelt, so dass der Programmierer auf verschiedene Fehlerzustände reagieren konnte. Im Falle der Rückgabe von Zeigern wurde als Bottomwert häufig ein Nullzeiger definiert, wie wir im (ebenfalls gekürzten und leicht geänderten) Online-Manual-Eintrag zur C-Bibliotheksfunktion fopen, die prinzipiell dem gleichen Zweck wie open dient, jedoch im Unterschied zu dieser einen Zeiger auf eine FILE-Datenstruktur zurückliefert, die analog zum Dateideskriptor zur Identifikation der konkreten geöffneten Datei für Lese- und Schreibvorgänge verwendet werden konnte, lesen: FOPEN(3)
C Programmer’s Manual
FOPEN(3)
NAME fopen - stream open function SYNOPSIS #include <stdio.h> FILE *fopen(const char *path, const char *mode); ... RETURN VALUE Upon successful completion fopen returns a FILE pointer. Otherwise, NULL is returned and the global variable errno is set to indicate the error. ...
Erneut wird auf die globale Variable errno verwiesen, die im Fehlerfalle einen exakteren Fehlerwert zur Verfügung stellt. Leider ist es nicht immer möglich, einen Bottom-Wert zu finden. Die C-Bibliotheksfunktion strtol konvertiert eine ihr übergebene nullterminierte Zeichenkette in einen Wert des Datentyps long. Der konvertierte Wert wird von der Funktion zurückgeliefert. Wie soll die Funktion nun reagieren, falls die übergebene Zeichenkette keinerlei konvertierbaren Zahlenwert enthielt? Es gibt praktisch keinen Rückgabewert, mit dem man ausdrücken könnte, dass ein Fehler aufgetreten ist, denn der gesamte Wertebereich des Datentyps long kann ja als vollkommen legaler Rückgabewert gelten. Die Entwickler dieser Bibliotheksfunktion sehen dafür einen Übergabeparame-
108
KLASSISCHE BEHANDLUNG VON FEHLERN
5
ter vor, in dem ein Zeiger auf das erste nicht erfolgreich in einen long konvertierte Zeichen der Zeichenkette zurückgeliefert wird: STRTOL(3)
C Programmer’s Manual
STRTOL(3)
NAME strtol - convert a string to a long integer. SYNOPSIS #include <stdlib.h> long int strtol(const char *nptr, char **endptr, int base); ... DESCRIPTION ... If endptr is not NULL, strtol() stores the address of the first invalid character in *endptr. If there were no digits at all, strtol() stores the original value of nptr in *endptr (and returns 0). In particular, if *nptr is not ‘\0’ but **endptr is ‘\0’ on return, the entire string is valid. ...
Falls der Programmierer nicht an entsprechenden Fehlern interessiert ist, kann er den Übergabeparameter endptr beim Aufruf als NULL setzen, anderenfalls gibt es anschließend grundsätzlich drei Zustände: 1. **endptr ist ein ASCII-Null-Zeichen (also der char-Wert 0) und *nptr (das erste Zeichen der übergebenne Zeichenkette) nicht 1, dann wurde die gesamte Zeichenkette erfolgreich konvertiert. Der Rückgabewert ist dann der konvertierte Wert. 2. **endptr ist kein ASCII-Null-Zeichen, dann wurde lediglich ein Teil der in nptr übergebenen Zeichenkette konvertiert und *endptr zeigt auf das erste Zeichen, das nicht konvertiert wurde. Der Rückgabewert ist der konvertierte Wert bis zu dieser Stelle in der Zeichenkette. 3. *endptr ist äquivalent zu nptr, dann wurde kein zu konvertierender Wert gefunden; in dem Fall ist der Rückgabewert 0. Man kann sich leicht vorstellen, wie komplex die Fehlerprüfung in diesem Falle im Code aussieht. Sowohl die Rückgabe eines speziellen Bottomwertes, als auch die Rückgabe eines Fehlerwertes in einem (Zeiger- oder Referenz-) Übergabeparameter haben einen 1
5
D.h. die ursprünglich übergebene Zeichenkette enthielt mindestens ein Zeichen.
EXCEPTIONS
109
wichtigen Nachteil: Vergisst der Programmierer die Prüfung des Rückgabewertes oder Fehlercodes, dann wird im Programm unter Umständen mit einem fehlerhaften Wert weitergearbeitet. Möglicherweise ist dieser fehlerhafte Wert in der weiteren Verarbeitung nicht sofort zu erkennen, und es treten Fehler auf, die nicht sofort mit der ursprünglichen Fehlerquelle in Zusammenhang gebracht werden. Das Debugging wird dadurch erheblich erschwert. Da die Entwicklung von entsprechendem Fehlerprüfungscode unbequem ist und durch die Sprache nicht erzwungen werden konnte, waren und sind entsprechende Programmierfehler in solchem Code sehr häufig.
5.2
AUSNAHMEBEHANDLUNG
Aufbauend auf diesen Erkenntnissen wurde daher in C++ eine neue Art der Fehlerbehandlung eingeführt, die als Exception Handling oder Ausnahmebehandlung bekannt geworden ist. Ein Programmierer kann dabei einen Fehler signalisieren, indem er auf spezielle Weise ein Exception-Objekt erzeugt, wobei der normale Programmfluss im Moment der Erzeugung des Exception-Objekts ausgesetzt wird und ein Rücksprung auf die nächsthöhere Abarbeitungsebene erfolgt. Dort kann die Exception dann in einem speziellen Code-Abschnitt, dem catch-Block, behandelt werden. Ist zur Behandlung der Exception kein catch-Block vorhanden, so wird der Programmfluss erneut ausgesetzt und wiederum auf die nächsthöhere Abarbeitungsebene zurückgekehrt; bis entweder ein catch-Block die Exception behandelt, oder das Programm zwangsweise beendet wird. Dieses Vorgehen hat mehrere Vorteile gegenüber den klassischen Methoden der Fehlerbehandlung: Vergisst der Programmierer die Fehlerbehandlung, so wird das Programm been-
det. Das Weiterarbeiten mit fehlerhaften Werten wird damit verhindert. Der Programmcode zur Behandlung von Fehlern wird optisch von dem Pro-
grammcode der normalen Abarbeitung getrennt. Dadurch werden Programme übersichtlicher und Zusammenhänge in beiden Arten von Code leichter durchschaubar. Da die Art des Fehlers im Datentyp des Exception-Objekts codiert werden kann, und für jeden Datentyp ein eigener catch-Block angegeben werden kann, können
die Behandlungen verschiedener Fehler optisch gut voneinander unterschieden werden. Da das Exception-Objekt bei der Auswertung des Fehlers im catch-Block zur Ver-
fügung steht, können komplexe Informationen über den Fehler übermittelt werden; da beliebige Datentypen (inklusive selbst definierter Klassen) als ExceptionTypen verwendet werden können ist Art und Anzahl solcher Informationen praktisch unbegrenzt und lediglich dem Programmierer überlassen.
110
AUSNAHMEBEHANDLUNG
5
Wir wollen uns das Exception-Handling am Beispiel unserer Array-Klasse anschauen. Diese enthält zwei Memberfunktionen, set und get, die einen Index in das Array übergeben bekommen. Wir wollen Prüfungen einbauen, die verhindern sollen, dass ein Programmierer versehentlich auf einen zu hohen (oder zu niedrigen) ArrayIndex zugreift. Dabei wollen wir das Exception-Handling verwenden; denn im Falle von get könnten wir aufgrund der Tatsache, dass wir zum Zeitpunkt der Programmierung der Array-Klasse den gespeicherten Datentyp nicht kennen, keinen universell geeigneten Bottom-Wert definieren. Im Falle von set könnten wir zwar einen Rückgabewert definieren, der einen Wert für einen Fehler beim Setzen des Elementes anzeigt, jedoch würden wir damit wieder dem Programmierer die Last der Fehlerprüfung aufbürden, was – wie oben dargestellt – natürlich dazu führen kann, dass der Programmierer die Prüfung vergisst und fälschlicherweise annimmt, dass der Wert erfolgreich im Array gespeichert wurde. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
#include <stdexcept>
// neu
using std::out_of_range;
// neu
template class Array { public: // ... void set(size_t e, T t) { if(e < 0 || e >= E) throw out_of_range("Arrayzugriff mit falschem Index!"); daten[e] = t; } T get(size_t e) const { if(e < 0 || e >= E) throw out_of_range("Arrayzugriff mit falschem Index!"); return daten[e]; } // ... };
Bei der Abarbeitung des Codes wird im if geprüft, ob sich der Index e innerhalb des Bereiches [0,E) befindet. Ist dies nicht der Fall, so wird die Abarbeitung der Funktion beendet und ein Exception-Objekt der Klasse out_of_range erzeugt, das die Fehlermeldung "Arrayzugriff mit falschem Index!" enthält. Die C++-Standardbibliothek definiert in ihrem Standardheader <stdexcept> eine Reihe von Exception-Klassen, die wir für gewisse Standardfehlerfälle verwenden können. Alle diese Exception-Klassen definieren einen Konstruktor, dem man eine 5
EXCEPTIONS
111
Zeichenkette mitgeben kann, um eine Fehlermeldung im Exception-Objekt abzuspeichern. Dazu gehört auch die Exception-Klasse out_of_range, die unserem Fehlerfall, also der Bereichsüberschreitung beim Arrayzugriff, entspricht. Das Exception-Objekt wird mit Hilfe des Schlüsselwortes throw erzeugt. Da es sich um eine normale C++-Klasse handelt, entspricht die Erzeugung eines solchen Exception-Objektes der Erzeugung eines Objektes einer Klasse durch Konstruktion. Wenn wir nunmehr in einer entsprechenden main-Funktion absichtlich einen solchen Zugriffsfehler einbauen ... 1 int main() 2 { 3 Array intA; 4 5 intA.set(4,10); 6 7 intA.print(cout); 8 }
... so passiert Folgendes: [mme@endeavour Source]: ./array_exc Abgebrochen
Wie wir sehen, wird die Abarbeitung noch vor der Ausgabe des Arrays beendet. Die Ursache dafür liegt darin, dass die Exception nicht behandelt wird: Nachdem die Memberfunktion set der Array-Klasse durch die throw-Anweisung beendet wird, befindet sich die Abarbeitung wieder in der main-Funktion. Da auch hier kein Code zur Exception-Behandlung aufgefunden wird, wird die main-Funktion ebenfalls beendet, was den Abbruch des Programms zur Folge hat. Die Meldung „Abgebrochen“, die in der Testumgebung erscheint, kommt von der Ausführungsumgebung des Programms, in meinem Fall einer Linux-Shell, die den „unsanften“ Abbruch des Programms feststellt und dem Nutzer dies in einfachen Worten mitteilt2. Andere Abarbeitungsumgebungen reagieren unter Umständen mit dem Öffnen eines Fehlermeldungs-Fensters oder ähnlichen Strategien. Wir können die Behandlung des Fehlers jedoch im Programm erledigen. Dafür müssen wir unsere main-Funktion um einen catch-Block erweitern: 1 2 3 4 5 6 7 8 9 2
int main() { try{ Array intA; intA.set(4,10); intA.print(cout); } Da das Programm zum Zeitpunkt der Feststellung des Abbruchs durch die Shell nicht mehr existiert, kann die Shell auch keine weiteren Angaben zum Abbruch machen, selbst wenn sie wollte.
112
AUSNAHMEBEHANDLUNG
5
10 11 12 13 14 }
catch(out_of_range &E){ cerr << E.what() << endl; return 1; }
Indem wir zunächst einen try-Block angeben, signalisieren wir dem Compiler, dass wir innerhalb der Anweisungen dieses Blockes das Auftreten von Exceptions vermuten. Unmittelbar auf den try-Block folgt dann der catch-Block zur Behandlung der Exception vom Typ out_of_range. Wir können uns dabei das Original-ExceptionObjekt als Referenz übergeben lassen; die Syntax entspricht dabei einer Parameterliste bei der Funktionsparameterübergabe. Die Standardexceptions verfügen alle über eine Memberfunktion what, die den im Exception-Objekt gespeicherten Text zurückliefert. Wir geben diesen Text auf dem Standardfehlerkanal cerr aus. Darüberhinaus geben wir als Rückkehrcode des Programms 1 zurück, um der Ausführungsumgebung auf klassische Weise einen Fehler zu signalisieren. Wenn wir diesen Code testen, erhalten wir folgende Ausgabe: mme@endeavour Source]: ./array_exc Arrayzugriff mit falschem Index!
Unsere Fehlermeldung wird also angezeigt. Da der try-Block in unserem Fall den gesamten Inhalt der main-Funktion umfasst, können wir diese etwas einfacher schreiben: 1 2 3 4 5 6 7 8 9 10 11 12
int main() try{ Array intA; intA.set(4,10); intA.print(cout); } catch(out_of_range &E){ cerr << E.what() << endl; return 1; }
Wir können jedoch den try-Block auch weiter einschränken. In diesem Fall wird der Code hinter dem entsprechenden catch-Block weiter ausgeführt:
5
EXCEPTIONS
113
1 int main() 2 { 3 Array intA; 4 5 try{ 6 intA.set(4,10); 7 } 8 catch(out_of_range &E){ 9 cerr << E.what() << endl; 10 } 11 12 intA.print(cout); 13 }
In diesem Beispiel wurde auf den Abbruch der main-Funktion mittels return 1 innerhalb des catch-Blockes verzichtet, um die weitere Abarbeitung des Programmes nach der Exception-Behandlung zu dokumentieren. Die Ausgabe bei der Ausführung des Programms sieht wie folgt aus: [mme@endeavour Source]: ./array_exc Arrayzugriff mit falschem Index! 0 0 0
Zunächst wird die Fehlermeldung ausgegeben, wonach die Abarbeitung normal weiterläuft und die print-Memberfunktion den aktuellen Zustand des Objektes intA darstellt. Da beide Kanäle, Standardausgabe- und Standardfehlerausgabekanal, ihre Ausgaben hier auf die gleiche Konsole schreiben, sehen wir beide Ausgaben hintereinander3. Obwohl das Exception-Handling von C++ viele weitere interessante Details umfasst, soll die Betrachtung im Rahmen dieses Buches hier enden. Für weitere Informationen, insbesondere Beispiele für die Definition eigener Exceptions und Exception-Hierarchien sei auf die Literatur verwiesen.
5.3
ZUSAMMENFASSUNG
Die in diesem Kapitel diskutierten Exceptions dienen der Trennung von normalem und Fehlerfall-Steuerfluss. Durch die Weitergabe eines Objektes entlang der Funktionsaufrufhierarchie des Programms wird eine umfangreiche Dokumentation des aufgetretenen Fehlers ermöglicht. Durch den automatischen Programmabbruch bei Nichtbehandlung von Exceptions wird sichergestellt, dass versehentliches Vergessen der Fehlerbehandlung in Programmen nicht dazu führt, dass das Programm mit fehlerhaften Daten weiterläuft oder in einem fehlerhaften Status Aktionen auslöst, die unerwünscht sind.
3
Die Unix-Shells bieten natürlich Möglichkeiten, beide Ausgabekanäle zu separieren und zum Beispiel in verschiedene Dateien umzuleiten.
114
AUSNAHMEBEHANDLUNG
5
5.4
ÜBUNGEN
1. Implementieren, compilieren und testen Sie die Beispiele in diesem Kapitel! 2. Passen Sie die Klasse Matrix aus der vorangegangenen Übung an, so dass die Memberfunktion get eine Exception wirft, wenn auf ein nicht vorhandenes Matrix-
element zugegriffen werden soll. Testen Sie die Rückgabe der Exception.
5.5
LITERATUREMPFEHLUNGEN
STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ In Kapitel 14 widmet sich Stroustrup dem Exceptionhandling. Er beschreibt auch die Herstellung von Exceptionhierarchien sehr ausführlich und geht dabei auf die Anordnung der catch-Blöcke zur Behandlung der Exceptions und die Reihenfolge ihrer Auswertung ein. Außerdem verweist er auf Probleme im Zusammenhang mit der Verwaltung von Ressourcen, deren Freigabe beim Erzeugen bzw. Fangen von Exceptions häufig vergessen wird.
KOENIG/MOO: „INTENSIVKURS C++“ Hier gibt es keinen dedizierten Abschnitt, der sich mit dem Exceptionhandling auseinandersetzt, im Rahmen der Komplexbeispiele wird jedoch zumindest auf den praktischen Einsatz von Exceptions eingegangen. Hervorzuheben ist hier vor allem Kapitel 4.
MEYERS: „MEHR EFFEKTIV C++ PROGRAMMIEREN“ In Kapitel 3 diskutiert Meyers verschiedene Effekte im Zusammenhang mit Exceptionhandling, die sich vorrangig auf den Umgang mit Ressourcen konzentrieren. Außerdem wird auf Exceptionspezifikationen und die Kosten des Exceptionhandlings eingegangen.
5
EXCEPTIONS
115
6 IOSTREAMS Wir haben uns in den vorangegangenen Kapiteln häufig mit Ein- und Ausgabeoperationen beschäftigt, ohne uns um die Hintergründe innerhalb der C++Standardbibliothek zu kümmern. Wir werden uns daher im Rahmen dieses Kapitels mit den Einrichtungen zur Ein- und Ausgabe von Daten beschäftigen. Wer dieses Kapitel durchgearbeitet hat, sollte folgende Kenntnisse besitzen:
Î Konzepte von istream und ostream als generelle Dateneingabe- und -ausgabestreams, Î Benutzung von ifstream und ofstream zur Dateiein- und -ausgabe, Î Einsatz von istrstream und ostrstream zur Ein- und Ausgabe in Speicherbereiche.
6.1
EIN- UND AUSGABESTREAMS
Wie wir bereits in vorangegangenen Kapiteln gesehen haben, bietet die C++-Standardbibliothek zwei Datentypen für die allgemeine Ein- und Ausgabe von Daten an: istream (input stream) und ostream (output stream). Die grundlegenden Definitionen des iostream-Teils der Standardbibliothek befinden sich im Standardheader . Darüber hinaus existieren in der Standardbibliothek 4 Objekte, die für jedes C++Programm von Beginn an initialisiert sind und für die Ein- und Ausgabe verwendet werden können: cin:
Eingabedatenstrom (Typ: istream), der mit dem Standardeingabekanal (Dateideskriptor 0) verbunden ist.
cout:
Ausgabedatenstrom (Typ: ostream), der mit dem Standardausgabekanal (Dateideskriptor 1) verbunden ist.
cerr:
Ausgabedatenstrom (Typ: ostream), der mit dem Standardfehlerausgabekanal (Dateideskriptor 2) verbunden ist und seine Ausgaben unmittelbar auf dem Ausgabekanal ausgibt.
clog:
Ausgabedatenstrom (Typ: ostream), der mit dem Standardfehlerausgabekanal (Dateideskriptor 2) verbunden ist und dessen Ausgaben intern gepuffert werden.
6
IOSTREAMS
117
Ausgaben erfolgen mittels des Ausgabeoperators (<<), wobei die einzelnen Ausgabedaten unmittelbar hintereinander verkettet werden können. Ausgaben werden in der Regel von der Standardbibliothek gepuffert, d.h. in einen Zwischenspeicher geschrieben und erst beim Eintreten einer von mehreren Bedingungen an das tatsächliche Ausgabegerät weitergegeben. Diese Bedingungen sind: Der für die Daten vorgesehene Zwischenspeicher (Puffer) ist komplett gefüllt. Ein Zeilenendezeichen wird in den Puffer geschrieben. (Zum Beispiel mit Hilfe des Manipulators endl.) Ein spezieller Manipulator wird benutzt, der für das unmittelbare Leeren des Puffers sorgt (flush). Die Verbindung zum Ausgabegerät wird getrennt. Alle im entsprechenden Puffer
angesammelten Daten werden zuvor an das Ausgabegerät weitergegeben. Der Ausgabeoperator ist für alle Basisdatentypen, sowie für alle Datentypen der Standardbibliothek, die sinnvoll ausgegeben werden können (string, complex, ...), vorgesehen. Der Programmierer kann für eigene Datentypen ebenfalls Ausgabeoperatoren angeben, wie in Kapitel 2.7.2 dargestellt. Eingaben werden häufig mit Hilfe des Eingabeoperators (>>) vorgenommen. Wie in Kapitel 2.7.3 gezeigt, ist das Ergebnis einer solchen Eingabeoperation eine Referenz auf das Streamobjekt, aus dem die Daten gelesen wurden. Aufgrund dessen ist eine Verkettung von Eingabeoperationen möglich. Das Eingabestreamobjekt kann direkt in einer Bedingungsprüfung verwendet werden. Wenn die Bedingungsprüfung true ergibt, können von dem entsprechenden Objekt weitere Daten eingelesen werden; die zuvor durchgeführte Einleseoperation (sofern eine stattgefunden hat) ist gut gegangen. Es gibt prinzipiell drei Gründe für ein false bei der Verwendung eines Eingabestreamobjekts in einer Bedingungsprüfung: Das Betriebssystem signalisiert ein Problem mit der Hardware, von der die Daten
eingelesen werden sollten. Die vorangegangene Einleseoperation ist fehlgeschlagen, da die Daten im Ein-
gabedatenstrom nicht in den gewünschten Datentyp konvertierbar waren. Das Ende der Datei ist erreicht.
Mit Hilfe der Memberfunktion clear kann der negative Zustand eines Eingabestreamobjektes zurückgesetzt werden. Dies ist insbesondere dann nützlich, wenn das Problem im Einlesen von Daten eines nichtkompatiblen Datentyps bestand, denn oftmals will man in diesem Fall die Einleseoperation vom selben Stream an anderer Stelle im Programm fortsetzen. Das folgende Programm nutzt die Auswertung des Zustandes von cin, um festzustellen, ob das Einlesen in die Variable des entsprechenden Typs erfolgreich war. Der Reihe nach werden int, double und string eingelesen. Damit der jeweils nächste Einleseversuch nicht von vornherein fehlschlägt, wird nach jedem missglückten Versuch der Fehlerzustand von cin mit Hilfe der Memberfunktion clear zurückgesetzt:
118
EIN- UND AUSGABESTREAMS
6
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
#include #include <string> using using using using
std::cout; std::cin; std::endl; std::string;
int main() { while(cin){ int v; if(cin >> v){ cout << "Ein Integer wurde gelesen: [" << v << "]" << endl; }else{ cin.clear(); double d; if(cin >> d){ cout << "Ein double wurde gelesen: [" << d << "]" << endl; }else{ cin.clear(); string s; if(cin >> s) { cout << "Ein string wurde gelesen: [" << s << "]" << endl; } } } } }
Dieses Programm hat jedoch einen entscheidenden Nachteil, der sich jedoch nur in der interaktiven Benutzung deutlich zeigt: Wenn das Einlesen eines Integers fehlschlägt, weil wir das Dateiendezeichen (STRG-D auf Unix-Systemen, STRG-Z unter Windows) eingegeben haben, so wird durch die Anwendung der Memberfunktion clear dieser Fehlerzustand beseitigt und das Programm erwartet nun beim Einlesen einen double-Wert. Wir signalisieren erneut das Dateiendezeichen, woraufhin der gleiche Vorgang sich wiederholt und das Programm nun beim Einlesen eines strings erneut anhält. Erst nach dem dritten Tastendruck von STRG-D oder STRG-Z beendet sich das Programm, denn auf das misslungene Einlesen eines strings folgt kein erneuter Aufruf von clear, so dass der Fehlerzustand in cin erhalten bleibt und die Bedingungsprüfung im while fehlschlägt. Wenn wir von einer Datei einlesen, wird automatisch bei jedem Lesevorgang erneut das Dateiende gelesen, weshalb wir in diesem Fall das Problem nicht bemerken. Dies führt jedoch unmittelbar zu der Frage, inwiefern Fehlerzustände von istreamObjekten differenziert werden können. Dazu sind in der Basisklasse aller Streamklassen, basic_ios, vier Memberfunktionen definiert, die jeweils einen bool-Wert
6
IOSTREAMS
119
zurückliefern und uns damit Auskunft über den Zustand des konkreten StreamObjektes liefern: good():
Das Stream-Objekt ist in gutem Zustand; die nächste Operation auf dem Stream-Objekt wird gutgehen.
eof():
Das Ende der Eingabe (z.B. Dateiendezeichen) wurde erkannt.
fail():
Die nächste Operation auf dem Stream-Objekt wird fehlschlagen.
bad():
Das Stream-Objekt befindet sich in einem allgemeinen Fehlerzustand.
Wir können unser Programm mit Hilfe der Memberfunktion eof auch interaktiv besser benutzbar machen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
#include #include <string> using using using using
std::cout; std::cin; std::endl; std::string;
int main() { while(cin){ int v; if(cin >> v){ cout << "Ein Integer wurde gelesen: [" << v << "]" << endl; }else{ if(!cin.eof()) // neu cin.clear(); double d; if(cin >> d){ cout << "Ein double wurde gelesen: [" << d << "]" << endl; }else{ if(!cin.eof()) // neu cin.clear(); string s; if(cin >> s) { cout << "Ein string wurde gelesen: [" << s << "]" << endl; } } } } }
Durch die Prüfung auf eof vor jedem Rücksetzen des Fehlerzustandes verhindern wir das Löschen genau dann, wenn eine der drei Einleseoperationen das Dateiendezeichen erkannt hat. Alle nachfolgenden Einleseoperationen schlagen dadurch unmittelbar fehl, so dass wir direkt zur Abfrage des cin-Zustandes im while gelangen. Das 120
EIN- UND AUSGABESTREAMS
6
Programm endet dann, sobald wir in der interaktiven Benutzung das Dateiende zum ersten Mal signalisieren.
6.1.1
MANIPULATOREN
Eine Reihe von Operationen mit iostreams können mit Hilfe von Manipulatoren erledigt werden. Insbesondere handelt es sich dabei um Formatierungsaufgaben. Allgemein gesprochen ist ein Manipulator ein Objekt, das innerhalb einer Ein- oder Ausgabesequenz geschrieben wird, und die Art und Weise der Ein- oder Ausgabe beeinflusst. Die Manipulatoren sind (bis auf endl) im Standardheader definiert. Die wichtigsten Manipulatoren fasst folgende Tabelle zusammen: endl
Ausgabe eines Zeilenendezeichens; als einziger Manipulator in definiert.
showbase
Bei der Ausgabe eines Zahlenwertes wird vor dem Zahlenwert die Basis ausgegeben (0x für hexadezimale, 0 für oktale Werte).
noshowbase
Nimmt showbase zurück.
skipws
Whitespace-Zeichen werden beim Einlesen von Werten ignoriert.
noskipws
Whitespace-Zeichen werden beim Einlesen von Werten mitgelesen.
dec
Zahlenwerte werden dezimal ausgegeben.
hex
Zahlenwerte werden hexadezimal ausgegeben.
oct
Zahlenwerte werden oktal ausgegeben.
setw(n)
Die Feldbreite der nächsten Ausgabe beträgt n Zeichen.
setfill(c)
Der char c wird als Füllzeichen für die nächste Ausgabe verwendet.
6.1.2
ZEICHENWEISE EIN- UND AUSGABEN
Für das zeichenweise Arbeiten mit Ein- und Ausgabestreams stehen abgesehen von den Ein- und Ausgabeoperatoren für den Datentyp char auch zwei Memberfunktionen zur Verfügung. Die Memberfunktion istream& istream::get(char& c) liefert das nächste im entsprechenden Stream-Objekt verfügbare Zeichen im Referenzparameter c zurück und hat das Stream-Objekt selbst als Rückgabeparameter. Die Memberfunktion ostream& ostream::put(char c) schreibt das als Parameter übergebene Zeichen c als nächstes Zeichen in das entsprechende Stream-Objekt und liefert das Stream-Objekt als Rückgabeparameter.
6
IOSTREAMS
121
Mit Hilfe beider Funktionen können wir das auf Unix-Systemen verfügbare Kommandozeilenprogramm cat nachbilden, das Daten zeichenweise von der Standardeingabe liest und auf der Standardausgabe ausgibt: 1 2 3 4 5 6 7 8 9 10 11
#include using std::cin; using std::cout; int main() { char c; while(cin.get(c)) cout.put(c); }
Ein Beispiel zeigt die Verwendung dieses Programms: [mme@endeavour Source]: ./cat < cat.cc #include using std::cin; using std::cout; int main() { char c; while(cin.get(c)) cout.put(c); }
Wie wir sehen, entspricht die Ausgabe exakt der Ausgabe des mit dem System ausgelieferten Programms1: [mme@endeavour Source]: cat < cat.cc #include using std::cin; using std::cout; int main() { char c; while(cin.get(c)) cout.put(c); } 1
Der Unterschied in der Kommandozeile ist das Fehlen des ./ vor dem aufzurufenden Programmnamen cat. Die fehlenden Zeichen sorgen im ersten Aufruf dafür, dass nicht das im Standard-Suchpfad verfügbare Systemprogramm cat, sondern die im aktuellen Verzeichnis (.) vorhandene eigene Version von cat verwendet wird.
122
EIN- UND AUSGABESTREAMS
6
6.2
DATEIEIN- UND -AUSGABEN
Die in jedem C++-Programm verfügbaren Streams cin, cout, cerr und clog sind mit den systemseitig zur Verfügung gestellten Ein- und Ausgabekanälen verbunden. C++ bietet jedoch auch die Möglichkeit an, Dateien zur Ein- und Ausgabe direkt zu öffnen. Von istream und ostream wurden daher zwei spezielle Klassen abgeleitet, die alle Memberfunktionen erben, und die Arbeit mit Dateien ermöglichen: ifstream und ofstream. Gleichzeitig wurde von der Klasse iostream, die ihrerseits die Operationen von istream und ostream vereint, die Klasse fstream abgeleitet, die sowohl für die Dateiein- als auch -ausgabe dienen kann. Alle drei Klassen sind im Standardheader vereinbart.
6.2.1
DATEIEINGABEN
Eine Eingabedatei wird durch die Erzeugung eines ifstream-Objektes geöffnet. Als Argument des Konstruktors wird dabei der Dateiname als nullterminierter C-String erwartet. Der Öffnungszustand der Datei kann anschließend anhand des Zustandes des Objektes ausgewertet werden. Wir wollen unsere eigene Version des Unix-Programmes cat erweitern, indem wir Kommandozeilenargumente unterstützen. Diese Kommandozeilenargumente werden als Dateinamen interpretiert, die Dateien nacheinander geöffnet und ihr Inhalt auf der Standardausgabe ausgegeben. Sollte kein Kommandozeilenargument angegeben worden sein, soll die ursprüngliche Funktionalität erhalten bleiben: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
6
#include #include using using using using using using using
std::cin; std::cout; std::cerr; std::endl; std::ifstream; std::istream; std::ostream;
istream& cat(istream& in, ostream& out) { char c; while(in.get(c)) out.put(c); return in; }
IOSTREAMS
123
21 int main(int argc, char **argv) 22 { 23 if(argc == 1){ 24 cat(cin,cout); 25 } 26 else{ 27 for(int i = 1; i != argc; ++i){ 28 ifstream datei(argv[i]); 29 if(datei){ 30 cat(datei, cout); 31 } 32 else 33 cerr << "Datei [" << argv[i] 34 <<"] kann nicht geöffnet werden." << endl; 35 } 36 } 37 }
Die eigentliche Ausgabe der Zeichen des Eingabestreams auf dem Ausgabestream wird dabei von der Funktion cat erledigt. Wir verwenden diese einerseits, um die Zeichen des Eingabestreams cin auf den Ausgabestream cout umzuleiten, sofern wir keine Kommandozeilenparameter vorfinden2; wir verwenden die gleiche Funktion, um nach dem erfolgreichen Öffnen der durch das entsprechende Kommandozeilenargument angegebenen Datei deren Inhalt an cout weiterzugeben. Wir können die gleiche Funktion für beide Zwecke benutzen, da istream eine Basisklasse für ifstream ist; die Variable datei wird innerhalb der Funktion cat folglich als allgemeiner istream betrachtet. Wie wir erkennen können, gibt es keine prinzipiellen Unterschiede in der Arbeit mit ifstream und istream. Durch die Vererbungsbeziehung unterstützen beide die gleichen Manipulatoren; besitzen die gleichen Memberfunktionen und Eingabeoperatoren. Der einzige Unterschied liegt in der Möglichkeit, ifstream-Objekte mit Hilfe eines Konstruktors zu erzeugen.
6.2.2
DATEIAUSGABEN
Eine Ausgabedatei wird durch die Erzeugung eines ofstream-Objektes unter Angabe eines Dateinamens als nullterminierten C-String erzeugt. Neben dem Dateinamen können Flags angegeben werden, die in der Klasse ios_base (Standardheader: ) definiert sind:
2
ios_base::app:
Öffnen zum Anhängen („append“),
ios_base::ate:
Offnen und zum Dateiende springen („at end“),
ios_base::trunc:
Öffnen und Datei auf Länge 0 kürzen.
Der Argumentzähler argc ist immer mindestens 1, da stets der Aufrufname des Programms als argv[0] übergeben wird.
124
DATEIEIN- UND -AUSGABEN
6
Werden keine Flags angegeben, so wird die Datei automatisch auf Länge 0 gekürzt; das Öffnen einer existierenden Datei zum Schreiben überschreibt also automatisch den vorherigen Inhalt der Datei. Wir wollen das Unix-Kommando tee nachbilden. Dieses liest Daten von der Standardeingabe und schreibt diese parallel in Dateien, deren Namen als Kommandozeilenparameter angegeben wurden, und auf die Standardausgabe: TEE(1)
User Commands
TEE(1)
NAME tee - read from standard input and write to standard output and files SYNOPSIS tee [OPTION]... [FILE]... DESCRIPTION Copy standard input to each FILE, and also to standard output.
Wir wollen die Angelegenheit etwas vereinfachen: die in der Synopsis angegebenen Kommandozeilenoptionen werden entfallen, und wir wollen uns auf eine Ausgabedatei neben der Standardausgabe beschränken. Außerdem legen wir uns darauf fest, dass die Ausgabedateien überschrieben werden sollen, sofern sie bereits existieren; eine Eigenschaft, die in der Original-Version von tee durch eine Kommandozeilenoption kontrollierbar ist. Hier ist unsere Version von tee: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
6
#include #include using std::cin; using std::cout; using std::ofstream; int main(int argc, char **argv) { if(argc == 1){ char c; while(cin.get(c)) cout.put(c); } else{ ofstream datei(argv[1]); char c; while(cin.get(c)){ cout.put(c); if(datei) datei.put(c); } } }
IOSTREAMS
125
Wollten wir zum Beispiel standardmäßig die Zieldatei(en) nicht ersetzen, sondern an deren Inhalte anhängen, so müssten wir die Konstruktion des Ausgabestreamobjektes folgendermaßen ändern: 1 #include 1 2 using std::ios_base; 3 // ... 4 5 // ... 6 ofstream datei(argv[1], ios_base::app); 7 // ...
6.2.3
DATEIEN FÜR EIN- UND AUSGABEN
Mit Hilfe der Flags von ios_base können Dateien auch zum Lesen und Schreiben geöffnet werden. Dafür muss die Klasse fstream verwendet werden, die sowohl die istream-, als auch die ostream-Eigenschaften geerbt hat. Die Flags ios_base::in und ios_base::out erlauben dann jeweils das Öffnen einer Datei zur Ein- bzw. Ausgabe. Die ios_base-Flags lassen sich durch binäres ODER kombinieren: 1 2 3
// ... fstream datei("einausgabe.txt", ios_base::in | ios_base::out); // ...
In allen andern Operationen unterscheidet sich dann das Objekt datei nicht von anderen iostream-Objekten.
6.2.4
SCHLIESSEN DER DATEIEN
Die Dateien, die mit den entsprechenden Stream-Objekten verknüpft sind, werden automatisch geschlossen, sobald das Stream-Objekt zerstört wird; also zum Beispiel am Ende der Funktion, in der das Stream-Objekt angelegt wurde. Soll die Datei an einem konkreten anderen Punkt des Ablaufs geschlossen werden, so kann dafür die Memberfunktion close verwendet werden: 1 2 3 4 5
126
// ... ifstream eingabe("eingabe.txt"); // ... eingabe.close(); // ...
DATEIEIN- UND -AUSGABEN
6
6.3
STRINGSTREAMS
Stringstreams erlauben die formatierte Ein- und Ausgabe mit den Mitteln der iostream-Klassen in bzw. aus string-Objekten. Die entsprechenden Klassen sind erneut von istream bzw. ostream abgeleitet und im Standardheader <sstream> vereinbart.
6.3.1
STRINGSTREAMS FÜR DIE AUSGABE
Die Objekte der Klasse ostringstream können zur Erzeugung von strings mit beliebig formatiertem Inhalt verwendet werden. Das folgende Beispiel zeigt eine Funktion zur Umwandlung von beliebigen Werten in einen string: 1 2 3 4 5 6 7 8 9 10 11 12 13
#include <sstream> #include <string> using std::ostringstream; using std::string; template string to_string(T const& t) { ostringstream s; s << t; return s.str(); }
Die Funktion to_string erzeugt einen leeren ostringstream, gibt dann den übergebenen Wert t in dieses Stream-Objekt aus und liefert danach mit Hilfe der Memberfunktion str eine string-Repräsentation des Inhalts dieses Stream-Objektes zurück. Um die Formatierungswirkung zu unterstreichen, wollen wir eine zweite Funktion betrachten, die den übergebenen Wert in eine hexadezimale Angabe umwandelt: 1 2 3 4 5 6 7 8 9 10
#include using std::hex; template string to_hex_string(T const& t) { ostringstream s; s << "0x" << hex << t; return s.str(); }
Hier wird innerhalb der Funktion to_hex_string dafür gesorgt, dass zunächst 0x als Kennzeichen für hexadezimale Werte in den Ausgabestream geschrieben wird. Danach folgt der Manipulator hex, der den Ausgabestream auf hexadezimale Werte umstellt. Danach folgt der übergebene Wert. Erneut wird die Memberfunktion str benutzt, um die string-Repräsentation zurückzuliefern.
6
IOSTREAMS
127
Wir können beide Funktionen mit folgender main-Funktion testen: 1 int main() 2 { 3 string s1 = "Ein Fließkommawert: [" + to_string(5.553) + "]"; 4 string s2 = "Ein hexadezimaler Wert: [" + to_hex_string(255) + "]"; 5 6 cout << s1 << endl << s2 << endl; 7 }
Die Ausgabe lautet dann: [mme@endeavour Source]: ./to_string Ein Fließkommawert: [5.553] Ein hexadezimaler Wert: [0xff]
Wichtig zu beachten ist hier, dass die Ausgaben nach cout jeweils mit den kompletten strings vorgenommen werden. Diese werden zuvor durch Addition mehrerer Teilzeichenketten zusammengebaut. Im konkreten Fall hätten wir die Ausgabe des hexadezimalen Wertes auch so schreiben können: 1 2 3
// ... cout << "Ein hexadezimaler Wert: [0x" << hex << 255 << "]"; // ...
Jedoch wollten wir den string zunächst komplett vorformatieren; und dies gelingt uns nur mit Hilfe des ostringstreams. Solche Funktionen wie to_string sind insbesondere dann nützlich, wenn man das Resultat der Umwandlung nicht direkt ausgeben, sondern zum Beispiel mit einer speziellen Funktion in eine Datenbank oder ein Bildschirmformular schreiben will.
6.3.2
STRINGSTREAMS FÜR DIE EINGABE
Der Begriff „Eingabe“ bedeutet hierbei so viel wie „Eingabe aus einem string“. Mit Hilfe von Objekten der Klasse istringstream kann man string-Objekte sehr einfach in ihre einzelnen Bestandteile zerlegen und diese mit dem gewohnten Eingabeoperator in Variablen unterschiedlicher Datentypen einlesen: 1 2 3 4 5 6 7 8 9 10 11 12 13
128
#include <sstream> #include #include <string> using using using using
std::istringstream; std::cout; std::endl; std::string;
int main() { string s = "15 Worte 19.95";
STRINGSTREAMS
6
14 15 16 17 18 19 20 }
istringstream is(s); int a; string b; double c; is >> a >> b >> c; cout << "a: [" << a << "] b:[" << b << "] c: [" << c <<"]" << endl;
Man beachte hier insbesondere die Initialisierung des istringstream-Objektes aus dem string s. Die Verwendung von istringstreams kann insbesondere dann nützlich sein, wenn Eingabedateien so formatiert sind, dass ein zeilenweises Einlesen unabdingbar ist. Man liest dann jede Zeile einzeln in einen string ein, und kann danach sofort mit Hilfe des istringstreams komfortabel auf die einzelnen Elemente der Eingabezeile zugreifen.
6.4
ZUSAMMENFASSUNG
Der iostream-Teil der C++-Standardbibliothek dient der Ein- und Ausgabe von Daten. In diesem Kapitel wurden die grundlegenden Einund Ausgabeklassen und ihre Benutzung dargestellt. Es wurde auf Manipulatoren und ihren Einfluss auf die Formatierung der Daten eingegangen. Im Rahmen verschiedener Beispiele wurde die Verwendung der iostreams dargestellt.
6.5
ÜBUNGEN
1. Implementieren, compilieren und testen Sie die Beispiele aus diesem Kapitel! 2. Entwickeln Sie eine Funktion, die mit Hilfe der stringstream-Klasse einen string in einen double-Wert umwandelt! 3. Generalisieren Sie die Funktion zur Umwandlung eines strings in einen double für
andere Datentypen! 4. Entwickeln Sie ein Programm namens grep, das nach dem Vorkommen einer als
ersten Kommandozeilenparameter übergebenen Zeichenkette in einem Eingabestream sucht. Die Eingabedaten kommen aus einer Datei, sofern ein zweites Kommandozeilenargument übergeben wurde, ansonsten aus der Standardeingabe. Die Daten sollen mit Hilfeder Funktion istream& getline(istream& in, string& s);
6
IOSTREAMS
129
aus der Standardbibliothek eingelesen werden. Wird ein Treffer gefunden, so soll die betreffende Zeile auf der Standardausgabe ausgegeben werden. Etwaige Fehlermeldungen werden auf der Standardfehlerausgabe ausgegeben. Testen Sie das Programm! 5. Implementieren Sie einen Eingabeoperator für die Klasse Array. Bedenken Sie
dabei, dass Sie nur eine bestimmte Menge von Daten einlesen dürfen. 6. Implementieren Sie einen Eingabeoperator für die Klasse Matrix. Nehmen Sie
dabei an, dass die Matrixdaten zeilenweise gegeben werden. Beachten Sie, dass die Anzahl der Zeilen und Spalten der Matrix beschränkt ist!
6.6
LITERATUREMPFEHLUNGEN
STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Auf die Feinheiten der Stream-Bibliothek wird in Kapitel 21 eingegangen. Sehr detailliert werden verschiedene Manipulatoren und ihre Auswirkungen betrachtet; jedoch auch die interne Verarbeitung der Daten im Stream, sowie zahlreiche Hinweise zur eigenen Entwicklung von Manipulatoren und iostream-Abkömmlingen werden gegeben. Darüberhinaus wird auch auf die von C geerbten Funktionen zur Ein- und Ausgabe von Daten eingegangen und auf Probleme beim gleichzeitigen Betrieb von C++- und C-Ein- und -Ausgaben eingegangen.
KOENIG/MOO: „INTENSIVKURS C++“ Im Rahmen der Einleitung in die Arbeit mit C++ in den Kapiteln 0 und 1 werden die Grundfunktionalitäten des -Teils der Standardbibliothek erläutert.
KUHLINS/SCHADER: „ DIE C++-STANDARDBIBLIOTHEK“ In Kapitel 12 befindet sich hier die Betrachtung der Streams zur Ein- und Ausgabe von Daten. Die Betrachtung ist etwas systematischer als im Stroustrup, wobei hier lediglich die Ansätze für eigene Entwicklungen dargestellt werden.
LANGER/KREFT: „STANDARD C++ IOSTREAMS AND LOCALES“ Das gesamte Buch beschäftigt sich sehr eingehend mit der internen Funktionsweise, der Benutzung und Erweiterung des Stream-Teils der Standardbibliothek. Hierbei wird auch sehr viel Wert auf die Unterstützung zur Internationalisierung durch die Definition von Locales und Facets eingegangen. Für Entwickler von eigenen Streams oder Stream-Anwendungen, die tiefer in die Funktionsweise der Streams eingreifen müssen, ist dies eine sehr wertvolle und fast unabdingbare Ressource.
130
LITERATUREMPFEHLUNGEN
6
7 SEQUENZIELLE CONTAINER UND ITERATOREN In diesem Kapitel soll die Speicherung von größeren Datenmengen im Vordergrund stehen. C++ bietet dafür zwei Arten von Containern an, wobei wir uns hier mit sequenziellen Containern beschäftigen wollen. Außerdem soll der Iteratorbegriff erläutert werden. Wer dieses Kapitel durchgearbeitet hat, sollte:
Î verschiedene Arten von Containern kennen gelernt haben, Î die grundlegenden Schnittstellen der C++-Standard-Container kennen und anwenden können, Î verschiedene Iteratorkategorien unterscheiden und anwenden können, Î die Performanceunterschiede zwischen verschiedenen Containertypen verstanden haben.
7.1
CONTAINER
Das Containerkonzept ist eine Abstraktion der C++-Standardbibliothek, die die Speicherung von Daten unabhängig von ihrem Datentyp und ohne vorherige Kenntnis über die gesamte Menge der zu speichernden Daten erlaubt. Die verschiedenen Arten von Containern definieren unterschiedliche Arten von Operationen auf ihren Elementen, stets mit dem Ziel, bestmögliche Effizienz zu bieten und ineffiziente Operationen von vornherein nicht zuzulassen. Grundsätzlich unterscheiden wir zwei Typen von Containern, sequenzielle und assoziative Container. Sequenzielle Container speichern ihre Inhaltselemente im Allgemeinen in der Reihenfolge, die der Benutzer des Containers durch die Anwendung der verschiedenen Einfügeoperationen vorsieht. Ihnen widmen wir uns in diesem Kapitel. Assoziative Container hingegen halten ihre Inhaltselemente in einer speziellen internen Ordnung, die den späteren Zugriff auf die Elemente nach konkreten Performancevorgaben ermöglicht. Der Benutzer eines assoziativen Containers hat in der Regel keine Möglichkeit, die Reihenfolge des Einfügens der Elemente zu beeinflussen oder zu erkennen. Wir werden uns assoziativen Containern im Kapitel 9 zuwenden.
7
SEQUENZIELLE CONTAINER UND ITERATOREN
131
7.2
ITERATOREN
Iteratoren erlauben die Formalisierung sequenzieller Zugriffe auf Container. Sie beschreiben die Eigenschaften von Datentypen, die für bestimmte Arten solcher Zugriffe erforderlich sind. Ein Iterator ist gewissermaßen ein Verweis auf ein Element eines Containers, versehen mit der Möglichkeit, auf das Element zuzugreifen, sowie auf das nächste (oder vorhergehende) Element weitergesetzt zu werden. In dieser Hinsicht ähnelt ein Iterator einem Zeiger. Die hier und in Kapitel 9 beschriebenen Container, sowie die Klasse string (siehe Kapitel 8 ) stellen Iteratoren zur Verfügung, die die Zugriffscharakteristika der entsprechenden Container widerspiegeln. Gleichzeitig benutzen die in Kapitel 10 vorgestellten Algorithmen Iteratoren zur Begrenzung der Datensequenz, auf der sie arbeiten. Diese Algorithmen stellen gewisse Anforderungen an die Iteratoren, die zwischen verschiedenen Algorithmen differieren und stets die Menge an Operationen darstellen, die zur Ausführung des Algorithmus auf der Datensequenz erforderlich ist. Auf diese Weise dienen Iteratoren zur Kopplung von Algorithmen und Containern und bilden den „gemeinsamen Nenner“ bezüglich der Operationen zum Zugriff auf die Elemente von Containern.
7.3
STANDARD-CONTAINER UND IHRE EIGENSCHAFTEN
Der C++-Standard sieht drei sequenzielle Container vor: vector, list und deque. Für jeden dieser Container existiert ein entsprechende Standardheader mit gleichem Namen. Alle Container sind Templates, wobei der Templateparameter1 den Typ der im Container zu speichernden Daten angibt. In dieser Hinsicht ähneln Container unserer in Kapitel 4.3 definierten Klasse Array.
7.3.1
GEMEINSAME BESTANDTEILE
Allen drei Containern gemeinsam sind folgende Bestandteile:
MEMBERTYPEN Membertypen sind Typdefinitionen, die innerhalb einer Klasse erfolgt sind. Typdefinitionen sind Anweisungen mit dem Schlüsselwort typedef, die einen neuen Namen für einen existierenden Datentyp festlegen2. Diese Anweisungen können eigenständig in C++-Code auftreten ... 1
2
Tatsächlich haben alle Container mehr als einen Templateparameter; durch Default-Templateparameter wird jedoch dafür gesorgt, dass wir in der Regel nur einen angeben müssen. Durch typedef wird also kein neuer Typ „definiert“, sondern lediglich ein neuer Name für einen Typ festgelegt.
132
ITERATOREN
7
1 2 3 4 5 6
// ... typedef string str; string s("Hallo"); str s2 = s; // ...
// Verwendung von str anstelle von string
... wir können sie jedoch auch in Klassendefinitionen einbetten: 1 2 3 4 5 6 7 8
template class Array { public: typedef T value_type; // ... };
Damit erlauben wir folgende Benutzung: 1 2 3 4 5
// ... Array intA; // ... Array::value_type x = intA[1]; // x ist vom Typ int
Die Container der C++-Standardbibliothek definieren auf diese Weise eine Reihe von Membertypen. Eine Auswahl zeigt die folgende Tabelle: value_type
Datentyp der Containerelemente
size_type
Datentyp von Elementanzahl und Indices
iterator
Iteratortyp für Zugriff auf Elemente
const_iterator
Iterator für Nur-Lese-Zugriff
reverse_iterator
Iteratortyp für Zugriff in umgekehrter Ordnung
const_reverse_iterator
Iteratortyp für Nur-Lese-Zugriff in umgekehrter Ordnung
reference
Referenztyp für Zugriff auf Elemente
const_reference
Referenztyp für Nur-Lese-Zugriff
ITERATOREN Die Container bieten Memberfunktionen an, die Iteratoren auf strategisch wichtige Positionen des Containers zurückliefern: begin
Iterator auf das erste Element
end
Iterator hinter das letzte Element
rbegin
Iterator auf das erste Element in umgekehrter Ordnung
rend
Iterator hinter das letzte Element in umgekehrter Ordnung
7
SEQUENZIELLE CONTAINER UND ITERATOREN
133
DIREKTE ELEMENTZUGRIFFE Mit Hilfe der Memberfunktionen front und back kann jeweils auf das erste bzw. letzte Element der Sequenz zugegriffen werden.
STACKOPERATIONEN Die gewöhnlich auf Stack-Speicherstrukturen verfügbaren Operationen push und pop (Einfügen und Herausnehmen von Elementen) sind für Container in verschiedener Weise implementiert. Alle Container unterstützen push_back und pop_back zum Einfügen und Entnehmen von Elementen am Ende des Containers.
LISTENOPERATIONEN Einfüge- und Löschoperationen, wie sie für Listen-Speicherstrukturen typisch sind, werden ebenfalls auf allen Containern angeboten: (it, it2 und it3 sind Iteratoren, n ist eine ganzzahlige Größe und x vom zu speichernden Datentyp) insert(it, x)
Fügt x vor der Position it ein.
insert(it, n, x)
Fügt n Kopien von x vor der Position it ein.
insert(it, it2, it3)
Fügt alle Elemente aus [it2,it3) vor it ein.
erase(it)
Löscht das Element an der Position it.
erase(it, it2)
Löscht die Elemente im Bereich [it, it2).
clear
Löscht alle Elemente.
ALLGEMEINE OPERATIONEN size
Anzahl der Elemente
empty
Wahr, falls der Container leer ist
max_size
Maximal mögliche Elementanzahl
swap
Austausch von Elementen zweier Container
Darüber hinaus sind auf allen Containern die Operatoren ==, != und < definiert, so dass Vergleiche angestellt und eine Reihenfolge zwischen verschiedenen Containerobjekten hergestellt werden kann.
KONSTRUKTOREN, ZUWEISUNG, DESTRUKTOREN Container()
Erzeugt einen leeren Container.
Container(n)
Erzeugt einen Container mit n Elementen mit Defaultwert.
Container(n,x)
Erzeugt einen Container mit n Kopien von x.
Container(it, it2)
Erzeugt einen Container mit den Elementen aus [it,it2).
Container(C)
Copykonstruktor
operator=(C)
Zuweisungsoperator
Container()
Zerstört den Container und alle Elemente.
134
STANDARD-CONTAINER UND IHRE EIGENSCHAFTEN
7
7.3.2
VECTOR
Der Standard-C++-Container vector (definiert im Standardheader ) verwaltet seine Elemente in einem zusammenhängenden Speicherabschnitt und bietet daher zusätzlich zu den gemeinsamen Operationen den direkten indexbasierten Zugriff auf die Elemente an. Dafür definiert er einerseits den Operator operator[] für ungeprüfte Zugriffe, andererseits die Memberfunktion at, die eine Exception auslöst, sofern Zugriffe vor den Beginn, bzw. hinter das Ende des vectors erfolgen. Wir wollen das Feld aus unserem quadrate-Beispiel in Kapitel 1.7 als vector gestalten: 1 2 3 4 5 6 7 8
#include using std::vector; int main() { vector quadrate; for(int i = 0; i != 11; ++i) quadrate.push_back(i*i); }
Mit dem Datentyp vector sagen wir, dass wir in dem vector int-Werte speichern wollen. Dies ist die übliche Angabe eines Templateparameters, die wir bereits aus Kapitel 4 kennen. Da vector dynamisch wachsen kann, müssen wir nicht angeben, wie viele Elemente im vector gespeichert werden sollen. Wir rufen stattdessen push_back auf dem vector quadrate auf, um die Elemente anzuhängen. Wir wollen die main-Funktion ergänzen, indem wir die Quadratwerte ausgeben. Dafür können wir den Indexoperator verwenden: 1 int main() 2 { 3 // ... 4 for(int i = 0; i != quadrate.size(); ++i) 5 cout << quadrate[i] << " "; 6 cout << endl; 7 }
Alternativ können wir auch einen Iterator verwenden: 1 int main() 2 { 3 // ... 4 for(vector::const_iterator it = quadrate.begin(); 5 it != quadrate.end(); ++it) 6 cout << *it << " "; 7 cout << endl; 8 }
Wir definieren uns it als Variable vom Datentyp vector::const_iterator. Dies ist ein Iterator, über den wir den Inhalt des vectors nicht verändern können. Die Memberfunktion begin liefert uns einen solchen Iterator auf das erste Element des vectors. 7
SEQUENZIELLE CONTAINER UND ITERATOREN
135
Die Memberfunktion end liefert einen Iterator hinter das Ende des vectors3. Wir müssen den Iterator (mit Hilfe von ++) so lange weitersetzen, bis wir auf das Ende stoßen. Um über den Iterator an das Element heranzukommen, verwenden wir *, ähnlich wie bei einem Zeiger. Die folgende Grafik veranschaulicht beide Zugriffsmöglichkeiten: Indizes:
quadrate:
0
1
0
1
...
4
9 16
quadrate.size()
25 36 49
64 81 100
quadrate.end()
quadrate.begin()
Die Linien bei quadrate.size() und quadrate.end() sind gepunktet, weil weder ein Zugriff über quadrate[quadrate.size()], noch über *quadrate.end() möglich ist. Dennoch eignen sich beide Werte als Endekennzeichnen für die Schleifenbedingung. Aufgrund der sequenziellen internen Speicherstruktur sind alle indexbasierten Zugriffe in ihrem Zeitverhalten unabhängig von der Anzahl der Elemente im Container. Werden Elemente gelöscht, so müssen alle Elemente hinter dem gelöschten Element einen Speicherplatz nach vorn kopiert werden. Das Löschen von Elementen innerhalb des vectors ist daher eine unter Umständen kostspielige Operation, die im schlimmsten Fall linear von der Anzahl der Elemente im vector abhängt. vector: zu l¨ oschendes Element
kopierte Elemente
Beim Einfügen von Elementen an beliebiger Stelle im vector müssen mindestens alle Elemente hinter dem einzufügenden Element um ein Element nach hinten kopiert werden. Gegebenenfalls ist jedoch auch eine Neuallokation des Speichers für alle Elemente notwendig; in diesem Fall müssen alle Elemente kopiert werden. In beiden Fällen ist der Aufwand und damit auch die erforderliche Rechenzeit von der Anzahl der Elemente im vector (und der Einfügeposition) abhängig.
3
In Kapitel 7.4.1 wird erklärt, wieso es ein Iterator hinter das Ende des vectors ist.
136
STANDARD-CONTAINER UND IHRE EIGENSCHAFTEN
7
Die Klasse vector eignet sich daher vor allem dort, wo schneller Zugriff auf beliebige Elemente erforderlich ist. Wir wollen vector verwenden, um unsere Klasse Array flexibler zu gestalten. Wir können dabei einerseits den Größenparameter aus dem Templateparametersatz entfernen, denn wie alle Container der C++-Standardbibliothek bietet uns auch vector im Unterschied zu den C++-Feldern die Möglichkeit, den Container wachsen zu lassen, sofern wir mehr Speicher benötigen. Wir werden daher unseren Defaultkonstruktor ändern, so dass als erstes Argument eine optionale Größenangabe erfolgen kann; wird kein Argument übergeben, so hat das Array eine Anfangsgröße von 0. Wir können einfach den entsprechenden Konstruktor von vector benutzen, um dies umzusetzen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
#include // ... weitere includes ... using std::vector; // ... weiter using-Deklarationen ... template // Elementanzahl E entfernt! class Array { public: Array(size_t s = 0, T const& t = T()) : daten(s, t) { }
Unsere Memberfunktion print müssen wir an die Verwendung eines vectors zur Datenspeicherung anpassen. Hier wird der bisherige Templateparameter E durch die Memberfunktion size des vectors ersetzt: 1 2 3 4 5 6
ostream& print(ostream& os) const { for(size_t i = 0; i != daten.size(); ++i) os << daten[i] << " "; os << endl; }
Auch unsere eigene Memberfunktion size muss entsprechend geändert werden: 1 2 3 4
size_t size() const { return daten.size(); }
Die Memberfunktionen set und get haben bislang stets eine Prüfung des verwendeten Index durchgeführt. Indem wir die Memberfunktion at von vector verwenden, können wir unsere eigene Prüfung einsparen. Die beiden Indexoperatoren behalten hingegen ihre bisherige Gestalt. Nach wie vor werden sie ungeprüfte Zugriffe auf die Inhalte des Arrays erlauben.
7
SEQUENZIELLE CONTAINER UND ITERATOREN
137
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
void set(size_t e, T t) { daten.at(e) = t; } T get(size_t e) const { return daten.at(e); } T const& operator[](size_t e) const { return daten[e]; } T& operator[](size_t e) { return daten[e]; }
Der Multiplikations-Compound-Operator erhält einen neuen Rückgabetyp, denn wir haben ja den Größenparameter aus dem Templateparametersatz entfernt. Darüber hinaus muss auch hier die Memberfunktion size zur Prüfung der Elementanzahl verwendet werden: 1 2 3 4 5 6 7
Array operator*=(T t) { for(size_t i = 0; i != daten.size(); ++i){ daten[i] *= t; } return *this; }
Schließlich sehen wir in der private-Sektion der Klassendefinition unsere Membervariable daten, deren Typ nunmehr vector anstelle von T ist: 1 2 private: 3 vector daten; 4 };
Natürlich müssen wir auch unsere Multiplikationsoperatoren anpassen. In der Regel ändern sich nur Übergabe- und Rückgabedatentypen bezüglich Array: 1 2 3 4 5 6 7
138
template Array operator*(T const& t, Array const& TE) { Array A = TE; return A *= t; }
STANDARD-CONTAINER UND IHRE EIGENSCHAFTEN
7
8 9 10 11 12
template Array operator*(Array const& TE, T const& t) { return operator*(t,TE); }
In der main-Funktion können wir die Größe als Templateparameter weglassen; dafür müssen wir – zumindest nach dem momentanen Stand der Entwicklung – die Größe als Konstruktorargument angeben. Wir werden in Kürze jedoch auch das dynamische Wachstum des vectors nutzen. Aus 1
Array intA;
// drei Elemente mit Wert 0
wird nunmehr 1
Array intA(3);
// drei Elemente mit Wert 0
Wie können wir nun das dynamische Wachstum in unsere Array-Klasse einbauen? Da wir die Memberfunktion set zum Setzen von Elementen des jeweiligen Array-Objektes verwenden können, wäre denkbar, dass wir diese Funktion auch das Wachsen des Datenbestandes übertragen. Die Überlegung wäre, dass man folgende Nutzung erlauben könnte: 1 2 3 4 5
Array intA;
// leeres int-Array
intA.set(5, 2); intA.set(10, 4); intA.set(15, 6);
// Elemente [0,5) sind 0 // Elemente [6,10) sind 0 // Elemente [11,15) sind 0
Durch das Einfügen von Elementwerten an entsprechenden Indizes werden die Elemente zwischen dem letzten Element und dem neuen Element automatisch mit 0 belegt. Mathematiker, die dünn besetzte Vektoren oder Matrizen abbilden wollen, werden diesen Service zu schätzen wissen: 1 2 3 4 5 6 7 8 9 10
void set(size_t e, T t) { if(e >= daten.size()){ for(size_t i = daten.size(); i != e; ++i) daten.push_back(T()); daten.push_back(t); } else daten.at(e) = t; }
Falls der angegebene Index e jenseits der Größe von daten liegt, wird zunächst eine entsprechende Menge von Zwischenelementen angehängt. Als Wert dieser Elemente wird der typspezifische Defaultkonstruktor verwendet, der zum Beispiel für Basisdatentypen einen entsprechenden Nullwert erzeugt. Danach wird dann der übergebene Wert t angehängt.
7
SEQUENZIELLE CONTAINER UND ITERATOREN
139
Wir können den obigen Beispielcode in eine main-Funktion packen und noch um 1
intA.print(cout);
ergänzen. Die Ausgabe sieht folgendermaßen aus: [mme@endeavour Source]: ./array_vec 0 0 0 0 0 2 0 0 0 0 4 0 0 0 0 6
Wir können alternativ (bzw. zusätzlich) natürlich auch eine Memberfunktion push_back in Analogie zu vector implementieren, die den Container jeweils um ein Element erweitert. Diese Änderung sei dem Leser als Übung überlassen. Die Anpassung der Schnittstelle unserer Array-Klasse an die Vorgaben der C++-Standardbibliothek für Container ist durch die interne Verwendung von vector trivial und beschränkt sich auf den Aufruf der entsprechenden vector-Memberfunktionen.
7.3.3
LIST
Die interne Struktur des Containertyps list (definiert im Standardheader <list>) ermöglicht das Einfügen und Löschen von Elementen innerhalb des Containers in konstanter Zeit, wobei jedoch im Gegensatz zu vector keine wahlfreien indexbasierten Zugriffe in konstanter Zeit mehr möglich sind. Dieses Verhalten wird durch die Gestaltung des internen Speichers als verkettete Liste realisiert. Hierbei enthält jedes Element abgesehen von seinem Wert auch einen Verweis auf das nächste (bzw. gegebenenfalls zusätzlich auf das vorherige) Element der Liste. Soll eines dieser Elemente gelöscht werden, so wird dies durch die Änderung des Verweises im vorherigen (und gegebenenfalls im nächsten) Element erreicht. Da es sich bei dieser Operation um einen reinen Speicherupdate handelt und keine Kopien von Elementen erforderlich sind, kann sie in konstanter Zeit durchgeführt werden; unabhängig davon, wie viele Elemente im Container vorhanden sind.
list:
zu l¨ oschendes Element
Zeiger auf neues Element verlegt (keine Kopie erforderlich)
Das Einfügen von Elementen in die list ist ebenso damit verbunden, eine bzw. zwei Speicherreferenzen zu verändern, unabhängig davon, an welcher Stelle das Element eingefügt werden soll und wie viele Elemente bereits in der list vorhanden sind.
140
STANDARD-CONTAINER UND IHRE EIGENSCHAFTEN
7
Der Container list eignet sich daher vor allem für Projekte, die mit zahlreichen Einfüge- und Löschoperationen in der Mitte des Containers verbunden sind. Beim Zugriff auf die Elemente ist lediglich eine sequenzielle Vorgehensweise möglich; wahlfreie indexbasierte Zugriffe (zum Beispiel mit Hilfe des Index-Operators []) werden nicht unterstützt.
7.3.4
DEQUE
Der Containertyp deque (ausgesprochen wie „Deck“ im Deutschen) ist im Standardheader <deque> definiert und bietet prinzipiell die gleichen Performancebedingungen wie vector. Einziger Unterschied ist, dass Einfüge- und Entfernungsoperationen am Beginn des Containers genauso effizient sind, wie am Ende des Containers. Der Containertyp deque eignet sich daher für alle Einsatzgebiete, in denen neben den Einsatzbedingungen für vector auch die Forderung nach effizienten Einfüge- und Löschoperationen am Beginn der Elementsequenz besteht.
7.4
ITERATORKATEGORIEN
Alle Container der Standardbibliothek bieten eine Iteratorschnittstelle. Das bedeutet, dass jeder Container über Memberfunktionen begin und end verfügt, die Iteratoren für zwei wichtige Positionen im Container zurückliefern: den Anfang und das Ende.
7.4.1
BETRACHTUNGEN ZUM SEQUENZENDE
Besonders bemerkenswert ist, dass die Memberfunktion end von Containern anstelle eines Iterators, der auf das letzte Element verweist, einen Iterator zurückliefert, der auf ein theoretisch unmittelbar hinter dem letzten Element gelegenes Element verweist. Dieser Iterator ist nur für einen Teil der üblichen Iteratorfunktionalitäten verwendbar; insbesondere lässt er sich nicht dereferenzieren, um an den Elementinhalt zu gelangen, denn da er nicht auf ein Element zeigt, gibt es auch keinen entsprechenden Inhalt. Verwenden lässt er sich jedoch zum Vergleichen mit anderen Iteratoren. Warum wurde dieses Verfahren gewählt, anstelle der zunächst logischer erscheinenden Methode, den Endeiterator einfach auf das letzte Element zeigen zu lassen? Wir wollen eine Schleife betrachten, die einen vector mit int-Werten durchläuft, und jeden Wert im vector inkrementiert: 1 2 3 4 5 6 7 8
7
vector v; // ... Einfügen von n Elementen ... for(vector::iterator it = v.begin(); it != v.end(); // v.end() zeigt *hinter* das letzte Element ++it) *it++;
SEQUENZIELLE CONTAINER UND ITERATOREN
141
Der Iterator it, der als Laufvariable für das Durchwandern der Sequenz verwendet wird, wird im Initialisierungsteil der for-Schleife auf das erste Element des vectors v gesetzt. Danach erfolgt der erste Vergleich auf das Ende der Sequenz: Sofern it nicht auf die gleiche Stelle zeigt, wie v.end(), wird der Schleifenkörper abgearbeitet. In diesem wird der Iterator dereferenziert; das Ergebnis der Dereferenzierung (der Wert des Elements) wird anschließend an Ort und stelle inkrementiert. Schließlich endet ein Schleifendurchlauf mit der Inkrementierung des Iterators selbst, welche diesen auf das nächste Element der Sequenz setzt. Der Zyklus startet erneut mit dem Vergleich zwischen it und v.end(), und so fort. Die Schleife endet, sobald it und v.end() auf die gleiche Stelle verweisen; also hinter das letzte Element. An diesem Punkt erfolgt kein weiterer Durchlauf, d.h. die letzte Dereferenzierung erfolgt mit dem vorherigen Element, das ja auch tatsächlich das letzte Element der Sequenz ist. Die Schleife funktioniert folglich zufrieden stellend. Wie müssten wir diese Schleife formulieren, wenn v.end() nicht hinter das letzte Element, sondern auf das letzte Element zeigen würde? 1 2 3 4 5 6 7 8
vector v; // ... Einfügen von n Elementen ... for(vector::iterator it = v.begin(); it <= v.end(); // v.end() zeigt *auf* das letzte Element ++it) *it++;
Beachtenswert ist die Verwendung von <= zum Vergleich der Iteratoren. Diese Verwendung erfordert die Definition einer Ordnungsrelation auf dem Iterator, so dass Vergleiche von Iteratoren mit Hilfe des Operators < möglich werden. Während dies für die meisten praktischen Sequenzen sicherlich ohne weiteres zutreffend ist, lässt sich dieses Verhalten in bestimmten Situationen (zum Beispiel beim umgekehrten Iterieren durch eine Sequenz) schwieriger realisieren. Wenn wir zulassen, dass v.end() hinter das Ende der Sequenz zeigt, genügt die Definition der Ungleichheit zweier Iteratoren; was deutlich weniger Ansprüche stellt. Ein weiteres Problem stellt das Verhalten eines leeren Containers dar. Wenn der Endeiterator hinter das letzte Element zeigen darf, so dürfen im Falle eines leeren Containers sowohl begin, als auch end den gleichen Iterator zurückliefern. Betrachten wir die erste unserer beiden Schleifen erneut: nachdem im Initialisierungsteil it auf v.begin() gesetzt wurde, prüfen wir vor dem ersten Schleifendurchlauf, ob it und v.end() äquivalent sind. Falls dies der Fall ist, wird der Schleifenkörper überhaupt nicht ausgeführt. Da wir vereinbart haben, dass im Fall eines leeren Containers v.begin() und v.end() einfach den gleichen (beliebigen) Iterator zurückliefern, erkennt die erste Schleife automatisch, dass es sich um einen leeren Container handelt. Der Programmierer muss sich um diesen Fall nicht speziell kümmern. Wenn wir fordern, dass der Endeiterator auf das letzte Element zeigt, und nicht dahinter, kompliziert sich die Situation. In einem leeren Container gibt es kein erstes 142
ITERATORKATEGORIEN
7
und kein letztes Element. Worauf soll v.end() hier zeigen? Da die zweite Schleife <= zum Vergleich zwischen it und v.end() verwendet, muss v.end() kleiner sein, als v.begin(), damit das gleiche automatische Verhalten wie mit der ersten Schleife eintritt. Dies ist eine kuriose Situation: das (nicht vorhandene) letzte Element muss (im Sinne einer Ordnungsrelation) vor dem (ebenfalls nicht vorhandenen) ersten Element liegen! Im Vergleich dazu stellt ein Endeiterator, der hinter das letzte Element der Sequenz zeigt, nur wenige Anforderungen. Er fordert vom Programmierer lediglich, dass er ihn nicht dereferenziert. Gleichzeitig ermöglicht er jedoch durch einen einfachen Vergleich, das Ende der Sequenz festzustellen und zu behandeln. Die Definition einer Ordnungsrelation und die vergleichsweise komplizierte Behandlung leerer Container führt dazu, dass ein Endeiterator, der auf das letzte Element zeigt, verworfen wurde. Wie wir noch in den weiteren Abschnitten dieses Kapitels, sowie im Kapitel 10 sehen werden, können Endeiteratoren, die nicht auf ein gültiges Element verweisen, sehr gut als Bottomwerte für die Rückgabe aus Funktionen verwendet werden. Dadurch wird es möglich, in den Algorithmen der Standardbibliothek (und auch in eigenen Funktionen) auf Exceptions zu verzichten.
7.4.2
ITERATOREN FÜR SEQUENZIELLES LESEN VON DATEN
Die einfachste Iteratorkategorie heißt Inputiterator und unterstützt folgende Operationen: it++ und ++it:
das Weitersetzen des Iterators it auf das nächste Element der Sequenz,
*it:
die Dereferenzierung des Iterators,
it1 == it2 und it1 != it2:
Vergleichsoperatoren zur Feststellung der Äquivalenz zweier Iteratoren.
Es ist damit möglich, einen Iterator über eine Sequenz von Werten laufen zu lassen und die Werte der Sequenz zu lesen. Außerdem können wir feststellen, ob zwei Iteratoren, die in die gleiche Sequenz zeigen, äquivalent sind. Damit lässt sich zum Beispiel eine Funktion implementieren, die die Suche von Daten in einer Sequenz durchführt. Wir wollen ein Beispiel einer solchen Implementierung angeben: 1 2 3 4 5 6 7
7
template InIt find(InIt start, InIt ende, T wert) { while(start != end && *start != wert) ++start; return start; }
SEQUENZIELLE CONTAINER UND ITERATOREN
143
Diese einfache Funktion erhält drei Argumente: zwei Inputiteratoren, die die Sequenz begrenzen, in der gesucht werden soll, und einen Wert, nach dem gesucht werden soll. Die while-Schleife bewegt den Iterator start so lange auf ende zu, bis entweder ende erreicht ist, oder der gesuchte Wert gefunden wurde. Der jeweils aktuelle Wert von start wird dann zurückgeliefert. Der Programmierer, der diese Funktion benutzt, prüft die Rückgabe der Funktion gegen das zweite übergebene Argument (innerhalb der Funktion ist dies ende). Falls beide identisch sind, wurde der Wert nicht in der Sequenz gefunden, anderenfalls ist der Rückgabewert ein Iterator, der auf die Stelle verweist, an der der Wert in der Sequenz vorkommt.
7.4.3
ITERATOREN FÜR SEQUENZIELLES SCHREIBEN VON DATEN
Iteratoren für das sequenzielle Schreiben von Daten heißen Outputiteratoren und unterstützen folgende Operationen: ++it und it++:
das Weitersetzen des Iterators auf die nächste Position in der Sequenz,
*it = x:
die Zuweisung eines Wertes an eine Position in der Sequenz.
Weiterhin stellen Outputiteratoren zwei Anforderungen an den Programmierer: 1. Zwischen zwei Zuweisungen darf der Outputiterator maximal einmal inkrementiert
werden. Damit wird verhindert, dass in der Ausgabesequenz Lücken entstehen. 2. Zwischen zwei Inkrementierungen darf maximal eine Zuweisung über den Out-
putiterator erfolgen. Damit wird das Überschreiben einer eben zugewiesenen Position verhindert. Da die Iteratoren und Iteratorkategorien lediglich logische Konzepte und keine Bestandteile der Sprachdefinition von C++ sind (sie werden nicht über eigene Schlüsselworte, sondern über nutzerdefinierte Typen, bzw. Typnamen realisiert) können diese Anforderungen von C++-Compilern nicht durchgesetzt werden. Der vom Programmierer geschriebene Code muss sie erfüllen, anderenfalls undefiniertes Verhalten folgen kann. Eine Funktion, die Outputiteratoren verwendet, ist copy: 1 2 3 4 5 6 7
template OutIt copy(InIt start, InIt ende, OutIt ziel) { while(start != ende) *ziel++ = *start++; return ziel; }
Diese Funktion kopiert die Werte einer durch die Inputiteratoren start und ende begrenzten Sequenz in die vom Outputiterator ziel vorgegebenen Positionen. Der Outputiterator ist dabei selbst dafür verantwortlich, entsprechenden Speicher für die Aufnahme der zu kopierenden Elemente bereitzustellen. Wir werden weitere Überlegungen dazu in Abschnitt 10.3.1 anstellen. 144
ITERATORKATEGORIEN
7
7.4.4
ITERATOREN FÜR SEQUENZIELLES LESEN UND SCHREIBEN
Die Kombination der unterstützten Operationen von Input- und Outputiterator liefert die Iteratorkategorie Forwarditerator. Dieser wird zum Beispiel in der Funktion replace benutzt, die innerhalb einer Sequenz alle Vorkommen eines gegebenen Wertes durch einen anderen Wert ersetzt: 1 2 3 4 5 6 7 8
template
ForIt, typename T> start, ForIt ende, T w1, T w2) ende){ == w1) = w2;
Wie wir sehen, werden hier sowohl die besonderen Eigenschaften des Inputiterators (Dereferenzierung zum Lesen des Wertes), als auch die Eigenschaften des Outputiterators (Dereferenzieren zum Schreiben des Wertes) verwendet.
7.4.5
ITERATOREN FÜR DATENZUGRIFFE IN UMGEKEHRTER REIHENFOLGE
Alle bisherigen Iteratoren konnten nur in einer Richtung durch die Sequenz bewegt werden, mit Hilfe des Operators ++. Die Iteratorkategorie des Bidirektionaliterators erweitert dies um die Operation --, die es erlaubt, den Iterator auf das vorherige Element der Sequenz zu setzen. Ein Beispiel für die Verwendung des Bidirektionaliterators ist die Funktion reverse, die die Werte einer Sequenz in ihrer Reihenfolge umkehrt: 1 2 3 4 5 6 7 8 9
template void reverse(BiIt start, BiIt ende) { while(start != ende) { --ende; if(start != ende) swap(*start++, *ende); } }
Die hierbei verwendete Funktion swap tauscht die Werte an den ihr durch Referenz übermittelten Positionen der Sequenz aus.
7
SEQUENZIELLE CONTAINER UND ITERATOREN
145
7.4.6
ITERATOREN FÜR WAHLFREIEN ZUGRIFF
Neben den Operationen von Bidirektionaliteratoren müssen Random-Access-Iteratoren auch folgende Operationen unterstützen (wobei it1 und it2 Random-Access-Iteratoren sind und n ein beliebiger int): it1 + n, it1 - n und n + it1:
das Weiter-/Zurücksetzen des Iterators um n Elemente,
it1 - it2:
das Bestimmen des Abstandes zwischen zwei Iteratoren; in Elementen,
it1[n] (in Äquivalenz zu *(it1 + n)):
der indizierte Zugriff auf den Wert des Elementes n Elemente vom aktuellen Element entfernt,
it1 < it2, it1 > it2, it1 <= it2 und it1 >= it2:
relationale Operationen.
Wir können einen Binärsuchalgorithmus angeben, der zumindest einen Teil der entsprechenden Operationen benutzt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
template bool binary_search(RAIt start, RAIt ende, T wert) { while(start < ende) { RAIt mitte = start + (ende - start) / 2; if(wert < *mitte) ende = mitte; else if(*mitte < wert) start = mitte + 1; else return true; } return false; }
Diese Funktion findet einen wert in einer sortierten Sequenz, die durch start und ende begrenzt ist. Verwendet wird dabei das Bisektionsverfahren: Zunächst muss dafür die mitte der Sequenz gefunden werden, wozu Iteratorarithmetik erforderlich ist. Danach wird festgestellt, ob der gesuchte wert kleiner oder größer ist, als der Wert des mittleren Elementes. Ist er kleiner, so wird in der linken Hälfte der Sequenz weitergesucht, ist er größer, in der rechten. Entspricht wert genau dem Wert des mittleren Elements, so ist die Suche beendet und wir können true zurückliefern, um anzuzeigen, dass der gesuchte Wert tatsächlich in der Sequenz vorkommt.
146
ITERATORKATEGORIEN
7
7.4.7
ITERATOREN FÜR
iostreams
In der C++-Standardbibliothek sind zwei Iteratoren als konkrete Typen definiert, die für die Ein- bzw. Ausgabe von istream bzw. nach ostream-Objekten verwendet werden können.
ISTREAM_ITERATOR Der istream_iterator ist ein Inputiterator. Er definiert zwei Konstruktoren: 1 template 2 istream_iterator::istream_iterator(istream&); 3 template 4 istream_iterator::istream_iterator();
Der erste Konstruktor erzeugt ein Iterator-Objekt, das auf den als Parameter angegeben Stream gerichtet ist und von diesem Daten des Datentyps T einlesen kann. Der zweite Konstruktor definiert ein Iterator-Objekt, das als Endeiterator für einen beliebigen Stream verwendet werden kann. Mit folgender Anweisung können wir double-Werte von der Standardeingabe einlesen und in einen vector kopieren: 1 2 3
vector<double> v; copy(istream_iterator<double>(cin), istream_iterator<double>(), back_inserter(v));
OSTREAM_ITERATOR Der ostream_iterator ist ein Outputiterator. Er definiert einen Konstruktor: 1 template 2 ostream_iterator::ostream_iterator(ostream& out, char const* delim);
Das erste Argument des Konstruktors ist der ostream, auf dem Objekte des Datentyps T ausgegeben werden sollen, das zweite Argument, delim, ist ein nullterminierter CString, der zur Abgrenzung zwischen jedes ausgegebene Objekt gesetzt wird. Folgende Anweisung gibt einen vector<string> auf der Standardausgabe aus, wobei jeder string auf einer neuen Zeile geschrieben wird: 1 2 3
7
vector<string> vs; // ... copy(vs.begin(), vs.end(), ostream_iterator<string>(cout, "\n"));
SEQUENZIELLE CONTAINER UND ITERATOREN
147
7.5
ZUSAMMENFASSUNG
Innerhalb der C++-Standardbibliothek nehmen sequenzielle Container eine tragende Rolle ein. Sie dienen der typunabhängigen Speicherung von Daten, wobei abhängig vom konkreten Containertyp verschiedene Formen der Speicherverwaltung verwendet werden, die zu unterschiedlichen Zugriffsbedingungen führen. Weiterhin haben wir in diesem Kapitel das Konzept des Iterators diskutiert, das zur Formalisierung des Zugriffs auf sequenzielle Container dient und dem Zeigerkonzept ähnelt. Iteratoren können in verschiedene Kategorien eingeteilt werden, die in diesem Kapitel dargestellt wurden.
7.6
ÜBUNGEN
1. Implementieren und testen Sie die Array-Klasse auf Basis von vector, wie in die-
sem Kapitel beschrieben. 2. Implementieren Sie das Standard-Containerinterface, wie in Kapitel 7.3.2 gefor-
dert. 3. Warum haben wir in der Funktion binary_search in Kapitel 7.4.6 anstelle von start + (ende - start) / 2 nicht viel einfacher (start + ende) / 2 geschrieben? 4. Wäre list ebenfalls als Basis für unsere Array-Klasse geeignet? Wie steht es mit deque? Begründen Sie Ihre Antworten! 5. Falls mindestens eine der beiden vorherigen Fragen mit „ja“ beantwortet wurde,
führen Sie die entsprechende Implementierung durch und stellen Sie deren Funktionsfähigkeit fest! 6. Vergleichen Sie in einem Programm die Performance von vector, deque und list
beim Löschen von Elementen. Bestimmen Sie jeweils die Laufzeiten mit verschiedenen Datenmengen bei jeweils prozentual gleicher Anzahl von zu löschenden Elementen. 7. Stellen Sie die Implementierung der Klasse Matrix ebenfalls auf vector um. Welche Komplikationen treten auf, wenn Sie für Matrix eine Standard-Container-
schnittstelle implementieren wollten? 8. Implementieren Sie das quadrate-Programm aus Kapitel 1.7 mit list. Welche Möglichkeiten haben Sie zur Ausgabe der Quadratwerte aus der list?
148
ÜBUNGEN
7
7.7
LITERATUREMPFEHLUNGEN
STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Standard-Container werden hier im Kapitel 17 diskutiert, wobei in diesem Kapitel die sequenziellen Container die Unterabschnitte bis einschließlich 17.3 einnehmen. Im Unterabschnitt 17.6 wird die Definition einer neuen Containerklasse (zunächst ebenfalls aufbauend auf den Containern der Standardbibliothek) beschrieben. Zum Thema Iteratoren werden zahlreiche Ausführungen im Kapitel 19 gemacht. In den Unterabschnitten 19.1 bis einschließlich 19.3 beschäftigt sich Stroustrup mit den Iteratorkategorien, wobei er ebenfalls Beispiele für ihre Anwendung bringt. In Unterabschnitt 19.3 wird dann eine eigene Iteratorklasse entwickelt.
KOENIG/MOO: „INTENSIVKURS C++“ Der Themenkomplex sequenzieller Container wird mit den beiden Typen vector und list anhand von Beispielen in den Kapiteln 3 und 5 sehr ausführlich diskutiert. Ein hier aufgeführter Performancevergleich macht die Unterschiede zwischen vector und list beim Löschen von Elementen in der Mitte der Sequenz deutlich. In Kapitel 8, Unterabschnitt 8.2 werden die Iteratorkategorien im Rahmen der Beschreibung der Algorithmen der Standardbibliothek diskutiert. In den darauf folgenden Unterabschnitten befinden sich noch einige weitere interessante Informationen über Iteratoren und ihre Anwendung.
KUHLINS/SCHADER: „DIE C++-STANDARDBIBLIOTHEK“ Die sequenziellen Container der Standardbibliothek werden in Kapitel 5 diskutiert.
7
SEQUENZIELLE CONTAINER UND ITERATOREN
149
k
8 STRINGS Wir wollen uns in diesem Kapitel mit dem von der C++-Standardbibliothek angebotenen Datentyp string (definiert im Standardheader <string>) beschäftigen. Er dient der Speicherung und Verarbeitung von Zeichenketten beliebiger Länge. Wer dieses Kapitel durchgearbeitet hat, sollte den Datentyp string effizient in eigenen Projekten einsetzen können.
8.1
KONSTRUKTION UND ZUWEISUNGSOPERATOREN VON STRINGS
Wird ein string-Objekt ohne Angabe von Argumenten erzeugt, so enthält dieses stets eine leere Zeichenkette der Länge 0. Alternativ dazu kann bei der Konstruktion eines strings ein Stringliteral bzw. ein nullterminierter C-String (z.B. in Form eines char const*) angegeben werden, dessen Zeichen dann in das entstehende string-Objekt kopiert werden: 1 2 3 4 5
string s; string s1 = "Hallo"; string s2("Hallo"); char *h = "Hallo"; string s3 = h;
// leerer string // Stringliteral // Stringliteral (Konstruktorsyntax) // char* (nullterminierter C-String)
Weiterhin ist es möglich, bei der Konstruktion ein Zeichen und eine Anzahl anzugeben. Im entstehenden string-Objekt ist das angegebene Zeichen dann entsprechend oft vorhanden: 1
string s4(42, ’*’);
// 42 Kopien von ’*’
Weitere Konstruktoren erlauben die Kopie eines kompletten bzw. eines Teilstücks aus einem string-Objekt: 1 2 3
string s5(s3); string s6(s5,1,3);
// Kopie von s3 ("Hallo") // Kopie von 3 Zeichen ab Pos. 1 aus s5 // ("all")
Zuweisungsoperatoren existieren für die Zuweisung eines anderen string-Objekts, eines einzelnen Zeichens, sowie eines nullterminierten C-Strings: 1 2
8
string s7; s7 = ’a’;
STRINGS
// Konstruktion eines leeren strings // Zuweisung eines einzelnen Zeichens
151
3 4
s7 = "Welt"; s7 = s5;
// Zuweisung eines C-Strings // Zuweisung eines anderen string-Objekts
Darüber hinaus gibt es die Memberfunktion assign, die die Funktionsweise aller Konstruktoren in Form von Zuweisungen anbietet. Die Version von assign zur Zuweisung eines Teilstücks aus einem string-Objekt sei beispielhaft dargestellt: 1 2 3
8.2
string s8; s8.assign(s5, 1, 3);
// Konstruktion eines leeren strings // Zuweisung von 3 Zeichen ab der // Position 1 in s5 ("all")
ZUGRIFF AUF DIE EINZELNEN ZEICHEN DES STRINGS
Wir können die einzelnen Zeichen von string-Variablen betrachten und ändern, indem wir entweder indiziert auf die Variable zugreifen, oder Iteratoren verwenden. Der Datentyp string definiert den Index-Operator []: 1 2
char c = s8[1]; // c == ’l’ s8[2] = ’p’; // s8 == "alp"
Darüberhinaus gibt es in Analogie zu den Containern der Standardbibliothek die Memberfunktionen begin und end, die jeweils einen Iterator auf das erste Zeichen, bzw. hinter das letzte Zeichen des strings zurückliefern. Der zurückgelieferte Iterator gehört zur Kategorie der Random-Access-Iteratoren. Wir können dies verwenden, um eine Funktion zur Erzeugung einer gesperrten Schreibweise („g e s p e r r t“) aus einem beliebigen string zu implementieren: 1 string sperren(string const& s) 2 { 3 string erg; 4 for(string::const_iterator it = s.begin(); it != s.end(); ++it){ 5 erg += *it; 6 erg += ’ ’; 7 } 8 9 return erg; 10 }
Wie in dieser Funktion zu sehen ist, werden auch die Typdefinitionen iterator und const_iterator in string angeboten. Zum umgekehrten Durchlaufen von strings wurden in Analogie zu den StandardContainern die Memberfunktionen rbegin und rend gemeinsam mit den Typdefinitionen reverse_iterator und const_reverse_iterator definiert. Dies ermöglicht uns, eine Funktion zur Umkehrung von strings zu entwickeln: 1 string umkehren(string const& s) 2 { 3 string erg;
152
ZUGRIFF AUF DIE EINZELNEN ZEICHEN DES STRINGS
8
4 5 6 7 8 }
for(string::const_reverse_iterator it = s.rbegin(); it != s.rend(); ++it) erg += *it; return erg;
8.3
ERMITTLUNG DER LÄNGE VON
strings
Mit Hilfe der Memberfunktion size kann die Länge eines strings ermittelt werden. Das Ergebnis dieser Funktion hat den Typ string::size_type. Soll lediglich geprüft werden, ob eine string-Variable leer ist, so kann ebenfalls die Memberfunktion empty benutzt werden. Wir wollen die für C-Strings (nullterminierte char const*) in der Standardbibliothek definierte Funktion strlen für die Benutzung mit Variablen des Datentyps string überladen: 1 string::size_type strlen(string const& s) 2 { 3 return s.size(); 4 }
8.4 8.4.1
ANHÄNGEN UND EINFÜGEN ANHÄNGEN
Zum Anhängen an einen string gibt es verschiedene Funktionen; die einfachste ist der operator+=, der drei verschiedene Datentypen akzeptiert: einen anderen string, einen char const* (nullterminierter C-String) und ein Zeichen des Datentyps char. Analog zu den Standard-Containern ist die Memberfunktion push_back implementiert, die einen char als Parameter nimmt und wie gewohnt an das Ende der Sequenz anfügt. Schließlich existieren eine Reihe von Memberfunktionen mit dem Namen append. Diese liefern jeweils eine Referenz auf das entsprechende string-Objekt zurück und akzeptieren folgende Argumente: append(string const& s):
hängt den gesamten string s ans Ende an,
append(string const& s, size_type i, size_type n):
hängt n Zeichen ab Position i aus dem string s an,
append(const char* p, size_type n):
hängt n Zeichen aus dem C-String p an,
append(const char* p):
hängt den nullterminierten C-String p an,
append(size_type n, char c):
hängt n Kopien des Zeichens c an,
append(InIt it1, InIt it2):
hängt die Zeichen aus der Sequenz [it1, it2) an, wobei InIt die Bedingungen eines Inputiterators erfüllen muss.
8
STRINGS
153
Beispielhaft könnten wir folgenden Code schreiben: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#include #include <string> using std::string; using std::cout; using std::endl; int main() { string B = "Beispiel"; string C = "Halskette"; cout B += cout cout
<< B << endl; "zeichen"; << B << endl; << B.append(C,4,5) << endl;
}
Dabei entsteht folgende Ausgabe: [mme@endeavour Source]: ./string_append Beispiel Beispielzeichen Beispielzeichenkette
8.4.2
EINFÜGEN
Aus den gleichen Gründen, die wir bei vector und deque angeführt haben, ist das Einfügen an einer beliebigen Stelle innerhalb eines strings eine relativ zeitaufwändige Angelegenheit. Dennoch wird die Funktion insert angeboten, von der es die folgenden 5 Varianten gibt, die alle eine Referenz auf den aktuellen string zurückliefern: insert(size_type i, string const& s):
fügt den string s vor Position i ein,
insert(size_type i, string const& s, size_type j, size_type n):
fügt n Zeichen ab Position j aus s vor Position i ein,
insert(size_type i, char const* p, size_type n):
fügt n Zeichen aus dem C-String p vor Position i ein,
insert(size_type i, char const* p):
fügt den nullterminierten C-String p vor Position i ein,
insert(size_type i, size_type n, char c):
fügt n Kopien des Zeichens c vor Position i ein.
154
ANHÄNGEN UND EINFÜGEN
8
Weiterhin gibt es noch 3 Versionen von insert, die Iteratoren akzeptieren: It insert(It it, char c):
fügt das Zeichen c vor der Iteratorposition it ein; zurückgeliefert wird ein Iterator auf das eingefügte Zeichen,
void insert(It it, size_type n, char c):
fügt n Kopien des Zeichens c vor der Iteratorposition it ein,
void insert(It it, InIt it1, InIt it2):
fügt die Zeichen aus der Sequenz [it1, it2) vor der Iteratorposition it ein.
Erneut wollen wir dies anhand eines Beispieles demonstrieren: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#include #include <string> using std::string; using std::cout; using std::endl; int main() { string B = "Beispiel"; string D = "kette"; cout << D << endl; D.insert(D.begin(), B.begin(), B.end()); cout << D << endl; cout << D.insert(B.size(), "zeichen") << endl; }
Die dabei entstehende Ausgabe muss folgendermaßen aussehen: [mme@endeavour Source]: ./string_insert kette Beispielkette Beispielzeichenkette
8.4.3
ZUSAMMENFÜGEN VON STRINGS
Das Zusammenfügen von strings ist eine spezielle Form des Anhängens und wird mit Hilfe des operator+ implementiert. Dieser kann neben einem string als zweites Argument (sowohl linker- als auch rechterseits) einen anderen string, einen nullterminierten C-String oder ein einzelnes Zeichen (char) akzeptieren. Jede dieser Versionen des operator+ liefert einen string zurück, so dass sich die Operation beliebig verketten lässt: 1 2 3 4
8
string S = "zeichen"; cout << S + "kette" << endl; cout << "Beispiel" + S + "kette" << endl; cout << "Beispiel" + S + "kette" + ’n’ << endl;
STRINGS
155
8.5
SUCHEN UND ERSETZEN
Eine ganze Reihe von Memberfunktionen des string-Datentyps beschäftigen sich mit dem Suchen in strings und dem Ersetzen von Zeichen und Zeichensequenzen.
8.5.1
SUCHEN
Jede der folgenden Memberfunktionen wird in vier Varianten angeboten: size_type func(string const& s, size_type i) const:
Das erste Argument ist der zu suchende string, das zweite Argument gibt die Position an, ab der der string, auf dem die Memberfunktion aufgerufen wird, durchsucht werden soll. Der Parameter i ist optional. Wird er nicht angegeben, so wird – abhängig von der tatsächlichen Funktion – der Index des ersten oder letzten Zeichens des strings eingesetzt.
size_type func(char const* p, size_type i, size_type n) const:
Das erste Argument gibt einen nullterminierten CString an, das zweite die Position ab der der string, auf dem die Memberfunktion aufgerufen wird, durchsucht werden soll, und das dritte Argument gibt die Anzahl von Zeichen an, die gesucht werden sollen.
size_type func(char const* p, size_type i) const:
Das erste Argument enthält einen nullterminierten C-String, das zweite die Position in dem string, auf dem die Memberfunktion aufgerufen wird, an der die Suche beginnt. Der Parameter i ist optional. Wird er nicht angegeben, so wird – abhängig von der tatsächlichen Funktion – der Index des ersten oder letzten Zeichens des strings eingesetzt.
size_type func(char c, size_type i) const:
Das erste Argument enthält das zu suchende Zeichen, das zweite Argument die Position in dem string, auf dem die Memberfunktion aufgerufen wird, an der die Suche beginnt. Der Parameter i ist optional. Wird er nicht angegeben, so wird – abhängig von der tatsächlichen Funktion – der Index des ersten oder letzten Zeichens des strings eingesetzt.
Alle Funktionen liefern als Rückgabewert die gefundene Position im string als Index zurück. Im Misserfolgsfalle wird string::npos zurückgeliefert; ein spezieller Bottomwert für einen nicht im string erlaubten Index. Wird dieser string::npos als Index in den string verwendet, wird von der entsprechenden Memberfunktion eine range_error-Exception erzeugt.
156
SUCHEN UND ERSETZEN
8
Die Namen der Suchfunktionen sind: find:
normale Suche vom Beginn des strings an,
rfind:
Suche vom Ende des strings an,
find_first_of:
Suche nach dem ersten Vorkommen des übergebenen Zeichens, bzw. eines der in der string- bzw. C-String-Variable übergebenen Zeichen,
find_last_of:
analog zu find_first_of, jedoch vom Ende her beginnend,
find_first_not_of:
Suche nach dem ersten Zeichen ungleich dem übergebenen, bzw. den in der string- bzw. C-String-Variablen übergebenen Zeichen,
find_last_not_of:
analog zu find_first_not_of, jedoch vom Ende her beginnend.
Anhand eines Beispiels wollen wir einige dieser Funktionen testen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
#include <string> #include using std::string; using std::cout; using std::endl; int main() { string B = "Beispielzeichenkette"; cout cout cout cout cout cout cout cout
<< << << << << << << <<
B.find("spiel") << endl; B.find("ei", 3, 2) << endl; B.rfind("ei") << endl; B.rfind(’t’) << endl; B.find_first_of("ei") << endl; B.find_last_of("ei") << endl; B.find_first_not_of("spielBall") << endl; B.find_last_not_of("spielBall") << endl;
// // // // // // // //
3 9 9 18 1 19 8 18
}
Dieses sehr einfach gestaltete Programm gibt die Indizes aus, an die als Ergebnis der entsprechenden Funktionsaufrufe entstehen. Diese sind auch in den Kommentaren zum Vergleich angegeben.
8.5.2
ERSETZEN
Für das Ersetzen in strings gibt es grundsätzlich zwei Funktionstypen: der eine Typ arbeitet mit Iteratoren, der andere mit Indexpositionen. Im ersten Falle stellen die beiden ersten Parameter zu diesen Funktionen jeweils einen Iterator auf den Anfang bzw. hinter das Ende der Sequenz dar. Im zweiten Fall ist der erste Parameter der Index, an dem die Ersetzung beginnen soll, und der zweite Parameter gibt die Anzahl von Zeichen an, die ersetzt werden sollen. Alle weiteren Parameter werden im Folgenden beschrieben: 8
STRINGS
157
replace(A1, A2, string const& s):
ersetzt die entsprechenden Zeichen mit dem string s,
replace(A1, A2, char const* p, size_type n):
ersetzt die entsprechenden Zeichen mit n Zeichen aus dem C-String p,
replace(A1, A2, char const* p):
ersetzt die entsprechenden Zeichen durch den nullterminierten C-String p,
replace(A1, A2, size_type n, char c):
ersetzt die entsprechenden Zeichen durch n Kopien des Zeichens c.
Neben diesen 8 Funktionen gibt es noch zwei weitere, die eine Sequenz von Zeichen durch eine andere Sequenz von Zeichen (zum Beispiel1 aus einer anderen stringVariable). Diese beiden Funktionen unterscheiden sich natürlicherweise in der Anzahl der Argumente, weswegen beide Versionen hier getrennt aufgeführt werden: replace(size_type i, size_type n, string const& s, size_type i2, size_type n2):
ersetzt n Zeichen ab Index i durch n2 Zeichen ab Index i2 aus dem string s,
replace(iterator i, iterator i2, InIt j, InIt j2):
ersetzt die Zeichen in der Sequenz [i,i2) durch die Zeichen in der Sequenz [j,j2). Wie die Typnamen andeuten, müssen die zweiten Iteratoren den Bedingungen eines Inputiterators genügen. (Die ersten beiden Iteratoren sind die von string definierten Random-Access-Iteratoren. Durch diese einfache Forderung ist es mit dieser Methode auch möglich, die zweite Sequenz aus einem vector oder einer list zu verwenden.)
Alle replace-Memberfunktionen liefern eine string& zurück. Wir können die Funktionalität erneut in einem kleinen Programm testen: 1 int main() 2 { 3 string B = "Beispielzeichenkette"; 4 string C = "Anderes Beispiel"; 5 6 cout << B << endl; 7 cout << B.replace(B.find("zeichen"), 7, "buchstaben") << endl; 8 cout << B.replace(B.begin(), B.end(), C) << endl; 9 }
Hierbei werden zwei der insgesamt 10 Funktionen (5 Varianten) getestet: replace(index, anzahl, C-String) und replace(iterator1, iterator2, C++-string). Das Ergebnis sieht folgendermaßen aus: [mme@endeavour Source]: ./string_replace Beispielzeichenkette Beispielbuchstabenkette Anderes Beispiel 1
... aber zumindest bei Verwendung der Iterator-Variante keineswegs zwingend ...
158
SUCHEN UND ERSETZEN
8
8.6
LÖSCHEN VON ZEICHEN
Mittels der Memberfunktion erase können Teile des strings gelöscht werden. Es gibt drei Versionen von erase: 2
string& erase(size_type i = 0, size_type n = npos):
Diese Funktion löscht n Zeichen ab Position i. Beide Argumente sind optional. Werden keine Argumente angegeben, so wird der gesamte string gelöscht (analog zur Memberfunktion clear der Standard-Container), wird nur ein Argument angegeben, so wird dies als die Anfangsposition interpretiert, ab der der Rest des strings gelöscht wird2. Die Funktion liefert eine Referenz auf den aktuellen string zurück.
It erase(It i):
Diese Funktion löscht das Zeichen, auf das der Iterator i verweist. Der nächste gültige Iterator wird zurückgeliefert.
It erase(It i1, It i2):
Diese Funktion löscht alle Zeichen im Bereich [i1, i2) und liefert das zweite Argument als Ergebnis zurück.
Das Zeitverhalten entspricht beim Löschen in der Praxis dem von vector bzw. deque. Erneut können wir die Funktionsweise mit Hilfe eines Programmes testen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
2
8
#include #include <string> using std::string; using std::cout; using std::endl; int main() { string B = "Beispielzeichenkette"; cout << "[" << B << "]" << endl; B.erase(B.begin(), B.begin() + 8); cout << "[" << B << "]" << endl; B.erase(0,1); B.erase(B.begin() + 6, B.end()); cout << "[" << B << "]" << endl; B.erase(); cout << "[" << B << "]" << endl; }
Diese Interpretation ergibt sich aus der Auswertung von Defaultargumenten in C++, die stets von rechts nach links erfolgt; das bedeutet, die weggelassenen Argumente sind stets die am weitesten rechts stehenden.
STRINGS
159
Dies erzeugt die folgende Ausgabe: [mme@endeavour Source]: ./string_erase [Beispielzeichenkette] [zeichenkette] [eichen] []
8.7
ERZEUGUNG VON SUBSTRINGS
Mit Hilfe der Memberfunktion string substr(size_type i = 0, size_type n = npos) const;
kann man Substrings (Teilzeichenketten) erzeugen. Die Parameter sind die Position i im string, an der der Substring beginnen soll, und die Anzahl von Zeichen im Substring n. Werden beide Parameter weggelassen, so wird der gesamte string als Kopie zurückgeliefert. Ein Beispiel: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
#include #include <string> using std::string; using std::cout; using std::endl; int main() { string B = "Beispielzeichenkette"; cout cout cout cout
<< << << <<
B.substr() << endl; B.substr(0,8) << endl; B.substr(8,7) << endl; B.substr(15) << endl;
}
Die Ergebnisse gestalten sich folgendermaßen: [mme@endeavour Source]: ./string_substr Beispielzeichenkette Beispiel zeichen kette
8.8
UMWANDLUNG IN C-STRINGS
Die Memberfunktion c_str ermöglicht die Umwandlung eines strings in einen nullterminierten C-String, der die gleichen Zeichen enthält. Zurückgeliefert wird ein char const*, der sich unter direkter Kontrolle des string-Objektes befindet. Soll die160
ERZEUGUNG VON SUBSTRINGS
8
ser C-String längere Zeit gespeichert werden, so muss der Programmierer unmittelbar nach dem Erhalt den C-String in selbst verwalteten Speicher kopieren. Anderenfalls könnte sich der C-String ändern, wenn sich das zugrunde liegende stringObjekt ändert. Im nachfolgenden Beispiel verändern wir das string-Objekt zwischen zwei aufeinander folgenden Ausgaben des C-Strings, indem wir den Anfang des strings löschen. Wie wir sehen, spiegelt sich die Änderung in der zweiten Ausgabe von C, unserem C-String, wider: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
#include #include <string> using std::string; using std::cout; using std::endl; int main() { string B = "Beispielzeichenkette"; char const* C = B.c_str(); cout << "[" << C << "]" << endl; B.erase(B.begin(), B.begin() + 8); cout << "[" << C << "]" << endl; }
Die Ausgabe: [mme@endeavour Source]: ./string_cstring [Beispielzeichenkette] [zeichenkette]
Man beachte, dass dieses Verhalten vom Standard nicht explizit gefordert wird; genauso gut könnte es Implementierungen von string geben, die dieses Verhalten nicht zeigen. Neben der Funktion c_str gibt es die Memberfunktion data. Diese liefert ebenfalls einen char const* zurück; wobei hier nicht garantiert ist, dass der zurückgelieferte Speicherbereich nullterminiert ist. (Man kann ihn natürlich trotzdem sinnvoll nutzen, indem man sich die Länge des string-Objektes vermerkt; data ist dennoch keine allzu häufig benutzte Funktion.)
8
STRINGS
161
8.9
ZUSAMMENFASSUNG
In diesem Kapitel wurde die string-Klasse der C++-Standardbibliothek erläutert. Diese Klasse kann umfassend zur Arbeit mit Zeichenketten verwendet werden. Dazu bietet sie eine große Anzahl von Memberfunktionen an, die oben beschrieben sind. Darüberhinaus kann die Klasse string als sequenzieller Container von char-Daten angesehen werden, die mit Hilfe von Iteratoren und den Algorithmen der Standardbibliothek (s. Kapitel 10) bearbeitet werden können.
8.10 ÜBUNGEN 1. Implementieren, compilieren und testen Sie die Beispiele in diesem Kapitel! 2. Führen Sie eine eigene string-Implementierung mit Hilfe von vector als
Datenspeicher durch. Implementieren Sie insbesondere alle hier dargestellten Memberfunktionen! 3. Entwickeln Sie eine Klasse für ein Textfeld, das zeilenweise Text enthält. Entwi-
ckeln Sie Funktionen, die das Einfügen von Zeilen, sowie das Zusammenfügen von getrennten Zeilen ermöglicht. Entwickeln Sie Ein- und Ausgabeoperatoren! 4. Schreiben Sie Funktionen zur horizontalen und vertikalen Verkettung von Text-
feldern. Schreiben Sie eine Funktion zur Umrahmung von Textfeldern. Das Ergebnis der Funktionen soll erneut ein Textfeld sein, in dem die entsprechenden Umformungen durchgeführt wurden. 5. Entwickeln Sie ein Programm namens wc (Wordcount), das eine ASCII-Datei zei-
lenweise einliest und ausgibt, wie viele Zeichen, Worte und Zeilen in der Datei vorhanden sind. 6. Entwickeln Sie eine eigene Funktion getline analog zur entsprechenden Funktion
der Standardbibliothek: 1
istream& getline(istream& in, string& s);
Verwenden Sie diese Funktion in den Übungsprogrammen grep aus Kapitel 6 und wc aus diesem Kapitel.
162
ÜBUNGEN
8
8.11 LITERATUREMPFEHLUNGEN STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Für die Betrachtung der string-Klasse der C++-Standardbibliothek hat Stroustrup Kapitel 20 reserviert. Neben den auch hier angeschnittenen essenziellen Memberfunktionen des Datentyps, geht er auch auf die von C geerbten Bibliotheksfunktionen zum Umgehen mit nullterminierten C-Strings ein.
KOENIG/MOO: „INTENSIVKURS C++“ Bereits in Kapitel 1 wird hier auf die string-Klasse und ihre Benutzung eingegangen, wobei der Fokus hier auf die Benutzung von string als Werkzeug gelegt wird. Es werden lediglich einige Memberfunktionen vorgestellt, die allerdings auch gleichzeitig die wichtigsten Arbeitsmöglichkeiten darstellen.
KUHLINS/SCHADER: „DIE C++-STANDARDBIBLIOTHEK“ Eine vollständige Beschreibung der Arbeitsmöglichkeiten mit dem Datentyp string befindet sich in Kapitel 11.
8
STRINGS
163
9 ASSOZIATIVE CONTAINER Assoziative Container gehören neben den in den vorangegangenen Kapiteln behandelten sequenziellen Containern zu den Konzepten der C++-Standardbibliothek für das Speichern großer Datenmengen. Im Unterschied zu sequenziellen Containern verwalten assoziative Container ihre Daten durch Einhaltung einer internen Reihenfolge zwischen den Elementen. Dies führt dazu, dass ablauftechnisch nacheinander eingefügte Elemente nicht zwangsläufig in der gleichen Reihenfolge im Container gespeichert werden. Wer dieses Kapitel durchgearbeitet hat, ist informiert über:
Î die verschiedenen Arten assoziativer Container und ihre Einsatzmöglichkeiten, Î die Benutzung der verschiedenen assoziativen Container, Î Einzelheiten zur Verwendung von Iteratoren im Zusammenhang mit assoziativen Containern.
9.1
DIE STANDARDCONTAINER
map
UND
multimap
Der Container map stellt einen Datenspeicher für (Schlüssel, Wert)-Paare zur Verfügung, wobei jeder Schlüssel nur einmal vorkommen darf. Er ist im Standardheader <map> definiert. Der Container multimap (definiert im Header <multimap>) entspricht weitestgehend dem Container map, mit dem Unterschied, dass multimap das Vorhandensein mehrerer gleicher Schlüsselwerte erlaubt. Alle im Folgenden für den Container map dargestellten Funktionen existieren auch für multimap, sofern an entsprechender Stelle nichts anderes vermerkt ist.
9.1.1
ANLEGEN EINER
map
Jeder Eintrag in der map besteht aus einem Schlüssel und dem zugehörigen Wert. Beim Anlegen einer map müssen daher zwei Datentypen angegeben werden, zuerst der Schlüsseldatentyp und dann der Wertdatentyp: 1
9
map fehler; // Schlüssel: int, Wert: string
ASSOZIATIVE CONTAINER
165
9.1.2
EINFÜGEN VON ELEMENTEN MIT HILFE DES INDEXOPERATORS
Das Einfügen von Elementen in die map kann mit Hilfe des Index-Operators erfolgen. Als Index wird dabei der Schlüsselwert angegeben: 1 2 3 4
fehler[5] = "Keine weiteren Datensätze vorhanden."; fehler[2] = "Kein Speicherplatz mehr verfügbar."; fehler[1] = "Fehler beim öffnen der Datei."; fehler[9999] = "Allgemeiner Fehler!";
Nach diesen Operationen enthält die map 4 Elemente.
9.1.3
BENUTZUNG VON ITERATOREN UND DATENTYP
pair
Man kann sich auch generell mit Hilfe von Iteratoren durch die map bewegen. Dabei ist es allerdings wichtig zu beachten, dass die Elemente in der map in der Form des Datentyps pair gespeichert sind. Der Datentyp pair speichert jeweils ein Paar aus Schlüsseldatum und Wertdatum. Ein Objekt des Datentyps pair verfügt über zwei Datenmember first und second, die jeweils Schlüssel und Wert beinhalten. Wenn wir also durch eine map iterieren, müssen wir folgenden Code schreiben: 1 2 3 4
for(map::const_iterator it = fehler.begin(); it != fehler.end(); ++it) cout << "Schlüssel: [" << it->first << "] Wert: [" << it->second << "]" << endl;
Wenn wir diese Schleife mit unserer oben aufgeführten map ausführen, erkennen wir, dass die Elemente intern anhand ihrer Schlüsselwerte geordnet sind.
9.1.4
ZUGRIFF AUF WERTE MIT HILFE DES INDEXOPERATORS
Will man auf den Wert für einen bestimmten Schlüssel zugreifen, so kann man ebenfalls die Indexoperatoren verwenden: 1
cerr << fehler[9999] << endl;
Wenn man dabei jedoch einen Schlüsselwert angibt, der noch nicht in der map vorhanden ist, so wird dadurch ein neues Element mit diesem Schlüssel angelegt, das einen defaultinitialisierten Wert enthält. Wenn man sich also unsicher ist, ob ggf. ein noch unbekannter Schlüssel beim Zugriff auf die map verwendet wird, sollte man lieber die Memberfunktion find benutzen: 1 2 3
map::const_iterator it = fehler.find(9999); if(it != fehler.end()) cout << *it << endl;
Bei map liefert find stets entweder die Position des Elementes zurück, oder den Wert der Memberfunktion end, falls der Schlüssel nicht gefunden wurde. Bei der multimap wird in Anwesenheit mehrerer Elemente mit dem gleichen Schlüssel stets ein Iterator auf das erste solche Element zurückgeliefert. Wir können in einer
166
DIE STANDARDCONTAINER MAP UND MULTIMAP
9
multimap mit Hilfe dreier Memberfunktionen auch die Bereiche finden, in denen Ele-
mente mit gleichem Schlüssel vorkommen: 1 2 3
It lower_bound(Key const& k); It upper_bound(Key const& k); pair equal_range(Key const& k);
Die Funktion lower_bound sucht dabei nach dem ersten Element mit dem Schlüssel k. Die Funktion upper_bound sucht nach dem ersten Element mit einem Schlüsselwert größer als k. Damit liefert sie gleichzeitig einen Endeiterator für den in lower_bound zurückgelieferten Iterator, denn das erste Element mit einem Schlüssel größer als k liegt genau ein Element hinter dem letzten Element mit Schlüssel k. Die Funktion equal_range vereinigt daher beide Funktionalitäten und liefert als Ergebnis ein pair, dessen Member first das Ergebnis von lower_bound, und dessen Member second das Ergebnis von upper_bound widerspiegelt.
9.1.5
EINFÜGEN UND LÖSCHEN VON ELEMENTEN
Analog zu anderen Containerdatentypen, können auch bei map und multimap insertMemberfunktionen zum Einfügen von Elementen benutzt werden. Da ein Element aus einem pair mit Schlüssel und Wert besteht, muss in der Regel ein solches pair als Argument für insert angegeben werden. Dieses kann direkt erstellt werden: 1
pair px(111, "Fehler bei der Datenauswertung.");
Alternativ kann man die Funktion make_pair verwenden, wie im Beispiel zu insert weiter unten. Es gibt drei Varianten der Memberfunktion insert: 1 2 3
pair insert(Pair const& elem); iterator insert(iterator pos, Pair const& elem); void insert(InIt start, InIt ende);
Die erste Version fügt das als pair übergebene Element elem an der durch die interne Ordnung vorgegebenen Stelle in die map in. Bei der zweiten Version können wir mit pos einen Hinweis geben, an welcher Stelle das Element vermutlich in die map eingefügt werden wird. Bei größeren Datensammlungen können wir damit die Effizienz der Operation steigern; allerdings wird pos tatsächlich nur als netter Hinweis verwendet. Die dritte Version erlaubt das Einfügen einer ganzen Reihe von Elementen, die aus einer anderen Sequenz stammen (z.B. einer anderen map). InIt ist dabei das Kennzeichen für einen Inputiterator. Man beachte, dass die Elemente der Sequenz [start,ende) von einem für die map korrekten pair-Typ sein müssen. Die drei Versionen haben verschiedene Rückgabewerte: Die erste Version liefert ein pair zurück, das als first-Member einen Iterator auf die Position enthält, an der sich das Element mit dem entsprechenden Schlüssel in der map befindet. Der Member second dieses pairs trägt den Wahrheitswert true, wenn das Element eingefügt wurde; false, wenn in der map bereits ein Element mit diesem Schlüssel vorhanden war. In diesem Fall wurde das übergebene Element nicht eingefügt. (Im Fall einer 9
ASSOZIATIVE CONTAINER
167
multimap wird das Element stets eingefügt, da dort mehrere Elemente mit dem glei-
chen Schlüsselwert erlaubt sind.) Die zweite Version liefert einen Iterator zurück, der auf die Position zeigt, an der das Element eingefügt wurde. Die dritte Version hat keine Rückgabedaten. Wir können daher mit folgendem Code ein Element einfügen: 1 2 3 4 5 6 7
9.2
pair<map::iterator, bool> erg = fehler.insert(make_pair(112, "lp on fire")); if(erg.second) { // Fehlermeldung 112 wurde eingefügt. } else { // Fehlermeldung 112 war schon vorhanden. }
DIE STANDARDCONTAINER
set
UND
multiset
Im Unterschied zu map und multimap speichern set (definiert in <set>) und multiset (definiert in <multiset>) lediglich Schlüssel. Das heißt, der gespeicherte Wert selbst wird als Schlüssel betrachtet. Abgesehen von dem für set und multiset nicht definierten Indexoperator gelten alle Betrachtungen für map und multimap auch für diese Datentypen entsprechend.
9.3
ZUSAMMENFASSUNG
Neben sequenziellen Containern bietet die C++-Standardbibliothek assoziative Container, die eine interne Ordnung der Elemente durchsetzen. Grundsätzlich existieren die beiden assoziativen Containerarten map und set, wobei die erstere eine Zuordnung von Schlüsseln zu Werten und die zweite eine Speicherung von Werte in einer geordneten Folge erlauben. Für beide Containerarten gibt es multi-Varianten, die das Vorhandensein mehrerer gleicher Schlüssel bzw. gleicher Werte unterstützen. Im Rahmen der Diskussion von map und multimap wurde die Klasse pair eingeführt, die die Zuordnung zweier Datenelemente zueinander erlaubt.
168
STANDARDCONTAINER SET UND MULTISET
9
9.4
ÜBUNGEN
1. Implementieren, compilieren und testen Sie die Beispiele in diesem Kapitel! 2. Ändern Sie das Übungsprogramm aus Kapitel 1 so ab, dass maps für die Verwal-
tung von Münzsorten und Banknoten verwendet werden! 3. Schreiben Sie ein Programm, das Daten von der Standardeingabe einliest und be-
stimmt, wie oft jedes einzelne Wort in der Eingabe vorkommt. Sie können dafür eine map<string,int> verwenden.
9.5
LITERATUREMPFEHLUNGEN
STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Im Rahmen seiner Betrachtung der C++-Standardcontainer geht Stroustrup im Abschnitt 17.4 auf assoziative Container ein. Er gibt dabei einen umfangreichen Überblick über die Memberfunktionen und Operatoren, sowie den Datentyp pair.
KOENIG/MOO: „INTENSIVKURS C++“ Für assoziative Container haben Koenig und Moo Kapitel 7 vorgesehen, wobei mehrere Beispiele zur Verwendung von map angegeben werden. Ein komplexes Beispiel (die Generierung von natürlichsprachlichen Sätzen) und Betrachtungen zur Performance assoziativer Container runden das Kapitel ab. Auf multimap, set und multiset wird nicht eingegangen.
KUHLINS/SCHADER: „DIE C++-STANDARDBIBLIOTHEK“ Im Kapitel 7 gehen Kuhlins und Schader ausführlich auf die assoziativen Container ein. Sie betrachten in einem eigenen Abschnitt auch die Modifikation von Elementen in set und multiset neben den üblichen Darstellungen der Memberfunktionen aller assoziativen Container.
9
ASSOZIATIVE CONTAINER
169
10 ALGORITHMEN Die C++-Standardbibliothek bietet Funktionen an, die häufig benötigte Algorithmen zur Verfügung stellen. Mit Hilfe von Iteratoren, die wir in Abschnitt 7.2 beschrieben haben, wurden diese Algorithmen generisch gestaltet, so dass sie für Sequenzen nahezu beliebiger Datentypen zur Verfügung stehen, sofern sich der Algorithmus auf derartige Daten anwenden lässt. Dieses Kapitel soll die grundsätzlichen Charakteristiken wichtiger angebotener Algorithmen darstellen; auf detailliertere Betrachtungen muss aus Platzgründen allerdings verzichtet werden.
10.1 EINTEILUNG UND FORMULIERUNG DER ALGORITHMEN Die Einteilung der Algorithmen erfolgt nach dem Effekt, den sie auf die Sequenzen haben, auf die sie angewendet werden. Entsprechend gibt es nichtmodifizierende und modifizierende Algorithmen. Weiterhin gibt es Sortieralgorithmen, Mengenalgorithmen, Heapalgorithmen, Permutationen und die Minimum- und Maximumsuche. Alle Algorithmen akzeptieren Iteratoren zur Spezifikation der Sequenz ihrer Arbeitsdaten. Diese Iteratoren müssen bestimmten Bedingungen genügen, welche anhand der in Kapitel 7.4.2 festgelegten Iteratorkategorien angegeben werden. Die Funktionsköpfe in den folgenden Abschnitten enthalten die in Kapitel 7.4.2 verwendeten Iteratorkurzbezeichnungen: InIt:
Inputiterator,
OutIt:
Outputiterator,
ForIt:
Forwarditerator,
BiIt:
Bidirektionaliterator und
RAIt:
Random-Access-Iterator.
Um die Funktionsköpfe möglichst übersichtlich anzugeben, wurde auf eine korrekte C++-Funktionsdeklarationssyntax verzichtet; insbesondere wurde die Template-
10
ALGORITHMEN
171
deklaration weggelassen. Folgende weitere Konventionen gelten für die Templateparameter: T:
meist der Datentyp der Elemente der Sequenz, bzw. ein Datentyp für den bestimmte Operationen mit den Elementen der Sequenz möglich sind,
Pred:
eine Funktion oder ein Funktionsobjekt (siehe Kapitel 10.4), das jeweils ein Element der Sequenz übergeben bekommt und einen Wahrheitswert zurückliefert,
BinPred:
eine Funktion oder ein Funktionsobjekt (siehe Kapitel 10.4), das jeweils ein Element der Sequenz und ein kontextabhängiges zweites Argument übergeben bekommt und einen Wahrheitswert zurückliefert,
difference_type:
ein Datentyp, der geeignet ist, um die Anzahl der Elemente in der bearbeiteten Sequenz zu betrachten; man kann hier im praktischen Einsatz den Typ Container::size_type verwenden, wobei Container der verwendete Containertyp (vector, list, ...) ist.
Alle genannten Algorithmen sind im Standardheader vereinbart, sofern nicht anders angegeben.
10.2 NICHTMODIFIZIERENDE ALGORITHMEN Nichtmodifizierende Algorithmen verändern den Inhalt der Sequenz, auf der sie arbeiten, nicht. Sie dienen vorrangig dem Suchen von Elementen mit bestimmten Charakteristika, bzw. statistischen Funktionen für die Sequenz.
10.2.1 1 2 3 4 5 6 7 8
find
InIt find(InIt start, InIt ende, T const& wert); InIt find_if(InIt start, InIt ende, Pred praed); ForIt find_first_of(ForIt start, ForIt ende, ForIt start2, ForIt ende2); ForIt find_first_of(ForIt start, ForIt ende, ForIt start2, ForIt ende2, BinPred binpraed); ForIt adjacent_find(ForIt start, ForIt ende); ForIt adjacent_find(ForIt start, ForIt ende, BinPred binpraed);
Die find-Familie von Algorithmen sucht in einer durch start und ende begrenzten Sequenz nach: dem ersten Element mit einem bestimmten wert, dem ersten Element, für das eine übergebene Funktion praed (von „Prädikat“) true zurückliefert, dem ersten Element, das einem Element aus der zweiten übergebenen Sequenz [start2,ende2) entspricht,
172
NICHTMODIFIZIERENDE ALGORITHMEN
10
dem ersten Element, für das die Funktion binpraed den Wahrheitswert true
zurückliefert, wenn sie der Reihe nach mit dem entsprechenden Element und allen Elementen der Sequenz [start2,ende2) aufgerufen wird, dem ersten Element, das seinem Nachfolgeelement gleicht, dem ersten Element, für das die Funktion binpraed den Wahrheitswert true
zurückliefert, wenn sie mit diesem Element und dessen Nachfolgeelement aufgerufen wird. Zurückgeliefert wird jeweils der Iterator auf das gefundene Element oder das zweite Argument (ende).
10.2.2
for_each
1 void for_each(InIt start, InIt ende, Pred praed);
Der Algorithmus for_each führt die Funktion praed mit jedem Element in [start, ende) als Argument aus.
10.2.3
count
1 difference_type count(InIt start, InIt ende, T const& wert); 2 difference_type count_if(InIt start, InIt ende, Pred praed);
Die Zählfunktionen ermitteln die Anzahl von Elementen in der Sequenz [start, ende), für die ein Vergleich mit wert erfolgreich ist, die Funktion praed true zurückliefert.
10.2.4
equal
1 bool equal(InIt start, InIt ende, InIt start2); 2 bool equal(InIt start, InIt ende, InIt start2, BinPred binpraed);
Die equal-Algorithmen liefern true zurück, wenn alle Elemente der Sequenz [start,ende) in [start2,start2+ende-start) in gleicher Reihenfolge vorkommen, beim Aufruf von binpraed kombiniert mit dem jeweils entsprechenden Element aus [start2,start2+ende-start) true zurückliefern.
Anderenfalls liefern beide Funktionen false. Der Programmierer muss selbst dafür sorgen, dass die zweite Sequenz (beginnend mit start2) mindestens genauso groß ist, wie die erste Sequenz [start,ende).
10
ALGORITHMEN
173
10.2.5
search
1 ForIt search(ForIt start, ForIt ende, ForIt start2, ForIt ende2); 2 ForIt search(ForIt start, ForIt ende, ForIt start2, ForIt ende2, 3 BinPred binpraed);
Die search-Algorithmen suchen nach dem Vorkommen der Subsequenz [start2,end2) innerhalb der Sequenz [start,ende). Die erste Version vergleicht die Elemente einfach miteinander, während die zweite Version jeweils binpraed mit zwei „passenden“ Elementen als Argumente aufruft. Wenn alle Elemente übereinstimmen, bzw. für alle Elemente true als Ergebnis von binpraed ermittelt wurde, so wird der Iterator auf das erste Element der Subsequenz innerhalb von [start,ende) zurückgeliefert. Wird die Subsequenz nicht gefunden, ist das Ergebnis das zweite Argument der Funktion, ende.
10.2.6 BEISPIEL Wir wollen einige der genannten Algorithmen in einem Programm testen: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
174
#include #include #include #include using using using using using using using using using
<string>
std::cout; std::cerr; std::endl; std::string; std::isspace; std::search; std::find_if; std::count; std::adjacent_find;
bool leerzeichen(char c) { return isspace(c); } bool kein_leerzeichen(char c) { return !leerzeichen(c); } int main() { string s = "Dies
ist ein\t\tTest.";
cout << s << endl;
NICHTMODIFIZIERENDE ALGORITHMEN
10
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 }
cout << "Anzahl von ’e’: " << count(s.begin(), s.end(), ’e’) << endl; string::iterator it = s.begin(); string::iterator it2; while(it != s.end()){ it = find_if(it, s.end(), kein_leerzeichen); it2 = find_if(it, s.end(), leerzeichen); if(it != it2){ cout << "Teilstring: [" << string(it, it2) << "]" << endl; it = it2; } } string t = "Test"; string::iterator it3 = search(s.begin(), s.end(), t.begin(), t.end()); cout << "Ergebnis von search: [" << string(it3, it3 + (t.end() - t.begin())) << "]" <<endl; string::iterator it4 = adjacent_find(s.begin(), s.end()); if(it4 != s.end()){ cout << "Benachbarte identische Zeichen an Position " << it4 - s.begin() << ": [" << *it4 << "]" << endl; } else{ cout << "Keine identischen benachbarten Zeichen." << endl; }
Dieses Programm führt verschiedene Untersuchungen an einem Beispielstring durch, den wir in der Variable s speichern: Mit Hilfe von count wird die Anzahl der vorkommenden Zeichen ’e’ festgestellt. In einer Schleife wird die Zeichenkette anhand von Whitespace (Leerzeichen,
Tabulatorzeichen, Newlinezeichen etc.) in Teilzeichenketten zerlegt. Diese werden ausgegeben. Zur Feststellung, inwiefern ein Zeichen ein Whitespace-Zeichen ist, bietet die C++-Bibliothek die Funktion isspace an. Diese ist für die Datentypen char und wchar_t (16-Bit-Zeichen, z.B. Unicode oder UTF-8) überladen, weshalb sie nicht direkt als Übergabeparameter für find_if verwendet werden kann. Stattdessen kapseln wir isspace in zwei eigenen Funktionen, deren Parametertyp eindeutig ist. Mit Hilfe von search wird versucht, die Zeichenkette Test aufzufinden. Gelingt
dies, so wird der entsprechende Teil des Originalstrings ausgegeben. Wie bereits beim Erzeugen der Teilzeichenketten, so wird auch hier zur Ausgabe jeweils ein 10
ALGORITHMEN
175
Teil des Originalstrings durch die Konstruktion eines neuen strings kopiert. Das Ende der zu kopierenden Sequenz wird hier durch die Länge der gesuchten Sequenz („Test“) bestimmt. Mittels adjacent_find werden die ersten benachbarten identischen Zeichen gefun-
den, sofern welche existieren. Betrachten wir das Ergebnis der Ausführung unseres Programms: [mme@endeavour Source]: ./algotest Dies ist ein Test. Anzahl von ’e’: 3 Teilstring: [Dies] Teilstring: [ist] Teilstring: [ein] Teilstring: [Test.] Ergebnis von search: [Test] Benachbarte identische Zeichen an Position 4: [ ]
10.3 MODIFIZIERENDE ALGORITHMEN Wie der Name dieser Kategorie von Algorithmen nahe legt, verändern sie mindestens eine der ihnen übergebenen Sequenzen.
10.3.1
copy
1 OutIt copy(InIt start, InIt ende, OutIt ziel); 2 BiIt copy_backward(BiIt start, BiIt ende, BiIt ziel);
Die copy-Algorithmen kopieren die Elemente der Sequenz [start,ende) nach ziel. Der Programmierer entscheidet dabei darüber, was ziel ist. Es kann sich dabei handeln um: einen normalen Outputiterator in eine Sequenz, wobei zwischen der Position des
Iterators beim Aufruf und dem Ende der Sequenz noch genügend Platz für die zu kopierenden Elemente besteht, einen Inserter, einen ostream_iterator.
Ein Inserter ist eine Funktion oder ein Funktionsobjekt, die einen speziellen Iterator erzeugt, der zum Einfügen von Werten in einen Container verwendet werden kann. Dabei wird dafür gesorgt, dass das Einfügen auf gleiche Weise geschieht, wie mit den Memberfunktionen push_back oder insert (d.h. der Speicherbedarf der Einfügeoperation wird automatisch behandelt). In der Standardbibliothek gibt es drei Inserter-Funktionen, die jeweils einen Iterator zum ...
176
MODIFIZIERENDE ALGORITHMEN
10
back_inserter:
Anhängen der Werte an den Container mittels push_back,
front_inserter:
Einfügen der Werte am Anfang des Containers mittels push_front,
inserter:
Einfügen der Werte vor einer beliebigen Position im Containers (diese muss beim Aufruf der Funktion übergeben werden)
... erzeugen. Man beachte, dass bei copy_backwards die Eigenschaften eines Outputiterators für ziel nicht ausreichend sind! Hier ist ein Bidirektionaliterator erforderlich. Aus dem im Jahre 1998 beschlossenen C++-Standard wurde versehentlich die Definition des Algorithmus copy_if herausgelassen. Bjarne Stroustrup übernimmt dafür in seinem Buch „Die C++-Programmiersprache“ in Kapitel 18.6.1 symbolisch die Verantwortung. Er gibt an gleicher Stelle auch eine mögliche Implementierung von copy_if an, die hier ihrer Kürze wegen aufgeführt werden kann: 1 2 3 4 5 6 7 8 9 10
template OutIt copy_if(InIt start, InIt ende, OutIt ziel, Pred praed) { while(start != ende) { if(praed(*start)) *ziel++ = *start; ++start; } return ziel; }
Alle Elemente aus [start,ende), für die das Prädikat praed true ergibt, werden in die Output-Sequenz hinter ziel eingefügt. Wie alle anderen copy-Funktionen auch, liefert copy_if als Ergebnis einen Endeiterator auf die Output-Sequenz zurück.
10.3.2
transform
1 OutIt transform(InIt start, InIt ende, OutIt ziel, Func f); 2 OutIt transform(InIt start, InIt ende, InIt start2, OutIt ziel, 3 BinFunc f);
Die erste Variante des Algorithmus transform wendet auf alle Elemente der Sequenz [start,ende) die Funktion f an und schreibt die Ergebnisse der Funktionsaufrufe nach ziel. Die Elemente der Ausgangssequenz ändern sich dabei nur, wenn als ziel wieder die Ausgangssequenz angegeben wird: 1 2 3 4 5
int quadriere(int x) { return x * x; } // ... vector v; // ... transform(v.begin(), v.end(), v.begin(), quadriere);
10
ALGORITHMEN
177
Schreibt man hingegen in eine neue Sequenz, so ändert sich die Ausgangssequenz nicht: 1 2
vector erg; transform(v.begin(), v.end(), back_inserter(erg), quadriere);
Die zweite Variante von transform verknüpft mit Hilfe einer binären Funktion jeweils zwei Elemente aus den Sequenzen [start,ende) und [start2,start2+(ende-start)). Der Programmierer muss selbst dafür sorgen, dass in der zweiten Sequenz mindestens genau so viele Elemente vorhanden sind wie in der ersten Sequenz. Ist dies nicht der Fall, so ist das Verhalten undefiniert. Ein Beispiel für die Benutzung wäre die Berechnung eines Gesamtpreises aus zwei Sequenzen mit Einzelpreis und Menge jeweils pro Produkt: 1 2 3 4 5 6 7 8
double gesamt(int menge, double preis) { return menge * preis; } // ... vector p_menge; vector<double> p_preis; // ... !p_menge und p_preis enthalten gleiche Elementanzahl! vector<double> p_gesamt; transform(p_menge.begin(), p_menge.end(), p_preis.begin(), back_inserter(p_gesamt), gesamt);
10.3.3 1 2 3 4
ForIt ForIt OutIt OutIt
unique unique(ForIt start, ForIt end); unique(ForIt start, ForIt end, BinPred binpraed); unique_copy(InIt start, InIt end, OutIt ziel); unique_copy(InIt start, InIt end, OutIt ziel, BinPred binpread);
Die unique-Algorithmengruppe sorgt dafür, dass Duplikate von Elementen aus der Übergabesequenz verschwinden. Die unique_copy-Algorithmen kopieren eine Sequenz, wobei Duplikate ebenfalls elimiert werden. Die Ausgangssequenz muss geordnet sein, so dass duplizierte Elemente unmittelbar hintereinander liegen. Da unique (wie auch alle anderen Algorithmen) keine Informationen darüber hat, welcher konkrete Container der übergebenen Sequenz zugrunde liegt, bzw. ob diese Sequenz überhaupt einem Container angehört, können die entsprechenden Duplikate nicht wirklich aus der Sequenz entfernt werden. Der Algorithmus sorgt lediglich dafür, dass im Anschluss alle eindeutigen Elemente am Anfang der Sequenz stehen. Der Rückgabewert von unique ist dann ein Endeiterator, der hinter das letzte eindeutige Element zeigt. Wir wollen dies mit einem string testen: 1 2 3 4 5
178
string s = "Schneeseeklee"; cout << s << endl; string::iterator it = unique(s.begin(), s.end()); cout << s << endl;
MODIFIZIERENDE ALGORITHMEN
10
6 7
s.erase(it, s.end()); cout << s << endl;
Dies liefert als Ausgabe: [mme@endeavour Source]: ./unique_test Schneeseeklee Schneseklelee Schnesekle
Anhand der zweiten Ausgabezeile können wir erkennen, dass die unmittelbare Anwendung von unique keinerlei Elemente aus der Sequenz entfernt: es sind noch genauso viele Zeichen vorhanden wie zuvor. Erst die nachfolgende Anwendung der Memberfunktion erase unter Zuhilfenahme des von unique zurückgelieferten Iterators schneidet die nunmehr überflüssigen Elemente ab. Bei Verwendung von unique_copy werden hingegen von vornherein nur die Elemente in die Zielsequenz kopiert, die eindeutig sind. Das gleiche Beispiel mit unique_copy sieht wie folgt aus: 1 2 3 4 5 6 7
string s = "Schneeseeklee"; string t; cout << s << endl; unique_copy(s.begin(), s.end(), back_inserter(t)); cout << s << endl; cout << t << endl;
Wir sehen das gleiche Endergebnis. Weiterhin können wir feststellen, dass sich s nicht geändert hat. [mme@endeavour Source]: ./unique_copy_test Schneeseeklee Schneeseeklee Schnesekle
Sowohl unique, als auch unique_copy erlauben die Angabe eines binären Prädikates, dem jeweils zwei aufeinander folgende Elemente übergeben werden und das durch Rückgabe von true entscheidet, dass beide gleich sind.
10.3.4 1 2 3 4 5 6
replace
void replace(ForIt start, ForIt ende, T const& alt, T const& neu); void replace_if(ForIt start, ForIt ende, Pred praed, T const& wert); OutIt replace_copy(InIt start, InIt ende, OutIt ziel, T const& alt, T const& neu); void replace_copy_if(InIt start, InIt ende, OutIt ziel, Pred praed, T const& wert);
Der replace-Algorithmus ersetzt in der Sequenz [start,ende) alle Vorkommen des Wertes alt durch neu. Im Falle von replace_if wird der Wert jedes Elementes durch wert ersetzt, für das das Prädikat praed true liefert. 10
ALGORITHMEN
179
Die _copy-Varianten schreiben die Ergebnissequenz nach ziel, anstatt in der Ausgangssequenz Änderungen durchzuführen.
10.3.5 1 2 3 4
ForIt ForIt OutIt OutIt
remove remove(ForIt start, ForIt ende, T const& wert); remove_if(ForIt start, ForIt ende, Pred praed); remove_copy(InIt start, InIt ende, OutIt ziel, T const& wert); remove_copy_if(InIt start, InIt ende, OutIt ziel, Pred praed);
Die remove-Familie dient zum „Entfernen“ von Elementen, die einen bestimmten wert haben, oder für die ein Prädikat praed true zurückliefert. Ähnlich zu unique, entfernen diese Algorithmen nicht wirklich Elemente aus der Sequenz; vielmehr werden alle Elemente, deren Wert ungleich wert ist, bzw. für die das Prädikat false zurückliefert, am Anfang der Sequenz angeordnet. Der Rückgabewert ist dann ein Endeiterator für diesen Teil der Sequenz und kann genau wie bei unique zum Löschen der „entfernten“ Elemente im Anschluss an die Ausführung von remove bzw. remove_if verwendet werden. Die _copy-Variante kopiert alle Elemente, die nicht den angegebenen wert haben, bzw. für die das Prädikat false zurückliefert, in die Zielsequenz ziel. Die Ausgangssequenz wird hierbei nicht verändert; der Rückgabewert enthält einen Endeiterator hinter das letzte in ziel platzierte Element.
10.3.6 1 2 3 4
void void void void
fill
fill(ForIt start, ForIt ende, T const& wert); fill_n(OutIt ziel, Anzahl n, T const& wert); generate(ForIt start, ForIt ende, Func f); generate_n(OutIt ziel, Anzahl n, Func f);
Der Algorithmus fill füllt die Sequenz [start,ende) mit dem übergebenen wert, der Algorithmus generate hingegen mit dem Ergebnis des Aufrufs der Funktion f. Im Unterschied zu transform wird der Funktion der aktuelle Wert des entsprechenden Elements nicht übergeben. Die _n-Varianten erhalten lediglich einen Outputiterator, dessen erste n Elemente sie mit wert bzw. dem Ergebnis von f füllen.
10.3.7 SORTIERALGORITHMEN UND ALGORITHMEN FÜR SORTIERTE SEQUENZEN sort 1 2 3 4
void void void void
180
sort(RAIt start, sort(RAIt start, stable_sort(RAIt stable_sort(RAIt
RAIt ende); RAIt ende, Func vergleich); start, RAIt ende); start, RAIt ende, Func vergleich);
MODIFIZIERENDE ALGORITHMEN
10
Die Funktionen sort und stable_sort sortieren die ihnen übergebene Sequenz von Werten. Die Unterschiede zwischen beiden Funktionstypen sind einerseits die Worst-Case-Performance, die bei sort bei O(N*N) liegt, während sie bei stable_sort mit O(N*log(N)*log(N)) bei höherem Speicherverbrauch angegeben wird, und andererseits die Eigenschaft, dass stable_sort eine relative Reihenfolge von gleichen Elementen (im Sinne der Sortierung) beibehält. Falls wir zum Beispiel eine Sequenz von Kundendaten nach dem Nachnamen der Kunden sortieren wollen, und wir haben einen „Müller, Klaus“ und einen „Müller, Herbert“ in dieser Reihenfolge in der Ausgangssequenz, so werden diese beiden Elemente bei Verwendung von stable_sort in der Ergebnissequenz erneut in gleicher Reihenfolge auftreten, dies ist bei sort nicht sichergestellt. Im Durchschnitt haben beide Algorithmen eine Performance von N*log(N). Jeder der beiden Algorithmen hat eine Version, in der eine Funktion f anstelle des sonst üblichen Vergleichsoperators < zum Vergleich der Elemente verwendet wird. Die Funktion f erhält jeweils zwei Elemente der Sequenz übergeben und muss true zurückliefern, wenn das erste Argument vor dem zweiten Argument eingeordnet werden soll. Da beide Algorithmen auf Random-Access-Iteratoren basieren, sind sie für list nicht verwendbar, da dieser Container lediglich Bidirektionaliteratoren zur Verfügung stellt. Der Container list bietet jedoch eine eigene Memberfunktion sort für diesen Zweck. Wir wollen ein Programm schreiben, das die von der Standardeingabe gelesenen Worte in lexikografischer Reihenfolge ausgibt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
10
#include #include <string> #include using using using using using using
std::cin; std::cout; std::endl; std::string; std::vector; std::sort;
int main() { vector<string> v; string s; while(cin >> s) v.push_back(s); sort(v.begin(), v.end()); for(vector<string>::iterator it = v.begin(); it != v.end(); ++it) cout << *it << endl; }
ALGORITHMEN
181
Wir können die Ausgabe noch verfeinern, indem wir Duplikate vermeiden: 1 using std::unique; 2 3 // ... 4 5 sort(v.begin(), v.end()); 6 v.erase(unique(v.begin(), v.end()), v.end()); 7 // ...
Das Ergebnis von unique – also der Iterator, der das Ende der eindeutigen Elemente anzeigt – verwenden wir als Anfang des zu löschenden Bereiches, der sich dann bis zum Ende des vectors hinzieht. Hätten wir list anstelle von vector verwendet (obwohl es hier keine Performancegründe gibt, die uns list vector vorziehen lassen), so hätten wir neben den #includeund using-Direktiven folgenden Zeilen ändern müssen: 1 int main() 2 { 3 list<string> v; 4 // ... 5 6 v.sort(); 7 8 //?... 9 for(list<string>::iterator it = v.begin(); it != v.end(); ++it) 10 cout << *it << endl; 11 }
Ausgehend vom originalen Beispiel können wir natürlich auch in umgekehrter lexikografischer Reihenfolge sortieren. Wir schreiben uns dafür eine Funktion vergleich, die das per default beim Vergleich verwendete Verfahren invertiert: 1 2 3 4 5 6 7 8 9 10 11 12
bool vergleich(string const& s1, string const& s2) { return s1 > s2; } int main() { // ... sort(v.begin(), v.end(), vergleich); // ... }
Sollte es für den entsprechenden Datentyp nicht möglich sein, den Operator > anstelle von < zu verwenden, so kann man alternativ natürlich auch die Variablen umdrehen; also s2 < s1 schreiben.
182
MODIFIZIERENDE ALGORITHMEN
10
Wenn man eine oder mehrere sortierte Sequenzen zur Verfügung hat, kann man darauf eine Reihe von Algorithmen anwenden, die wir im Folgenden betrachten wollen:
FINDEN GLEICHARTIGER ELEMENTE 1 2 3 4 5 6 7
ForIt lower_bound(ForIt start, ForIt ende, T const& wert); ForIt lower_bound(ForIt start, ForIt ende, T const& wert, Func vergl); ForIt upper_bound(ForIt start, ForIt ende, T const& wert); ForIt upper_bound(ForIt start, ForIt ende, T const& wert, Func vergl); pair equal_range(ForIt start, ForIt ende, T const& wert); pair equal_range(ForIt start, ForIt ende, T const& wert, Func f);
Die Funktionen lower_bound und upper_bound liefern jeweils den unteren bzw. oberen Punkt der Sequenz, zwischen denen alle Elemente den Wert wert haben, wobei die Werte gegebenenfalls nicht mit dem Gleichheitsoperator ==, sondern mit vergl verglichen werden können. Die Funktion equal_range liefert beide Werte in einem gemeinsamen Rückgabewert. Der Member first des Rückgabeparameters entspricht dem Ergebnis von lower_bound, der Member second dem Ergebnis von upper_bound. Wird jeweils kein Element des Wertes gefunden, so wird ein Iterator auf das erste Element, das größer ist als wert, zurückgeliefert. Wenn kein solches Element existiert, dann ist das Ergebnis ende, der zweite Parameter des Funktionsaufrufs. merge 1 OutIt merge(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel); 2 OutIt merge(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel, 3 Func vergl);
Der Algorithmus merge vereinigt zwei sortierte Sequenzen [start,ende) und [start2,ende2), so dass in ziel erneut eine sortierte Sequenz entsteht. Zurückgeliefert wird ein aktueller Endeiterator für die Zielsequenz. Die erste Version des Algorithmus verwendet den Operator < zur Feststellung der Reihenfolge; die zweite Version erlaubt die Angabe einer Vergleichsfunktion vergl. partition 1 BiIt partition(BiIt start, BiIt ende, Pred praed); 2 BiIt stable_partition(BiIt start, BiIt ende, Pred praed);
Die partition-Algorithmen teilen eine Sequenz in zwei Partitionen ein: am Anfang der Sequenz stehen alle Elemente, die das Prädikat praed erfüllen; am Ende alle Elemente, die das Prädikat nicht erfüllen. Als Rückgabewert liefern beide Funktionen einen Endeiterator für die Partition, die die Elemente enthält, die das Prädikat erfüllen. Die Funktion stable_partition sorgt dafür, dass die Elemente in beiden Partitionen ihre relative Ordnung behalten, während partition keine Garantien in dieser Hinsicht macht. 10
ALGORITHMEN
183
10.3.8 MENGENOPERATIONEN Alle aufgeführten Mengenoperationen werden nur für sortierte Sequenzen unterstützt. Da die Mengentypen der Standardbibliothek, set und multiset (siehe Kapitel 9.2) automatisch sortiert sind, können die Algorithmen natürlich auch mit diesen verwendet werden. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
bool includes(InIt start, InIt ende, InIt start2, InIt ende2); bool includes(InIt start, InIt ende, InIt start2, InIt ende2, Func vergl); OutIt set_union(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel); OutIt set_union(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel, Func vergl); OutIt set_intersection(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel); OutIt set_intersection(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel, Func vergl); OutIt set_difference(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel); OutIt set_difference(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel, Func vergl); OutIt set_symmetric_difference(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel); OutIt set_symmetric_difference(InIt start, InIt ende, InIt start2, InIt ende2, OutIt ziel, Func vergl);
Alle Mengenalgorithmen verwenden zwei Sequenzen. Zum Vergleichen der Elemente wird üblicherweise der Gleichheitsoperator == verwendet; soll stattdessen eine andere Operation durchgeführt werden, so kann optional eine Vergleichsfunktion vergl angegeben werden. Die Funktionen mit OutIt als Rückgabetyp liefern einen Endeiterator in die Zielsequenz zurück. Die Algorithmen entsprechen folgenden Mengenoperationen: includes:
liefert true zurück, wenn die zweite Sequenz eine Teilmenge der ersten ist, sonst false,
set_union:
liefert die Vereinigung der beiden Sequenzen, also alle Elemente der einen und der anderen Sequenz,
set_intersection:
liefert die Schnittmenge der beiden Sequenzen, also alle Elemente, die sowohl in der einen, als auch in der anderen Sequenz vorhanden sind,
set_difference:
liefert die Differenzmenge zurück, d.h. alle Elemente, die zwar in der ersten Sequenz, nicht aber in der zweiten Sequenz vorhanden sind,
set_symmetric_difference:
liefert die symmetrische Differenzmenge zurück, d.h. alle Elemente, die nur in der einen oder der anderen, nicht aber in beiden Sequenzen, vorhanden sind.
184
MODIFIZIERENDE ALGORITHMEN
10
10.3.9 MINIMUM UND MAXIMUM min
UND
1 2 3 4
const& const& const& const&
T T T T
max max(T max(T min(T min(T
const& const& const& const&
a, a, a, a,
T T T T
const& const& const& const&
b); b, Func vergl); b); b, Func vergl);
Die Algorithmen max und min bestimmen (ggf. mit Hilfe der Vergleichsfunktion vergl das Maximum bzw. Minimum zweier übergebener Objekte und liefert eine Referenz auf das Ergebnisobjekt zurück. min_element 1 2 3 4
ForIt ForIt ForIt ForIt
UND
max_element
max_element(ForIt max_element(ForIt min_element(ForIt min_element(ForIt
start, start, start, start,
ForIt ForIt ForIt ForIt
ende); ende, Func vergl); ende); ende, Func vergl);
Die Algorithmen max_element und min_element erweitern die Funktionalität von max und min auf Sequenzen. Sie liefern jeweils einen Iterator auf das maximale bzw. minimale Element der übergebenen Sequenz zurück. Optional kann anstelle der üblichen Vergleichsoperatoren eine Vergleichsfunktion vergl verwendet werden.
10.4 FUNKTIONEN UND FUNKTIONSOBJEKTE Verschiedene Algorithmen erlauben die Übergabe einer Funktion, die dann für die Elemente der Ausgangssequenz aufgerufen wird, und entsprechend dem konkreten Algorithmus verschiedene Operationen mit ihnen ausführt. Ein Beispiel für den Einsatz einer solchen Funktion ist im Rahmen der Diskussion von sort in Kapitel 10.3.7 zu finden. Gelegentlich kann es darüber hinaus erforderlich sein, der für jedes Element aufzurufenden Funktion einen festen Parameter mitzugeben. Betrachten wir die Multiplikation aller Elemente einer Sequenz mit einem skalaren Wert als Beispiel. Jede Einzeloperation erfordert zwei Operanden: das Sequenzelement und den skalaren Wert. Wir könnten folgenden Code schreiben, um die Skalarmultiplikation umzusetzen: 1 2 3 4 5 6 7 8
10
template void product(ForIt start, ForIt ende, T const& wert) { while(start != ende) { *start *= wert; ++start; } }
ALGORITHMEN
185
Im vorangegangenen Abschnitt haben wir über die Möglichkeit des Einsatzes von Algorithmen gesprochen. Wir haben dabei auch einen Algorithmus erwähnt, der auf jedes Element einer Sequenz eine übergebene Funktion anwendet und das Ergebnis des Funktionsaufrufes dann wieder in eine (möglicherweise andere) Sequenz einträgt. Grundsätzlich wäre dieses Verfahren für diesen Fall geeignet, wenn es uns gelänge, den zu multiplizierenden Wert an die Funktion zu übergeben: 1 2 3 4 5 6 7 8 9 10 11 12
template T multipliziere(T const& t1, T const& t2) { return T1 * T2; } int func() { vector v; // ... transform(v.begin(), v.end(), v.begin(), multipliziere(?)); }
Wir können bei der Übergabe der Funktion keine Argumente mitgeben; der Compiler würde dann annehmen, dass wir die Funktion nicht übergeben, sondern aufrufen wollen. Selbst wenn wir Argumente mitgeben könnten; wie sollten wir klar machen, dass nur eines der beiden Argumente von uns spezifiziert wird, während das andere aus der übergebenen Sequenz kommt? Die Lösung des Problems liegt in der Definition eines Funktionsobjektes. Es handelt sich dabei um eine Klasse, die als wichtigsten Bestandteil den operator() definiert. Dieser erlaubt es, ein Objekt dieser Klasse wie eine Funktion „aufzurufen“: 1 2 3 4 5 6 7
template struct Multipliziere { T wert; Multipliziere(T const& w) : wert(w) {} T operator() (T const& x) const { return x * wert; } };
Wir können dieses Funktionsobjekt direkt verwenden ... 1 int func() 2 { 3 Multipliziere M(10); // erzeugt ein Objekt zur Multiplikation mit 10 4 int ergebnis = M(12); 5 cout << ergebnis << endl; 6 }
... oder in unserem transform-Algorithmus einsetzen: 1
186
transform(v.begin(), v.end(), v.begin(), Multipliziere(10));
FUNKTIONEN UND FUNKTIONSOBJEKTE
10
In einigen Fällen ist es auch erforderlich, Argumente aus zwei Sequenzen zu multiplizieren. Wir könnten unser Multiplikations-Funktionsobjekt daher auch anders formulieren: 1 2 3 4 5
template struct Multipliziere { T operator() (T const& x, T const& y) const { return x * y; } };
Wie würden wir dieses Funktionsobjekt in unserem obigen Beispiel einsetzen? Wir stoßen auf das gleiche Problem, wie mit unserer binären Ausgangsfunktion, denn wir haben keine Möglichkeit mehr, den zweiten Operanden anzugeben! Die C++-Standardbibliothek bietet für diesen Zweck im Standardheader so genannte Binder an: sie erzeugen aus einem binären ein unäres Funktionsobjekt, bei dem der erste (oder zweite) Operand an einen festen Wert gebunden ist. Die Funktion bind1st und bind2nd binden einen festen Wert an den ersten bzw. zweiten Operanden des übergebenen Funktionsobjektes. Damit wir diese Binder-Funktionen verwenden können, müssen wir unser Funktionsobjekt von einer C++-Standard-Basisklasse für Funktionsobjekte ableiten. Zur Verfügung stehen unary_function und binary_function für unäre bzw. binäre Funktionen. Beide sind Templateklassen mit maximal drei Templateparametern: 1 2 3 4
template struct unary_function{ ... }; template struct binary_funkction{ ... };
Arg, bzw. Arg1 und Arg2 sind dabei die Typen der Operanden, Erg der Typ des Ergebnisses. In unserem Fall sind sowohl Operanden als auch Ergebnis vom Typ T, folglich müssen wir die Definition von Multipliziere entsprechend ändern: 1 2 3 4 5
template struct Multipliziere : public binary_function { T operator() (T const& x, T const& y) const { return x * y; } };
Damit können wir unseren transform-Aufruf erneut schreiben: 1 2
transform(v.begin(), v.end(), v.begin(), bind2nd(Multipliziere(), 10));
Wir übergeben bind2nd als erstes Argument das Funktionsobjekt, das wir uns als anonymes Objekt konstruieren. Als zweites Argument geben wir den festen Wert an, der als zweiter Operand der Multiplikation verwendet wird. Da die Multiplikation kommutativ ist, hätten wir auch bind1st verwenden können. Die Syntax wäre hierbei die gleiche.
10
ALGORITHMEN
187
10.4.1 FUNKTIONSOBJEKTE DER C++-STANDARDBIBLIOTHEK Es gibt zwei Kategorien von Funktionsobjekten in der C++-Standardbibliothek: Prädikate und artithmetische Funktionsobjekte. Alle sind als Templates im Standardheader definiert. Im Folgenden befindet sich eine Kurzübersicht über die Funktionen.
PRÄDIKATE Alle Prädikate liefern ein Ergebnis des Typs bool. equal_to
op1 == op2
not_equal_to
op1 != op2
greater
op1 > op2
less
op1 < op2
greater_equal
op1 >= op2
less_equal
op1 <= op2
logical_and
op1 && op2
logical_or
op1 || op2
logical_not
!op
ARITHMETISCHE FUNKTIONSOBJEKTE Die Ergebnisdatentypen entsprechen jeweils den Argumentdatentypen. plus
op1 + op2
minus
op1 – op2
multiplies
op1 * op2
divides
op1 / op2
modulus
op1 % op2
negate
-op
188
FUNKTIONEN UND FUNKTIONSOBJEKTE
10
10.5 ZUSAMMENFASSUNG Die in diesem Kapitel diskutierten Algorithmen bilden das Herz der C++-Standardbibliothek. Sie erlauben dem Programmierer die effiziente Durchführung vielfältiger Operationen auf Datensequenzen, wobei Iteratoren verwendet werden, um geringstmögliche Anforderungen an die zu bearbeitenden Sequenzen zu stellen. Im Zusammenhang mit den Algorithmen wurden Binder und Funktionsobjekte diskutiert, die zur Spezifikation von konkreten Operationen (Addition, Subtraktion, ...) als Funktionsargumente verwendet werden können.
10.6 ÜBUNGEN 1. Implementieren, compilieren und testen Sie die Beispiele in diesem Kapitel! 2. Geben Sie eigene Implementierungen für folgende Algorithmen an: a. equal b. mismatch c. merge d. search e. remove f. partition 3. Wenden Sie die Algorithmen der Standardbibliothek auf Objekte der Klassen Array und Matrix an, sofern möglich. 4. Geben Sie eigene Implementierungen für die in Kapitel 10.4.1 vorgestellten
Funktionsobjekte an! 5. Implementieren Sie back_inserter für den Container vector. Wandeln Sie die
Funktion dann in eine Templatefunktion um, die für beliebige Container anwendbar ist. Welche Bedingung muss der Container erfüllen, damit er mit dieser Funktion verwendbar ist?
10
ALGORITHMEN
189
10.7 LITERATUREMPFEHLUNGEN STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Die Algorithmen und Funktionsobjekte werden in Kapitel 18 behandelt. Dabei werden für die meisten Algorithmen Implementierungen und Beispiele angegeben. Am Ende des Kapitels wird auf von C geerbte Algorithmen (qsort und bsearch) eingegangen.
KOENIG/MOO: „INTENSIVKURS C++“ Einige Algorithmen der C++-Standardbibliothek werden in Kapitel 6 vorgestellt. Dabei wird die Analyse von strings mit Hilfe der Algorithmen (anstelle der eigenen Memberfunktionen) am Beispiel gezeigt, sowie anhand des Komplexbeispiels die bereits in Kapitel 5 bezüglich der Containertypen vector und list angestellten Performancebetrachtungen auf Algorithmen ausgedehnt.
KUHLINS/SCHADER: „DIE C++-STANDARDBIBLIOTHEK“ Innerhalb des Kapitels 9 gibt es für jeden Algorithmus der Standardbibliothek einen eigenen Abschnitt, in dem die entsprechenden Funktionen beschrieben werden. Funktionsobjekte werden in Kapitel 3 besprochen.
190
LITERATUREMPFEHLUNGEN
10
11 MANUELLE SPEICHERVERWALTUNG Bisher haben wir uns in allen Beispielen auf die automatische Speicherverwaltung verlassen. Gelegentlich kann es erforderlich sein, selbst die Initiative zu ergreifen und den vom System zur Verfügung gestellten Speicher selbst zu verwalten. Manchmal ist es auch wünschenswert eigene Vorstellungen bezüglich der Lebenszeit von Objekten zu verwirklichen. Da die manuellen Speicherverwaltungsoperationen das Erzeugen und Zerstören von Objekten bewerkstelligen können, ist uns damit ein Werkzeug auch für diesen Zweck in die Hand gegeben. Wer dieses Kapitel durchgearbeitet hat, sollte:
Î die Unterschiede zwischen automatischer und manueller Speicherverwaltung verstanden haben, Î dynamisch Speicherbereiche akquirieren, nutzen und freigeben können, Î zu selbstgewählten Punkten im Programmablauf Objekte erzeugen und zerstören können, Î die Dreierregel verstanden haben und anwenden können. 11.1 AUTOMATISCHE SPEICHERVERWALTUNG Automatische Speicherverwaltung beinhaltet die Erzeugung und Zerstörung von lokalen Variablen, sowie die Erzeugung und Zerstörung von Wertekopien, die bei der Übergabe und Rückgabe von Funktionsparametern erforderlich sind. Wir haben im Allgemeinen keine Wahl, in die automatische Speicherverwaltung einzugreifen; die Regeln für die Erzeugung und Zerstörung von lokalen Variablen zum Beispiel sind fester Bestandteil der Sprachdefinition und wurden hier in Kapitel 1.2 behandelt. Stellen wir uns zum Beispiel vor, wir wollen aus einer Funktion Daten über einen Zeiger anstelle einer Kopie zurückgeben: 1 int *funktion() 2 { 3 int wert = 10; 4 // ... 5 return &wert; 6 }
11
// FEHLER!
MANUELLE SPEICHERVERWALTUNG
191
Wenn die return-Anweisung dieser Funktion erreicht wird, so wird die Adresse der lokalen Variablen wert an den Aufrufer zurückgeliefert. Noch bevor der Aufrufer jedoch mit dieser Adresse etwas anfangen kann, wird durch die automatische Speicherverwaltung am Ende der Funktion funktion der von der Variable wert belegte Speicher zurückgegeben. Mit dem Zeiger, den der Aufrufer der Funktion erhalten hat, kann dieser also nichts anfangen. Eine legale Möglichkeit, einen Zeiger auf eine lokale Variable zurückzuliefern, wäre die Verwendung von static: 1 int* funktion() 2 { 3 static int* wert = 100; 4 // ... 5 return &wert; 6 }
Da der Speicherklassenspezifizierer static dafür sorgt, dass zwischen zwei Aufrufen der Funktion funktion die Variable wert erhalten bleibt, ist sichergestellt, dass auch der entsprechende Speicher in diesem Zeitraum zur Verfügung steht. Damit kann also über den zurückgelieferten Zeiger bis zum Ende des Programms stets zugegriffen werden. Leider erlaubt diese Methode stets nur die Rückgabe eines Zeigers auf denselben Speicherplatz.
11.2 VERWENDUNG VON
new
UND
delete
Wenn wir aus einer Funktion bei jedem Aufruf einen Zeiger auf einen neuen Speicherplatz zurückliefern wollen, müssen wir dafür die dynamische Speicherverwaltung manuell benutzen. Zum Allokieren von Speicher existiert der Operator new: 1 int* funktion() 2 { 3 int *wert = new int(1000); 4 // ... 5 return wert; 6 }
Der Operator new nimmt als Argument einen Datentyp; er besorgt dann vom System genügend Speicher für den angegebenen Datentyp und gibt diesen Speicher zurück. Wenn gewünscht, kann die Konstruktor-Syntax benutzt werden, um einen Initialwert mitzugeben, wie hier den Wert 1000. Der dynamisch mit Hilfe von new okkupierte Speicher wird nicht automatisch ans System zurückgegeben. Dafür muss der Programmierer ebenfalls selbst sorgen; verwenden kann er dafür den Operator delete, dem man einfach einen Zeiger auf den mit new allokierten Speicher gibt: 192
VERWENDUNG VON NEW UND DELETE
11
1 2 3
int* ergebnis = funktion(); // ... delete ergebnis;
Der Operator delete sorgt dann dafür, dass zunächst das in dem entsprechenden Speicherabschnitt platzierte Objekt zerstört wird. Dies geschieht durch internen Aufruf des Destruktors, sofern es sich um einen Klassentyp handelt. Basisdatentypen hingegen erlauben keine speziellen Aktionen bei ihrer Zerstörung. Anschließend wird der vom entsprechenden Objekt okkupierte Speicher zurückgegeben. Nehmen wir also an, wir wollen dynamisch ein Kunden-Objekt verwalten: 1 2 3
Kunde *K = new Kunde("Müller", "Heinz", "Waldstadt"); // ... delete K;
Beim Anlegen des Objektes mit new wird zunächst der Speicher allokiert und anschließend das Objekt durch einen entsprechenden Konstruktor hergestellt. Beim Aufruf von delete wird zunächst der Destruktor von Kunde bemüht, so dass alle Bestandteile des Objektes zerstört werden, und danach der von dem Objekt okkupierte Speicher freigegeben. Was passiert, falls wir ein Kunden-Objekt über einen Zeiger auf die Basisklasse verwalten? 1 2 3
Verwaltungsdaten *V = new Kunde("Müller", "Heinz", "Waldstadt"); // ... delete V;
Hier kommt uns zupass, dass wir den Destruktor von Verwaltungsdaten als virtual deklariert haben. Dadurch wird der durch delete ausgelöste Destruktoraufruf – analog zu einem Memberfunktionsaufruf über einen Basisklassenzeiger – auf den tatsächlichen Destruktor der Klasse Kunde umgeleitet. Hätten wir den Destruktor nicht als virtual deklariert, so wären einerseits nur die Bestandteile der Klasse Verwaltungsdaten zerstört worden; andererseits wäre nur so viel Speicher freigegeben worden, wie die Datenmember von Verwaltungsdaten benötigen, obwohl tatsächlich für ein Kunden-Objekt mehr Speicher erforderlich ist. Der an delete übergebene Zeiger darf den Wert 0 haben; in diesem Fall tut delete überhaupt nichts. Falls mit Hilfe von new ein Array angelegt werden soll, so kann man die Dimension des Arrays in [] hinter dem Typ angeben. Die Dimension muss keine Konstante sein: 1 int *get_array(size_t groesse, int wert = 0) 2 { 3 int* array = new int[groesse]; 4 for(size_t i = 0; i != groesse; ++i) 5 array[i] = wert; 6 return array; 7 }
11
MANUELLE SPEICHERVERWALTUNG
193
Per default führt new keine Initialisierung des Speichers durch. Auch die Angabe von Konstruktorargumenten ist bei der Herstellung eines Arrays mit new nicht möglich. Wenn Arrays von Klassentypen erstellt werden, so wird durch new eine Defaultinitialisierung vorgenommen. Dies bedeutet, dass ein Defaultkonstruktor definiert sein muss, damit ein Array eines Klassentyps erstellt werden kann. Beim Freigeben eines mit new erzeugten Arrays ist der Operator delete [] zu verwenden. Werden die [] hier vergessen, so wird nur ein Element des Arrays zurückgegeben; für die übrigen Elemente geht der Speicherverweis verloren, so dass es anschließend keine Chance gibt, diesen Speicher freizugeben! 1 2 3
int *arr = get_array(64, 16); // ... delete [] arr;
11.2.1 BEISPIEL ANHAND DER KLASSE
Array
Wir können mit Hilfe von new und delete unsere in Kapitel 4.3 definierte und in Kapitel 7.3.2 verbesserte Klasse Array nunmehr von vector abkoppeln und eine eigene Speicherverwaltung integrieren.
VORÜBERLEGUNGEN Für unsere eigene Speicherverwaltung müssen wir zu jeder Zeit in unserem ArrayObjekt vermerken, wie viel Speicher wir momentan allokiert haben, und wo dieser Speicherbereich beginnt. Dies kann auf zwei Wegen geschehen: 1. Wir vermerken den Anfang des Speicherbereichs (als Zeiger), sowie die Größe
des Speicherbereichs. 2. Wir speichern Anfang und Ende des Speicherbereichs als Zeiger. Die Größe ergibt
sich dann als Differenz der beiden Zeiger. Welche von beiden Möglichkeiten sollen wir wählen? Wir müssen diese Entscheidung anhand der möglichen Benutzung unserer Array-Klasse fällen. Wir haben zum Beispiel die Funktion set implementiert, die die dynamische Erweiterung des Arrays (inklusive dem Füllen von Zwischenelementen) durchführen soll, sofern der ihr übergebene Index hinter den momentan maximalen Index zeigt. In der vector-Version von Array hatten wir dafür die Memberfunktion push_back von vector verwendet; nunmehr müssen wir uns um die Speicherverwaltung selbst kümmern. Wie werden wir die Speicherverwaltung gestalten? Eine einfache Strategie wäre, zum Anhängen eines neuen Elementes einfach so viel Speicher zu allokieren, wie das neue Array erfordern würde; danach alle Elemente zu kopieren und den Wert des neuen Elements am Ende einzutragen. Wenn wir uns jedoch vorstellen, dass wir in einer Schleife nacheinander mehrere Elemente anhängen, dann erkennen wir, dass wir mit dieser Strategie sehr viel Zeit verbrauchen würden: mit jedem neu anzuhängenden Element müssen alle vorherigen Elemente in den neu allokierten Speicher kopiert werden. 194
VERWENDUNG VON NEW UND DELETE
11
Wir wollen daher ein Speicherverwaltungsschema benutzen, bei dem wir vorsorglich mehr Speicher allokieren, als wir eigentlich momentan benötigen. Damit müssen wir erst dann neu allokieren, wenn wir diesen zunächst überzähligen Speicher ebenfalls mit Arrayelementen gefüllt haben. Dies führt dazu, dass wir nunmehr drei Angaben verwalten müssen: den Anfang des allokierten Speicherbereichs, die Anzahl der momentan vergebenen Arrayelemente, das Ende des allokierten Speicherbereichs oder die Gesamtanzahl der allokierten
(aber teilweise unbelegten) Arrayelemente. Da wir Anfang und Ende des Speicherbereichs als Argumente für Standardalgorithmen und als Rückgabewerte für die Standard-Container-Memberfunktionen benötigen, entscheiden wir uns dafür, alle drei Werte als Zeiger auszuführen. Dies erleichtert uns entsprechende Aufrufe und erhöht deren Verständlichkeit.
DIE UMSETZUNG DES DESIGNS DATENMEMBER Wir benötigen drei Datenmember, die alle Zeiger auf unseren im Array gespeicherten Datentyp T sind. Die Namen der Member sprechen für sich: 1 2 3 4 5 6 7 8 9 10
template class Array { public: // ... private: T* daten; T* daten_end; T* speicher_end; };
DREIERREGEL: KONSTRUKTOREN UND DESTRUKTOREN Ganz offensichtlich benötigen wir einen Defaultkonstruktor sowie einen Konstruktor, der die Angabe einer Anfangsgröße und von Anfangswerten erlaubt. Wir können beide Aufgaben – wie auch in den bisherigen Versionen unserer Array-Klasse – erneut in einem Konstruktor erledigen: 1 2 3 4 5 6 7 8
11
template class Array { public: Array(size_t s = 0, T const& t = T()) { daten = new T[s]; daten_end = daten + s;
MANUELLE SPEICHERVERWALTUNG
195
9 10 11 12 13
speicher_end = daten + s; fill(daten, daten_end, t); } // ...
Wir verwenden new zum Allokieren des benötigten Speichers. Der Operator new verhält sich automatisch richtig, wenn s den Wert 0 hat: Er liefert einen Zeiger zurück, der mit delete behandelt, aber nicht andersweitig benutzt werden kann. Wir setzen daten_end und speicher_end auf das (bzw. hinter das) Ende des momentan allokierten Speicherbereichs und füllen diesen mit dem angegebenen Wert, wozu wir den fillAlgorithmus (siehe Kapitel 10.3.6) benutzen. Benötigen wir einen Copykonstruktor? Bislang konnten wir darauf verzichten, da der von der Implementierung automatisch bereitgestellte Copykonstruktor jeweils das Richtige tat. Das Richtige bestand darin, eine Kopie der Datenmember anzulegen: Entweder mit Hilfe des eigenen Copykonstruktors, wenn der Datenmember einen Klassentyp hatte, oder durch bitweises Kopieren, wenn der Datenmember einem Basisdatentyp entsprang. Wir haben nunmehr Datenmember, die Zeiger sind; und wie wir in Kapitel 1.7 dargestellt haben, sind alle Zeiger (egal auf welchen Typ sie gerichtet sind) als Basisdatentypen zu betrachten. Der automatisch generierte Copykonstruktor würde folglich die Werte der Zeiger (die Speicheraddressen) einfach kopieren, so dass sowohl Original, als auch Kopie auf den selben Speicherbereich zeigen würden. Ändern wir anschließend das Original, so wird auch die Kopie geändert! Wenn wir den vom Original okkupierten Speicher für daten freigeben, und danach über die Kopie auf den selben Speicherbereich zugreifen, entsteht ein noch größeres Problem: Der Speicher, auf den wir über die Kopie zugreifen, ist gar nicht mehr im Programm verfügbar! Aus diesen Gründen müssen wir einen Copykonstruktor definieren, sobald wir innerhalb einer Klasse ein eigenes Speichermanagement durchführen. Das Argument lässt sich auf zwei weitere Spezialfunktionen erweitern: den Destruktor und den Zuweisungsoperator. Damit der innerhalb der Konstruktoren (und im Zuweisungsoperator, wie wir gleich sehen werden) allokierte Speicher irgendwie wieder freigegeben wird, sobald das entsprechende Objekt zerstört wird, müssen wir einen Destruktor definieren, der den Speicher freigibt. Der Zuweisungsoperator leistet grundsätzlich Ähnliches wie der Copykonstruktor; ergänzt noch um eine Prüfung auf Selbstzuweisung und die Rückgabe einer Referenz auf den soeben zugewiesenen Wert. Daraus ergibt sich bereits die Notwendigkeit zu seiner Implementierung. Diese Systematik ist allgemein als Dreierregel bekannt: Sobald man sich gezwungen sieht, Copykonstruktor, Zuweisungsoperator oder Destruktor zu entwickeln, müssen ebenfalls die zwei anderen Funktionen implementiert werden.
196
VERWENDUNG VON NEW UND DELETE
11
Hier haben wir alle drei: Copykonstruktor, Zuweisungsoperator und Destruktor: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
Array(Array const& A) { kopiere_neu(A.daten, A.daten_end); } Array& operator=(Array const& A) { if(A.daten != daten){ delete [] daten; kopiere_neu(A.daten, A.daten_end); } return *this; } ~Array() { delete [] daten; }
Da Copykonstruktor und Zuweisungsoperator eine Reihe identischer Operationen durchführen, haben wir eine private Memberfunktion kopiere_neu für die gemeinsame Arbeit entwickelt. Im Zuweisungsoperator prüfen wir die daten-Zeiger auf Identität, um eine Selbstzuweisung zu erkennen. Wir hätten genauso gut auch &A == this dafür verwenden können; es sollte nur illustriert werden, dass es mehr als eine Möglichkeit zur Feststellung der Zuweisung an das eigene Objekt gibt. Hier nun die Funktion kopiere_neu, die die eigentliche Arbeit von Copykonstruktor und Zuweisungsoperator leistet: 1 //... 2 private: 3 4 void kopiere_neu(T* s, T* e) 5 { 6 daten = new T[e - s]; 7 copy(s, e, daten); 8 daten_end = daten + (e - s); 9 speicher_end = daten + (e - s); 10 } 11 12 // ...
Sie erhält zwei Iteratoren (Zeiger) auf den Anfang und hinter das Ende des zu kopierenden Bereiches. Sie allokiert zunächst neuen Speicher für die zu kopierenden Daten, führt dann mittels des Algorithmus copy die Kopie aus und setzt anschließend daten_end und speicher_end auf sinnvolle Werte. Nach einer solchen Kopie zeigen daten_end und speicher_end auf die gleiche Stelle; genau wie im Konstruktor.
11
MANUELLE SPEICHERVERWALTUNG
197
print
UND
size
Die Memberfunktion print verwendet nunmehr einen ostream_iterator zur Ausgabe der Daten (siehe Kapitel 7.4.7): 1 2 3 4 5
ostream& print(ostream& os) const { copy(daten, daten_end, ostream_iterator(os," ")); os << endl; }
Die Memberfunktion size muss die aktuelle Elementanzahl nunmehr aus dem Abstand zwischen daten_end und daten berechnen: 1 2 3 4 set
size_t size() const { return daten_end - daten; }
UND
get
Im Falle der Zugriffsfunktion set müssen wir darüber nachdenken, wie wir für das Wachstum des Arrays sorgen wollen. Was müssen wir tun, um den Speicher zu erweitern? Folgender Algorithmus tut, was wir wollen: 1. Neue Größe bestimmen, wobei wir dafür sorgen wollen, dass wir nicht bei jedem
anzufügenden Element alle anderen kopieren müssen. Wir wollen hier den Speicher bei jeder Allokation verdoppeln; andere Strategien sind ebenfalls möglich. 2. Alte Daten kopieren. 3. Alten Speicherbereich freigeben. 4. daten, daten_end und speicher_end auf aktuelle Werte setzen.
Wann müssen wir für Wachstum sorgen? Wenn der Index, an dem wir Daten einfügen wollen, auf oder hinter den Iterator daten_end, aber vor den Iterator speicher_end zeigt, befinden wir uns noch in einem Bereich mit für uns gültigem Speicher; erst, wenn wir auf oder hinter speicher_end zugreifen, muss der Speicher erweitert werden. Es folgt nunmehr der Code von set: 1 2 3 4 5 6 7 8 9 10 11
198
void set(size_t e, T t) { if(e >= size()){ if(e >= speicher_end - daten){ size_t groesse = e * 2; T* daten_neu = new T[groesse]; copy(daten, daten_end, daten_neu); delete [] daten; speicher_end = daten_neu + groesse;
VERWENDUNG VON NEW UND DELETE
11
12 13 14 15 16 17 18 19 20 21 22
daten_end = daten_neu + (daten_end - daten); daten = daten_neu; } fill(daten_end, daten + e, T()); daten_end = daten + e; *daten_end++ = t; } else { daten[e] = t; } }
Die Zeilen 5-13 zeigen unseren Speichererweiterungsalgorithmus. Sie treten nur in Kraft, wenn der Index e auf oder hinter das Ende des momentan verfügbaren Speicherbereichs (speicher_end) zeigt. Anschließend (oder wenn wir keinen neuen Speicher allokieren mussten) füllen wir den Bereich zwischen dem letzten Array-Element und der Stelle, an die wir das übergebene Element t kopieren wollen, mit einem typabhängigen Defaultwert, indem wir den fill-Algorithmus einsetzen. Dies entspricht der for-Schleife, die wir in der vergangenen Version von Array dafür verwendet haben. Natürlich müssen wir daten_end entsprechend weitersetzen. Als letzte Aktion kopieren wir t an die gewünschte Stelle und setzen daten_end dahinter. Sofern der Index e in Speicher zwischen daten und daten_end zeigt, können wir das entsprechende Arrayelement einfach direkt zuweisen. Die Zugriffsfunktion get ist wesentlich einfacher. Im Unterschied zur bisherigen Version müssen wir uns hier jedoch selbst um die Erzeugung der Exception kümmern, wenn der Zugriff hinter die Grenzen des Arrays erfolgt: 1 2 3 4 5 6
T get(size_t e) const { if(e >= size()) throw out_of_range("Zugriff hinter die Arraygrenze."); return daten[e]; }
INDEXOPERATOREN
Die Indexoperatoren müssen nicht geändert werden.
COMPOUND-MULTIPLIKATION
Der Compound-Multiplikationsoperator wird nunmehr mit Hilfe von transform formuliert: 1 2 3 4 5 6
Array operator*=(T t) { transform(daten, daten_end, daten, bind2nd(multiplies(), t)); return *this; }
11
MANUELLE SPEICHERVERWALTUNG
199
7 // ... 8 private: 9 // ... 10 };
Als „Transformationsfunktion“ verwenden wir das Funktionsobjekt multiplies der Standardbibliothek. Da es sich dabei um eine binäre Funktion handelt und wir jedes Element der Sequenz mit t multiplizieren wollen, müssen wir t an das zweite Argument von multiplies binden (das erste Argument ist das jeweilige Element der Sequenz), wozu wir die Funktion bind2nd der Standardbibliothek benutzen. Das Ergebnis der Transformation schreiben wir wieder nach daten, denn wir wollen die bisherigen Werte mit den neuen überschreiben.
MAIN Wir haben nunmehr unsere Array-Klasse mit eigener Speicherverwaltung ausgestattet, sowie einige unserer eigenen Schleifenkonstruktionen durch Algorithmen der Standardbibliothek ersetzt. Wir wollen die Verwendbarkeit mit Hilfe eines entsprechenden Hauptprogramms testen: 1 int main() 2 { 3 Array intA; // leeres int-Array 4 5 // Anh‰ngen von Elementen: 6 intA.set(5, 2); // Elemente [0,5) sind 0 7 intA.set(10, 4); // Elemente [6,10) sind 0 8 9 intA.set(3, 1); // ƒndern eines vorhandenen Elementes 10 11 intA.print(cout); // Ausgabe von intA 12 13 // Zuweisung 14 Array intB = intA; 15 16 // Skalare Multiplikation: 17 intA *= 10; 18 19 intB.print(cout); // Werte entsprechen intA (korrekte Kopie) 20 intA.print(cout); // Ergebnis der Multiplikation 21 }
Die Kommentare sollten die jeweils beabsichtigten Tests hinreichend erläutern. Wir wollen uns die Ausgabe anschauen: [mme@endeavour Source]: ./array_new 0 0 0 1 0 2 0 0 0 0 4 0 0 0 1 0 2 0 0 0 0 4 0 0 0 10 0 20 0 0 0 0 40
200
VERWENDUNG VON NEW UND DELETE
11
Als Erstes sehen wir die Ausgabe von intA, der erfolgreich durch das Einfügen von Elementen verlängert wurde. Auch der am Index 3 eingefügte Wert 1 ist richtig eingetragen worden. In der zweiten Zeile der Ausgabe sehen wir den Wert von intB, der als Kopie von intA vor der Ausführung der Skalarmultiplikation erzeugt wurde. Die dritte Zeile schließlich zeigt das Ergebnis der Skalarmultiplikation.
KRITIK Wir haben nunmehr bis auf einen Punkt alle in Kapitel 4.3.4 genannten Probleme behoben. Der verbleibende Punkt ist die Tatsache, dass nach wie vor ein Defaultkonstruktor für den zu speichernden Datentyp T benötigt wird. Dies hängt damit zusammen, dass bei Anwendung von new zum Anlegen des Feldes jedes Element mit Hilfe des Defaultkonstruktors1 konstruiert wird. Wir können das Vorhandensein dieser Beschränkung ohne Probleme ertragen, solange wir Basisdatentypen verwenden. Es ist jedoch wichtig zu bemerken, dass das Problem nicht notwendigerweise auftreten muss. Abgesehen von new und delete bietet uns die C++-Standardbibliothek zur Speicherverwaltung auch Allokatoren an. Diese erlauben eine flexiblere Gestaltung der Initialisierung von Speicherelementen. In dem Buch „Intensivkurs C++“ von Andrew Koenig und Barbara Moo wird in Kapitel 11 die Implementierung einer vector-ähnlichen Klasse beschrieben, wobei ein Allokator zum Einsatz kommt. Auf dieses Beispiel und auf die weitere Literatur zum Thema sei daher abschließend verwiesen.
11.3 ZUSAMMENFASSUNG Die manuelle Speicherverwaltung stellt eine Möglichkeit für Programmierer dar, selbst über die Lebenszeit von Objekten zu entscheiden. Dazu können sie die Operatoren new und delete zum Erzeugen bzw. Löschen von Objekten verwenden. Anhand eines Komplexbeispiels wird die Benutzung dieser Operatoren dargestellt. Weiterhin kann man anhand dieses Beispiels die Anforderungen erkennen, die an Klassen mit manueller Speicherverwaltung unterliegenden Datenmembern gestellt werden. Diese Anforderungen gipfeln in der Dreierregel, die die Definition von Destruktor, Copykonstruktor und Zuweisungsoperator für derartige Klassen fordert. Ausgehend von diesem Kapitel, und mit dem Wissen der vorangehenden Kapitel, kann der Leser nunmehr vielfältige Sprachmittel und Bestandteile der C++-Standardbibliothek in eigenen C++-Projekten einsetzen. Die angegebenen Literaturhinweise helfen ihm dabei, weitere detaillierte Hinweise zu den hier dargestellten Konzepten zu finden.
1
11
Zumindest, wenn das Element einen Klassentyp hat!
MANUELLE SPEICHERVERWALTUNG
201
11.4 ÜBUNGEN 1. Implementieren, compilieren und testen Sie die Beispiele in diesem Kapitel! 2. Stellen Sie die Klasse Matrix auf eine Implementierung mit eigener Speicherver-
waltung um. Überlegen Sie zunächst, wie Sie das zweidimensionale Feld in einen eindimensionalen Speicherbereich ablegen. 3. Führen Sie mit Hilfe von new und delete und den instrumentierten Klassen Verwaltungsdaten, Kunde, Rabattkunde und Mitarbeiter aus Kapitel 3 Experimente
durch, die Ihnen zeigen sollen, welche Konstruktoren/Destruktoren bei der Konstruktion/Zerstörung von Objekten der verschiedenen Typen ausgeführt werden. Prüfen Sie dabei auch, was passiert, wenn Sie die Objekte über Zeiger auf den Basisklassentyp verwalten!
11.5 LITERATUREMPFEHLUNGEN STROUSTRUP: „DIE C++-PROGRAMMIERSPRACHE“ Speicherverwaltung ist kein separates Thema in diesem Buch, vielmehr sind die Betrachtungen auf verschiedene Abschnitte verteilt. Die Betrachtung von Allokatoren, die im Rahmen des vorliegenden Buches nicht möglich ist, wird in Kapitel 19 durchgeführt.
KOENIG/MOO: „INTENSIVKURS C++“ Die manuelle Speicherverwaltung wird im Kapitel 10 ausführlich diskutiert, eingeleitet durch eine generelle Betrachtung von Zeigern und Feldern. Im Rahmen der Entwicklung der Containerklasse Vec im Kapitel 11 wird dann die Verwendung von Allokatoren zur Speicherverwaltung gezeigt. Technologien zur Einsparung von Speicher beim Kopieren von Datencontainern werden in Kapitel 14, insbesondere im Abschnitt 14.3 gezeigt.
MEYERS: „EFFEKTIV C++ PROGRAMMIEREN“ Speicherverwaltung ist ein zentrales Thema der Abschnitte 5 bis 10. Hier werden insbesondere häufige Fehler bei der Verwendung von new und delete erörtert und Vermeidungsstrategien diskutiert.
MEYERS: „MEHR EFFEKTIV C++ PROGRAMMIEREN“ Im Zusammenhang mit verschiedenen anderen C++-Konzepten wird hier über Speicherverwaltung diskutiert. Der Abschnitt 2.4 geht zunächst noch einmal auf Besonderheiten im Zusammenhang mit new und delete ein, in Abschnitt 3.1 wird auf die Vermeidung von Ressourcenleaks im Zusammenhang mit Exceptions eingegan202
ÜBUNGEN
11
gen — ein Thema, das natürlich auch dynamisch allokierten Speicher als Ressource betrifft. In Abschnitt 5.3 diskutiert Meyers heapbasierte Objekte, sowie in den Abschnitten 5.4 bis 5.6 Techniken zur Vermeidung unnötiger Datenkopien (Referenzzähler, Smartpointer und Proxyklassen).
KUHLINS/SCHADER: „DIE C++-STANDARDBIBLIOTHEK“ Das Allokator-Konzept der Standardbibliothek wird in Kapitel 10 vorgestellt.
11
MANUELLE SPEICHERVERWALTUNG
203
ANHANG: OPERATOREN IN C++ C::m
Der Member m der Klasse C.
N::m
Der Member m des Namespaces N.
::m
Der Name m des globalen Definitionsbereichs.
x[y]
Element mit Index y im Objekt x.
x->y
Member y des Objekts, auf das x zeigt.
x.y
Der Member y des Objekts x.
f(args)
Aufruf der Funktion f mit Übergabe von args.
x++
Inkrementiert x, gibt Originalwert zurück.
x--
Dekrementiert x, gibt Originalwert zurück.
*x
Dereferenziert Zeiger x. Liefert das Objekt, auf das x zeigt.
&x
Die Adresse von x. Liefert einen Zeiger.
-x
Unäres Minus.
!x
Logische Negation.
~x
Einerkomplement von Integer x.
++x
Inkrementiert x, gibt inkrementierten Wert zurück.
–x
Dekrementiert x, gibt dekrementierten Wert zurück.
sizeof(e)
Anzahl von Bytes, die der Ausdruck e im Speicher einnimmt, angegeben als size_t.
sizeof(T)
Anzahl von Bytes, die ein Objekt des Typs T im Speicher einnimmt, als size_t.
T(args)
Konstruiert ein T-Objekt aus args.
new T
Allokiert ein neues, defaultinitialisiertes Objekt des Typs T.
new T(args)
Allokiert ein neues Objekt des Typs T initialisiert mit args.
new T[n]
Allokiert ein Feld von n defaultinitialisierten Objekten des Typs T.
delete p
Gibt das Objekt frei, auf das p zeigt.
delete [] p
Gibt das Feld von Objekten frei, auf das p zeigt.
x * y
Produkt von x und y.
ANHANG: OPERATOREN IN C++
205
x / y
Quotient von x und y. Wenn beide Operanden Integer sind, wählt die Implementierung, ob nach 0 oder nach -∞ gerundet wird.
x % y
Rest der Division von x durch y, äquivalent zu x-((x/y)*y).
x + y
Summe von x und y, wenn beide Operanden numerisch sind. Wenn ein Operand ein Zeiger und der andere ein Integer ist, dann liefert die Operation die Position y Elemente hinter x.
x - y
Differenz von x und y, wenn beide Operanden numerisch sind. Wenn x und y Zeiger sind, liefert die Operation die Entfernung in Elementen, die zwischen ihnen liegt.
x >> y
Für ganzzahlige x und y, x um y Bits nach rechts verschoben; y muss nicht-negativ sein. Wenn x ein istream ist, wird aus x in y eingelesen und das Ergebnis ist der lvalue x.
x << y
Für ganzzahlige x und y, x um y Bits nach links verschoben; y muss nicht-negativ sein. Wenn x ein ostream ist, wird y auf x ausgegeben, und das Ergebnis ist der lvalue x.
x relop y
Die relationalen Operatoren liefern bool und geben damit den Wahrheitswert der Relation an. Die Operatoren (<, >, <= und >=) haben ihre offensichtliche Bedeutung. Wenn x und y Zeiger sind, müssen sie auf das gleiche Objekt oder Feld zeigen.
x == y
Gibt einen bool-Wert zurück, der angibt, ob x gleich y ist.
x != y
Gibt einen bool-Wert zurück, der angibt, ob x ungleich y ist.
x & y
Bitweises AND. x und y müssen Integer sein.
x ~ y
Bitweises XOR. x und y müssen Integer sein.
x | y
Bitweises OR. x und y müssen Integer sein.
x && y
Gibt einen bool-Wert zurück, der angibt, ob sowohl x, als auch y true sind. Wertet y nur aus, wenn x true ist.
x || y
Gibt einen bool-Wert zurück, der angibt, ob x oder auch y true ist. Wertet y nur aus, wenn x false ist.
x = y
Weist x den Wert von y zu und gibt x als lvalue-Ergebnis zurück.
x op= y
Compound-Zuweisungsoperator; äquivalent zu x = x op y, wobei op ein arithmetischer oder Schiebeoperator ist.
x ? y1 : y2
Ergibt y1, wenn x true ist, ansonsten y2. Wertet nur einen der beiden Ausdrücke y1 und y2 aus. y1 und y2 müssen vom selben Typ sein. Wenn y1 und y2 lvalues sind, ist das Ergebnis ebenfalls ein lvalue. Der Operator ist rechts-assoziativ.
throw x
Signalisiert einen Fehler durch Erzeugung des Wertes x. Der Wert von x bestimmt, welcher Handler den Fehler behandeln wird.
x, y
Wertet x aus, verwirft das Ergebnis und wertet dann y aus. Liefert y.
206
ANHANG
C++ PROGRAMMIEREN IM KLARTEXT
REGISTER Symbole - 146 != 143 " 15 #define 29 #ifndef 29 #include 14 -Guard 29 () 186 * 101, 103, 138, 144 *= 101, 138, 199 + 22, 146, 155 ++ 135, 143–144 += 153 /*...*/ 13 // 13 ; 16, 23 < 142, 146, 181 << 15, 118 -operator 72 <= 142 172 <deque> 141 123 187 121 124 117 <list> 140 <map> 165 <multimap> 165 <multiset> 168 <set> 168 <sstream> 127
<stdexcept> 111 <string> 151 135
=-operator 69 == 143 > 146 >> 75, 118 -- 143, 145 [] 99 { 14 } 14
A adjacent_find 172 allocator 201
Anweisung 16 Ausdruck 16
B back_inserter 176 basic_ios 119
Basisklasse 81 Bibliothek 27 binary_function 187 binary_search 146 bind1st 187 bind2nd 187, 200 Binder 187 Bisektion 146 Block 17 bool 31, 119 Bottomwert 107–109
C call by reference 25 by value 24 call by reference 26 value 26 case 32 cat 122–123 catch 110, 112 cctype 98 cerr 113, 117 char 15, 121, 153, 155 char const* 15 cin 19, 117–118, 124 clog 117 const 18, 99 Container 89, 131–132 assoziative 131, 165 Iteratoren 133 sequenzielle 131, 136 copy 144, 176, 197–198 copy_backward 176 copy_if 177 count 173 count_if 173 cout 15, 117, 124, 128 C-String 108, 151, 153, 155–157, 160
D Datei -ausgabe 124, 126 -eingabe 123, 126 schließen 126 Dateiendezeichen 119 Datentyp 15 Basistyp 19, 193, 196 Klassentyp 19, 193 selbstdefinierter 44 dec 121 default 32 delete 192 [] 194
208
deque 132, 141, 154 Destruktor 58, 72, 134, 193, 195–196 virtual 92, 193 divides 188 double 15 Dreierregel 72, 195–196
E else 31 endl 16 equal 173 equal_range 183 equal_to 188
Ergebnis 16 errno 108 Exception 107, 110 -Objekt 110 out_of_range 111, 113
F false 31
Feld 193 new 193 fill 180, 199 fill_n 180 find 143, 172 find_first_of 172 find_if 172 fopen 108 for 32 for_each 173 front_inserter 176 fstream 123, 126 Funktion 14, 22, 185 Definition 22 Deklaration 22 Haupt- 14 -sobjekt 185–186
REGISTER
G
K
generate 180 generate_n 180 greater 188 greater_equal 188
Klammer geschweifte 14 Klasse abgeleitete 81 Basis- 81, 91 Klassen Basisabstrakte 87 Kommentar 13 Konstruktor 53, 83, 91, 134, 151, 195 Copy- 56, 72, 91, 196 Default- 53, 72, 91, 98, 103–104, 137, 139, 201
H Hallo, Welt 13 Hauptprogramm 14 Header 27 -datei 27 Standard- 14 Hello, world 13 hex 121 Hochkomma 15
I if 31 ifstream 123 includes 184
Inserter 176 inserter 176 int 14–15 ios_base 124, 126 iostream 14, 19, 127 istream 19, 117, 119, 121, 127, 147 istream_iterator 147 istringstream 128 Iterator 132–133, 135, 152, 155, 158–159, 171, 197 Bidirektional- 145, 171, 176, 183 Dereferenzierung 143 Ende- 141–142, 195, 197 Forward- 145, 171–172, 174, 178–180, 183, 185 Input- 143, 147, 155, 158, 167, 171–173, 176–180, 183–184 -kategorien 141, 171 Output- 144, 147, 171, 176–180, 183–184 Random-Access- 146, 152, 158, 171, 180
REGISTER
L less 188 less_equal 188 list 132, 140, 181–182
Elemente einfügen 140 Elemente löschen 140 logical_and 188 logical_not 188 logical_or 188 lower_bound 183
M main 14
Manipulator 16, 121 map 165 max 185 max_element 185 Memberfunktion const- 99 pure virtual 87, 90 merge 183 min 185 min_element 185 minus 188 modulus 188
209
multimap 165–166 multiplies 188, 200 multiset 168
N Namespace 15 std 15 negate 188 new 192 noshowbase 121 noskipws 121 not_equal_to 188 Nullzeiger 108, 193
O Objekt 15 oct 121 ofstream 123–124 open 107 Operand 15 Operator 91 - 146 "!= 143 () 186 * 101, 103, 138, 144 *= 101, 138, 199 + 146, 155 ++ 135, 143–144 += 153 < 142, 146, 181 << 72, 118 <= 142 = 69 == 143 -- 143, 145 [] 137 arithmetischer 77, 101 Ausgabe 15 Compound 24 delete 192 Index- 99 logischer 77
210
new 192
Zuweisung 134, 151, 196 Zuweisungs- 91 ostream 15, 117, 121, 127, 147 ostream_iterator 147, 176, 198 ostringstream 127 out_of_range 111, 113
P pair 166–167, 183 partition 183 plus 188
Polymorphie 85 Präprozessor 14 private 62, 82, 89, 138 Programmierung Generische- 95 protected 62, 82, 89 public 62, 82, 89
R Referenz 16, 25–26, 35, 100, 109 remove 180 remove_copy_if 180 remove_if 180 replace 145, 179 replace_copy 179 replace_copy_if 179 replace_if 179 return 16, 23, 114, 192 reverse 145 Rückgabewert 109
S Schneeseeklee 178 search 174 Seiteneffekt 16 Semikolon 16, 23 set 168 set_difference 184
REGISTER
set_intersection 184 set_symmetric_difference 184 set_union 184 setfill 121 setw 121 short 15 showbase 121 size_t 98 skipws 121 sort 180, 185 stable_partition 183 stable_sort 180 Standardausgabe 13, 15 static 192
Streams Ausgabe- 117 Eingabe- 117 string 17–18, 127–128, 132, 147, 151 Stringliteral 15 strlen 153 strtol 108 struct 43 swap 145 switch 32
U Übergabe Referenz- 25–26, 109 Wert- 24 unary_function 187 unique 178 unique_copy 178 upper_bound 183
V Variable 17 Definition 18 Initialisierung 18 Lebenszeit 17 vector 132, 135–136, 141, 147, 154, 201 Elemente einfügen 136 Elemente löschen 136 virtual 86–87, 90, 193 pure 87, 90
W T
while 32, 144
tee 125
Template 95, 132, 171 -funktion 95–96 -klasse 96–97 -parameter 96, 104, 132, 137 template 96, 171 throw 112 transform 177, 187, 199 true 31 try 113 typedef 132 typename 96, 171
REGISTER
Z Zeiger 109 Null- 108 Zeilenende 16
211
Copyright Daten, Texte, Design und Grafiken dieses eBooks, sowie die eventuell angebotenen eBook-Zusatzdaten sind urheberrechtlich geschützt. Dieses eBook stellen wir lediglich als 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 anderen Websites, der Veränderung, des Weiterverkaufs und der Veröffentlichung bedarf der schriftlichen Genehmigung des Verlags. Bei Fragen zu diesem Thema wenden Sie sich bitte an: mailto:[email protected]
Zusatzdaten Möglicherweise liegt dem gedruckten Buch eine CD-ROM mit Zusatzdaten bei. Die Zurverfügungstellung dieser Daten auf der Website ist eine freiwillige Leistung des Verlags. Der Rechtsweg ist ausgeschlossen.
Hinweis Dieses und andere eBooks können Sie rund um die Uhr und legal auf unserer Website
(http://www.informit.de)
herunterladen