Programmierung II C++ Thomas Letschert FH Giessen–Friedberg (Version vom 10. September 2002) i Programmierung II ii Zusammenfassung Dieser Text ist die Unterlage zu Programmierung II. Die Lernziele der Veranstaltung sind: 1. Konzepte der objektbasierten Programmierung kennen und anwenden können: Prinzipien der Kapselung: Schnittstelle und Implementierung, Geheimnisprinzip; Klassenkonzept: Abstrakte und konkrete Datentypen; Freundschaften kennen und einsetzen. 2. Basiskonzepte der objektorientierten Programmierung kennen und anwenden können: Vererbung: öffentliche Ableitungen und Polymorphismus; Definition einfacher Klassenhierarchien einsetzen; Technische Details der Klassendefinition in C++ beherrschen; 3. Datenstrukturen aufbauen und verwalten können: Stack und Heap; Zeiger und Referenzen; new und delete; Behälterklassen mit Iteratoren. 4. Templates einsetzen können: Funktionstemplates; Klassentemplates. 5. Einfache Dateiverarbeitung beherrschen: Dateikonzept in C++; Daten beliebigen Typs schreiben und lesen; Konzept der persistenten Daten; elementare Dateistrukturen. 6. Elementare Bestandteile einer Entwicklungsumgebung kennen, unterscheiden und einsetzen: Compiler, Binder, Lader; Dateiinklusion; getrennte Übersetzung; Make; Objektbibliotheken. 7. Klassenbiliotheken einsetzen: Einsatz der Standard Template Library (STL) zur Lösung von algorithmischen Standardproblemen. Die STL wird nur unsystematisch an Hand einiger einfacher Beispiele behandelt. 8. Einsatz von UML zur Beschreibung von Programmstrukturen. Einfache Klassenbeziehungen können mit Hilfe einiger der wichtigsten Elemente der UML–Notation dargestellt werden. UML wird nur sehr unsystematisch an Hand weniger sehr einfacher Beispiele behandelt. Die beschriebenen Elemente von C++ entsprechen dem aktuellen Sprachstandard. Die vorausgesetzten Kenntnisse und Fertigkeiten sind im Wesentlichen durch die Lernziele der Veranstaltung Programmierung I beschrieben. Daneben wird vorausgesetzt, dass der Leser mit den grundlegensten Begriffen der Informatik vertraut ist. Zahlsysteme sowie das Konzept einer Grammatik sollten beispielsweise bekannt sein. Die Übungen und Lösungshinweise sind ein wesentlicher Bestandteil dieses Lehrmaterials. Es wird dringend empfohlen sich mit ihnen zu beschäftigen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg iii Inhaltsverzeichnis 1 Klassen 1 1.1 Klassen: Verbunde mit Geheimnissen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.2 Kapselung: Klassen als Softwarekomponenten mit Schnittstelle und Implementierung . . . . . . . 4 1.3 Freunde . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 1.4 Überladene Operatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 1.5 Inline Funktionen und Methoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 1.6 Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14 1.7 Initialisierung von Objekten, Initialisierer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18 1.8 Statische Komponenten einer Klasse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 1.9 Konstante Komponenten, konstante Parameter . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 1.10 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 2 Objektorientierte Programmierung: Entwurf von Klassendefinitionen 37 2.1 Referenzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37 2.2 Ein– und Ausgabe selbst definierter Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 2.3 Zustands– und Wertorientierte Klassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 2.4 Von Abstrakten zu Konkreten Datentypen: Zuweisung und Kopie . . . . . . . . . . . . . . . . . . 46 2.5 Beziehungen zwischen Klassendefinitionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 2.6 Beispiel: Geometrische Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55 2.7 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57 3 Verweise und dynamische Objekte 60 3.1 Zeiger (Pointer) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60 3.2 Definition von Zeigervariablen und –Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64 3.3 Verkettete Objekte, der Null–Zeiger . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 68 3.4 Dynamisch erzeugte Objekte: Der new– und delete–Operator . . . . . . . . . . . . . . . . . . 72 3.5 Zeiger und Felder . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76 3.6 Zeiger, Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82 3.7 Zeiger und const . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85 3.8 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91 4 Programmorganisation 100 4.1 Konsolenanwendungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 100 4.2 Speicherbereiche und Speicherschutz . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102 4.3 Übersetzen und Binden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105 4.4 Präprozessor und Inklusionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106 4.5 Getrennte Übersetzung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110 4.6 Konventionen zur Gestaltung von Übersetzungseinheiten . . . . . . . . . . . . . . . . . . . . . . 112 4.7 Make . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122 4.8 Bibliotheken . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 124 Programmierung II iv 4.9 Beispiel: Geometrische Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 127 4.10 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130 5 Datenstrukturen 132 5.1 Verkettete Liste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 132 5.2 Menge als konkreter Datentyp . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 137 5.3 Wiederverwendung: Die Liste als allgemeine Behälterklasse . . . . . . . . . . . . . . . . . . . . 139 5.4 Cursorkonzept: Liste mit aktueller Position . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142 5.5 Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144 5.6 Konstante Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150 5.7 Beispiel Syntaxbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152 5.8 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160 6 Schablonen (Templates) 163 6.1 Funktionsschablonen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163 6.2 Klassenschablonen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 170 6.3 Schablone mit Freunden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174 6.4 Datenstrukturen und Algorithmen mit Schablonen . . . . . . . . . . . . . . . . . . . . . . . . . . 179 6.5 Standard Template Library STL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183 6.6 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 189 7 Vererbung 192 7.1 Basisklasse und Abgeleitete Klasse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 192 7.2 Vererbung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 194 7.3 Sichtbarkeit und Vererbung: public, protected und private . . . . . . . . . . . . . . . . . . . . . . 199 7.4 Vererbungstypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203 7.5 Konstruktoren und Destruktoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 204 7.6 Beispiel Sortierte Liste: Abgeleitete Klasse als konkreter Datentyp . . . . . . . . . . . . . . . . . 212 7.7 Beispiel Syntaxbaum: Ableitung um Typvarianten zu ermöglichen . . . . . . . . . . . . . . . . . 214 7.8 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 218 8 Polymorphismus 222 8.1 Virtuelle Methoden: Das Gleiche auf unterschiedliche Arten tun . . . . . . . . . . . . . . . . . . 222 8.2 Polymorphismus: Typanalyse zur Laufzeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 224 8.3 Konstruktoren, Destruktoren, Zuweisungen und Polymorphismus . . . . . . . . . . . . . . . . . . 227 8.4 Abstrakte Basisklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 229 8.5 Umschlagklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231 8.6 Geklonte Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233 8.7 Beispiel: Tupel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 237 8.8 Beispiel: Syntaxbaum, Implementierung mit Polymorphismus . . . . . . . . . . . . . . . . . . . 239 8.9 Typinformationen und dynamische Typkonversionen . . . . . . . . . . . . . . . . . . . . . . . . 243 8.10 Beispiel: Multimethoden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 246 Th Letschert, Fachbereich MNI, FH Giessen–Friedberg v 8.11 Vererbung und Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 252 8.12 Übersicht: Mechanismen der Vererbung und des Polymorphismus . . . . . . . . . . . . . . . . . 254 8.13 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 257 9 Dateien 263 9.1 Sequentielle Dateien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263 9.2 Ein– und Ausgabe selbst definierter Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 269 9.3 Darstellungskonversionen und String–Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . 273 9.4 Dateien in Iteratoren umwandeln . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 277 9.5 Wahlfreier Zugriff . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 281 9.6 Dateistrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 285 9.7 Binärdateien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 288 9.8 Dateiabstraktion: Datei als Speicher von Sätzen fester Länge . . . . . . . . . . . . . . . . . . . . 293 9.9 Iteratoren auf abstrakten Satz–Dateien . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 299 9.10 Verwaltung von Sätzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 300 9.11 Übungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 303 A STL: wichtige Behälterklassen 305 A.1 Vektoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 305 A.2 Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 305 A.3 Mengen und Multimengen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 306 A.4 Abbildungen (Sortierte Schlüssel–Wert–Paare) . . . . . . . . . . . . . . . . . . . . . . . . . . . 307 B Lösungshinweise 309 B.1 Lösungshinweise zu Kapitel 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 309 B.2 Lösungshinweise zu Kapitel 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 319 B.3 Lösungshinweise zu Kapitel 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 325 B.4 Lösungshinweise zu Kapitel 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 337 B.5 Lösungshinweise zu Kapitel 5 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 341 B.6 Lösungshinweise zu Kapitel 6 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 347 B.7 Lösungshinweise zu Kapitel 7 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 350 B.8 Lösungshinweise zu Kapitel 8 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 353 B.9 Lösungshinweise zu Kapitel 9 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 359 Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 1 1 Klassen 1.1 Klassen: Verbunde mit Geheimnissen 1.1.1 Klassen: Verbunde mit privaten und öffentlichen Komponenten Klassen sind eine Variante der Verbunde (Structs). Umgekehrt sind Verbunde eine Sonderform der Klassen. Einem Verbund S, der als struct S T1 s1; T2 s2; ... ; class S public: T1 s1; T2 s2; ... ; definiert wurde, entspricht die Klasse Das Schlüsselwort struct wurde hier durch class ersetzt und public leitet die Definition der Komponenten ein. Die Kennzeichnung public (= öffentlich) sagt, dass s1, s2 und so weiter public = öffentlich sind. Ein Verbund ist eine Klasse deren Komponenten zunächst einmal öffentlich (public) sind. Umgekehrt sind Klassen Verbunde, bei denen alle Komponeten zunächst einmal privat (private) sind. Einer Klasse K, die als class K T1 s1; T2 s2; ... ; definiert wurde, entspricht der Verbund struct K private: T1 s1; T2 s2; ... ; In Klassen ist also alles privat und in einem Verbund ist alles public. Durch Angabe der Schlüsselworte public und private kann diese Voreinstellung verändert werden. In Klassen und Verbunden kann zwischen öffentlichen und privaten Komponenten unterschieden werden. In Verbunden sind Komponenten öffentlich, wenn sie nicht explizit als privat gekennzeichnet werden. In Klassen sind Komponenten privat, wenn sie nicht explizit als öffentlich gekennzeichnet werden. Der Einfachheit halber, und entsprechend der üblichen Praxis, belassen wir es im Folgenden dabei, dass in Verbunden alles öffentlich ist und benutzen Klassen, wenn zwischen öffentlich und privat unterschieden werden soll. 1.1.2 Verwendung von public und private Die Klasse Vektor mit öffentlichem x und y class Vektor public: float x, y; ; ist das Gleiche wie der Verbund struct Vektor float x, y; ; Lässt man in der Klassendefinition aber public weg, dann werden x und y zu privaten Komponenten von Vektor: class Vektor float x, y; ; // x und y sind privat Programmierung II 2 Mit dem Schlüsselwort private kann man dies auch explizit ausdrücken: class Vektor { private: float x, y; }; // x und y sind privat Öffentliche und private Komponenten können in einer Klasse auch gemischt auftreten: class Vektor { public: float x; private: float y; }; x ist jetzt öffentlich und y ist privat. Genau wie Datenkomponenten können auch Methoden – inklusive Operatoren – öffentlich oder privat sein. Beispiel: class Vektor { public: Vektor operator+ (Vektor); private: float x, y; }; // oeffentliche Methode // private Daten Nur die Komponenten einer Klasse, die in der Definition der Klasse hinter dem Schlüsselwort public erscheinen, sind öffentlich, die anderen sind privat. Beispiel: class S1 { int x; int f (int); public: int y; int g (int); }; // privat // privat // oeffentlich // oeffentlich Mit dem Schlüsselwort public wird der private Teil beendet und ein öffentlicher begonnen. Mit private wird ein öffentlicher Teil beendet und ein privater begonnen. Die beiden Schlüsselworte können beliebig oft und in beliebiger Reihenfolge angewendet werden. Beispiel: class S2 { int x; public: int y; int g (int); private: int f (int); }; 1.1.3 // privat // oeffentlich // oeffentlich // privat Eingeschränkte Sichtbarkeit der privaten Komponenten einer Klasse Nach so viel Formalismus wird es Zeit sich mit der Bedeutung von public und private zu beschäftigen. Die öffentlichen Komponenten einer Klasse verhalten sich in jeder Beziehung genau so wie die “normalen” Komponenten von Verbunden (struct–Typen). Die privaten Komponenten dagegen haben eine eingeschränkte Sichtbarkeit: sie können nur innerhalb der Klasse verwendet werden, zu der sie gehören. Beispiel: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg class S3 { int x; public: int f(int); int y; }; 3 // privat // oeffentlich // oeffentlich Die Komponente x kann nur von der Klasse S3 selbst verwendet werden. Beispielsweise in der Methode f, die zu S3 gehört: int S3::f (int p) {// Wir sind in S3::f, also innerhalb von S3 ++x; // OK: privat aber intern genutzt return y; // OK: y ist oeffentlich } Außerhalb des Sichtbarkeitsbereichs von S3 ist x im Gegensatz zu y und f nicht zugreifbar: int main () { S3 s; s.x = 10; ++s.y; } // Wir sind in main, also ausserhalb von S3 // FEHLER: x ist eine private Komponente der Klasse S3 // OK : y ist "offentlich Hier wird von main aus (der Punktoperator “.x” taucht in main auf) versucht auf s.x zuzugreifen, das ist nicht erlaubt. x ist eine private Komponente von S3 und der “Zugreifer” main gehört natürlich nicht zu S3. Der Zugriff auf s.y ist dagegen von überall her uneingeschränkt möglich. Private Komponenten dürfen nur in Methoden der Klasse benutzt werden, zu der sie gehören. Der Zugriff auf private Komponenten einer Klasse ist darum nur von dieser Klasse aus erlaubt. Es kommt dabei darauf an, wo der Zugriff (der Punktoperator) auftaucht; in einer Methode der Klasse: OK!, Irgendwo anders: Nicht OK! 1.1.4 Klassen, nicht Objekte, haben eine eigene Privatsphäre public und private operieren auf der Ebene von Klassen, nicht auf der Ebene von Objekten. Mit private werden die Komponenten eines Objekts vor dem Zugriff von Funktionen und Methoden anderer Klassen geschützt. Die Methoden der Klasse, zu der ein Objekt gehört, haben vollen Zugriff auf die eigenen Komponenten und auf die Komponenten aller Objekte der gleichen Klasse. Solange sie zu gleichen Klasse gehören können also Objekte auf die privaten Komponenten anderer Objekte zugreifen. Beispiel: class C { public: int a; void g(); private: int b; }; C x, y; void C::g() {// wir sind in C: alles erlaubt ++b; // OK: eigene private Komponente ++x.b; // OK: private Komponente von x } int main () { // wir sind in main, nicht in C: nur Zugriff auf public erlaubt y.g(); // OK: y greift in Methode g auf Privates von sich selbst und von x zu ++x.b; // FEHLER } Programmierung II 4 Innerhalb der Klasse C, also in einer Methode von C, ist jeder Zugriff erlaubt, außerhalb nur der auf öffentliche Komponenten. Von welchem Objekt dabei auf welches andere Objekt zugegriffen wird, ist völlig irrelevant. private heißt private Komponente der Klasse insgesamt: Für den internen Gebrauch von allen Methoden aller Objekte der Klasse. Klassen definieren die Familie ihrer Objekte. Innerhalb einer Familie gibt es keine Privatsphäre. Mitglieder der eigenen Familie dürfen alles anfassen. Fremden klopft der Compiler allerdings auf die Finger, wenn sie die Privatsphäre der Familie (= Klasse) nicht achten. private ist also gar nicht so privat. Innerhalb einer Methode einer Klasse S kann auf die privaten Komponenten aller dort sichtbaren Objekte der Klasse S zugegriffen werden. Sichtbar sind nach den üblichen Sichtbarkeitsregeln die lokalen Variablen, die Parameter und die globalen Variablen – soweit jeweils vorhanden. 1.2 1.2.1 Kapselung: Klassen als Softwarekomponenten mit Schnittstelle und Implementierung Öffentlich und privat als Schnittstelle und Implementierung Private Klassenkomponenten mit ihrer beschränkten Sichtbarkeit unterscheiden sich von den Komponenten eines Verbunds nur dadurch, dass man weniger mit ihnen machen kann. Das neue Konzept der Klassen bringt also eine Einschränkung gegenüber den Verbunden! Diese Beschränkung soll die Entwicklung komplexer Softwaresysteme unterstützen. Mit ihr kann eine Klasse als Softwarekomponente mit klar definierter Funktionalität und Implementierung realisiert werden. Mit private und public können die Komponenten einer Klasse in zwei Gruppen aufgeteilt werden. Private Komponenten sind nur für interne Zwecke da. Sie sind Hilfskonstrukte der Implementierung und gehen die Benutzer der Klasse nichts an. Die öffentlichen sind dagegen dazu gedacht, von außen benutzt zu werden. Mit der Unterscheidung in privat und öffentlich können die Komponenten einer Klasse sortiert werden. Man nennt dies das Prinzip der Kapselung: Öffentliche Schnittstelle: Von außen sicht– und benutzbare Bestandteile. Private Implementierung: Rein interne Bestandteile, die den Benutzer nicht zu interessieren haben. Mit “Benutzer” ist dabei der Quellcode – und dessen Entwickler – gemeint, der nicht zur Klasse gehört, sondern sie nur benutzt. 1.2.2 Zwei Arten von Benutzern Bei dem Wort “Benutzer” denkt man meist an einen Menschen, der ein fertiges (Software–) Produkt benutzt. Der Benutzer steht im Gegensatz zum Entwickler. Der eine stellt etwas her, der andere benutzt es. Diese Unterscheidung ist für die Wirklichkeit der Softwarebranche zu grob. Ein Entwickler hat es nur recht selten mit dem sogenannten End–Benutzer zu tun. Da Software normalerweise im Team entwickelt wird, kommuniziert er meist mit anderen Entwicklern. Zwischen den Entwicklern, die an einem Projekt arbeiten, bestehen ebenfalls Benutzt–Relationen. Softwarekomponenten benutzen andere Komponenten um ihre eigene Funktion zu erfüllen. In folgenden Beispiel benutzt die kgv–Funktion die ggt–Funktion: unsigned int ggt(unsigned int a, unsigned int b) { while (a != b) if ( a > b ) a = a-b; else b = b-a; return a; } unsigned int kgv(unsigned int a, unsigned int b) { return a*b/ggt(a,b); } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 5 Die Benutzt–Relation zwischen Softwarekomponenten ist von ganz entscheidender Bedeutung für die Strukturierung von Software. Ein Benutzer ist darum im Folgenden in der Regel ein Stück Software bzw. dessen Autor. Der Mensch, der ein fertiges Produkt benutzt, wird explizit End–Benutzer genannt. 1.2.3 Schnittstelle und Implementierung Teilt man Softwareprodukte in Komponenten auf, die sich gegenseitig benutzen, dann kommt man naturgemäß dazu, die einzelnen Komponenten weiter in Schnittstelle und Implementierung aufzuteilen. Für die kgv–Funktion oben (und deren Autorin) ist nur die Schnittstelle von ggt interessant: Was gebe ich wie hinein, damit was wie herauskommt. Die Implementierung – Schleife oder Rekursion – ist vom Standpunkt des Benutzers völlig unerheblich. Hauptsache es funktioniert, wie ist egal. Da alle halbwegs komplexen Systeme in Komponenten aufgeteilt werden, ist die Trennung von Schnittstelle und Implementierung ein weitverbreitetes Konstruktionsprinzip technischer Systeme, das das Leben enorm erleichtert. Ein Lichtschalter beispielsweise hat eine Schnittstelle und eine Implementierung. Man kann mit ihm das Licht an– und ausmachen, indem die Schalterfläche umgelegt wird. Das ist seine Schnittstelle. Intern werden Kontakte geschlossen. Das ist die Implementierung. Selbstverständlich ist es auch möglich den Schalter aufzuschrauben und die Kontakte direkt zu verbinden. Das ist aber eine ebenso unübliche wie unerwünschte Art den Schalter zu bedienen. Dieses bewährte Prinzip wird auf die Software übertragen indem man streng unterscheidet zwischen den öffentlichen, für den Benutzer gedachten, Komponenten einer Klasse und den internen, den privaten. (Siehe Abbildung 1): Benutzer public private Schnittstelle Implementierung benutzte Komponente Abbildung 1: Das Prinzip der Kapselung 1.2.4 Mengen–Typ als Verbund mit informaler Schnittstelle Ein Verbund–Typ Menge zur Darstellung von Mengen positiver ganzer Zahlen könnte wie folgt aussehen: //Die Klasse: struct Menge { // Zugriff (Schnittstelle): void einfuege (int); void entnehme (int); bool istEnthalten (int); // interne Speicherung: int m[10]; // Mengenelemente int a; // Zahl der belegten Pl"atze in m }; //Ihr Benutzer: int main () { Menge m; Programmierung II 6 m.einfuege (1); m.a = 2; m.m[2] = 3; // erwuenschter Zugriff // nicht erwuenschter Zugriff // nicht erwuenschter Zugriff } Die Schnittstelle besteht hier aus den Methoden einfuege, entnehme und istEnthalten. Die Komponenten m und a dienen der internen Speicherung und Verwaltung der Mengenelemente. Sie sollten nicht direkt benutzt werden. Ihre genaue Verwendung ist für die Benutzung des Typs darum irrelevant: sie gehören zur Implementierung. Der Benutzer sollte seine Finger von ihnen lassen. Durch Aufruf der Methode einfuege sollten Elemente eingefügt werden. Man kann statt dessen auch direkt auf m und a zugreifen. Das ist aber so unerwünscht wie das Einschalten des Lichts durch Abschrauben des Schalterdeckels und Kurzschluss der internen Kontakte. Man kann darüber den Kopf schütteln, verhindern kann man es aber nicht. 1.2.5 Mengen–Typ als Klasse mit expliziter Trennung von Schnittstelle und Implementierung Mit dem Klassen–Konstrukt (mit der Aufteilung in öffentlich und privat) kann die Zweiteilung klar zum Ausdruck gebracht und (vom Compiler) überwacht werden. private entspricht dabei dem Deckel auf dem Lichtschalter, der den direkten Zugriff auf die Kontakte verhindert: // Die Klasse class Menge { public: void einfuege void entnehme bool istEnthalten private: int m[10]; int a; }; //Ihr Benutzer: int main () { Menge m; m.einfuege (1); m.a = 2; m.m[2] = 3; } // Aha, Schnittstelle: (int); // zur allgemeinen Benutzung (int); // freigegeben! (int); // Aha, Implementierung: // geht nur Mengenimplementierer // was an! // OK, Zugriff moeglich // FEHLER: Zugriff nicht moeglich // FEHLER: Zugriff nicht moeglich Die Aufteilung in Schnittstelle und Implementierung in diesem Beispiel ist: Schnittstelle: – void einfuege (int); – void entnehme (int); – bool istEnthalten (int); Implementierung: – int m[10]; – int a; – Die Körper der Methoden – privat oder öffentlich – gehören zur Implementierung: void Menge::einfuege (int i) ... void Menge::entnehme (int i) ... bool Menge::istEnthalten (int i) ... Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 7 Bei dieser Mengen–Klasse können Elemente nur auf dem offiziellen Weg über die entsprechende Methode eingefügt oder entnommen werden. Ein direkter Zugriff auf die Innereien ist nicht möglich. Der Deckel kann nicht abgeschraubt werden. 1.2.6 Klassendiagramm zur Darstellung einer Klasse Softwarekonponenten werden der besseren Übersichtlichkeit halber oft grafisch dargestellt. Nach vielen Jahren des völligen Chaos’ durch konkurrierende Darstellungskonventionen hat sich jetzt UML 1 allgemein durchgesetzt. Die Klasse Menge wird in UML folgendermaßen dargestellt (siehe Abbildung 2): Klassenname Menge Datenkomponenten −m −a + einfuege + entnehme + istEnthalten Methoden +: public (Bestandteil der Schnittstelle) −: private Abbildung 2: Klassendiagramm der Menge Klassen werden durch Rechtecke dargestellt. Oben enthalten sie den Klassennamen, es folgen die Datenkomponenten und dann die Methoden. Öffentliche Komponenten werden mit einem + gekennzeichnet, private mit einem -. Alles mit einem + gehört also zur Schnittstelle. So wie in diesem Beispiel sind sehr oft alle Datenkomponenten privat. Die Kennzeichnungen + und - kı̈nnen auch weggelassen werden. Die Aufteilung eines Stücks Software in Schnittstelle und Implementierung kann in UML leider nur mit optionalem + und -, ausgedrückt werden. 2 1.2.7 Geheimnisse nutzen den Anwendern: Was ich nicht weiß, kann ich nicht missverstehen oder vergessen. Die Kapselung, also die Trennung von Schnittstelle und Implementierung, wird oft auch als Geheimnisprinzip bezeichnet. Die Implementierung ist ein Geheimnis der Klasse. Sie geht keinen ihrer Benutzer etwas an. Die klare Trennung von Interna und benutzbarer Schnittstelle nutzt zunächst einmal den Benutzern/Anwendern der Klasse. Sie können sich auf das konzentrieren, was als öffentlich deklariert und somit zur Benutzung freigegeben wurde. Jeder, der ein komplexes System anwenden muss, wird dankbar alles zur Kenntnis nehmen, was er nicht kennen oder wissen muss. Ein Lichtschalter wird einfach gedrückt. Ich muss mir dabei keine Gedanken darüber machen, ob es sich um einen einfachen Schalter oder einen Wechselschalter handelt, ob er ein Relais bedient oder direkt schaltet, welche Kontakte verbunden werden, und so weiter. Ich sehe die Schnittstelle und kann das Ding bedienen. 1.2.8 Geheimnisse nutzen den Implementierern: Was nur ich weis, brauche ich nicht abzusprechen oder zu erklären. Auf die privaten Komponenten kann nur innerhalb der Methoden der Klasse selbst zugegriffen werden. Damit ist garantiert, dass sie jederzeit und ohne Absprache mit dem Benutzer der Klasse geändert, gestrichen oder durch 1 UML ist nicht als direkte Illustration des Quelltextes von Programmen gedacht – es kann aber zu genau diesem Zweck genutzt werden. Es ist eine Notation für Spezifikationen. UML–Diagramme enthalten darum oft nur das “Wichtige” oder das “was man jetzt schon weiß”. UML, die Unified Modeling Language, wird in der Softwaretechnik näher behandelt. 2 Importschnittstellen, also das was eine Komonenten von anderen benötigt, können in UML gar nicht dargestellt werden. Es spricht nicht gerade für den allgemeinen Zustand der Softwaretechnik (und der Informatik allgemein), dass UML – die wichtigste und am weitetesten verbreitete Notation – das wichtigste Konzept im Softwareentwurf – Komponenten mit Schnittstelle und Implementierung – nur jämmerlich ausdrücken kann. Programmierung II 8 gänzlich andere ersetzen werden können. Die Elektrik eines Hauses kann geändert werden, ohne dass jeder, der das Licht anschalten will, einen Lehrgang besuchen muss. Alle Lichtschalter haben die gleiche Schnittstelle, egal, ob es sich um Wechsel– oder Relaisschalter handelt. Die einen können darum jederzeit ohne Absprache gegen die anderen ausgetauscht werden. 3 Genauso kann die Implementierung der Menge geändert werden, ohne die Benutzer auch nur darüber informieren zu müssen. Kopplung von A und B Wissen über B, das notwendig ist, um B zu benutzen. Komponente A Komponente benutzt B Implementierer von A Abbildung 3: Der Begriff der Kopplung 1.2.9 Kopplung von Softwarekomponenten Unter Kopplung versteht man die Verzahnung von Softwarekomponenten die dadurch entsteht, dass das Wissen über das Wesen der einen Bestandteil der anderen ist. So ist der Benutzer der Klasse Menge an die Klasse Menge gekoppelt, weil er beispielsweise wissen muss, wie die Methode zum Einfügen eines Elementes heißt. Dieses Wissen ist im Quellcode des Benutzers “fest verdrahtet” und beide sind damit gekoppelt (Siehe Abbildung 3). struct void void bool Menge { einfuege (int); entnehme (int); istEnthalten (int); Jetzt benutzt er schon überall a als Obergrenze, ich kann meinen Code darum nicht mehr ändern. int m[10]; int a; }; Implementierung Wie meint sie denn das mit a und m? Anwendung War a die Obergrenze in m oder die Anzahl oder was? Menge x; x.m[a++] = 15; ?? oder x.m[++a] = 15; ?? oder x.m[a−−] = 15; Abbildung 4: Zu enge Kopplung von Anwendung und Implementierung Die Kopplung von Softwarekomponenten sollte einerseits klar erkennbar und andererseits so klein wie möglich sein. Der Grad der Kopplung wird durch das Geschick oder Ungeschick der Software–Entwickler bestimmt (siehe Abbildung 4). Die Unterscheidung von Schnittstelle und Implementierung kann die Kopplung nur dokumentieren: 3 Benutzer, die direkt auf die Implementierung zugreifen sterben wegen Stromschlag aus. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9 alles was zur Schnittstelle gehört, aber nur das, kann in einer anderen Komponente benutzt werden. Die Kopplung zwischen dem Bneutzer und der Realisation einer Klasse wird auf die Schnittstelle – also die öffentlichen Datenfelder und Methoden – begrenzt. Eine Trennung von Privatem und Öffentlichem gibt es auch bei Verbunden – man denke nur an struct Menge ... ;. Bei einer Klasse kann sie aber im Code mit den Schlüsselworten private und public dargelegt und so vom Compiler überwacht werden. Es bleibt natürlich weiterhin den Entwicklern überlassen zu entscheiden, was zu welcher Kategorie gehört. Diese Entscheidung ist oft genug weder eindeutig noch einfach. 1.2.10 private und public dienen dazu den Programmcode zu organisieren Mit dem Schlüsselwort private wird das Aussehen von Programmtexten beeinflusst. Mit ihm sollen bestimmte Komponenten einer Klasse – nicht vor Menschen (!) –, sondern vor anderen Textstücken im gleichen Programm “verborgen” werden. Wobei “verborgen” nichts anderes heißt, als dass der Compiler eine Fehlermeldung ausgibt, wenn er einen “verborgenen” (privaten) Bezeichner an der falschen Stelle antrifft. Dies alles bezieht sich nur auf den Quelltext von Programmen. Was wann welcher Mensch sehen oder nicht sehen darf, ist eine völlig andere Frage. Ob die Programmiererin, die die Definition der Klasse Menge im Beispiel oben benutzt, die Definition der privaten Komponenten m und a mit eigenen Augen sehen darf, hat vielleicht etwas mit dem Betriebsklima oder mit Lizenzverträgen zu tun. Es wird ihr aber vom Compiler weder verboten noch erlaubt. Er kann entsprechende Regeln nicht einmal überwachen. Der Compiler interessiert sich nicht für die Beziehungen zwischen Menschen sondern nur für Programmtexte. Er prüft und übersetzt einen Text ohne zu wissen von wem er stammt. Mit private kann der Programmierer dem Compiler nur eine Absicht für die Organisation des Quelltextes bekannt gegeben werden. Dieser prüft dann, ob diese Absicht im gesamten Programm auch eingehalten wird. private hat also ganz und gar nichts mit der Privatsphäre, den Geheimnissen oder den Besitzverhältnissen zwischen Menschen zu tun. Es trennt lediglich die Schnittstelle (das “ Öffentliche”) und die Implementierung (das “Private”) in einem Stück Software. 1.3 1.3.1 Freunde Freundschaft in C++ Freie Funktionen und Methoden anderer Klassen dürfen nicht auf die privaten Komponenten einer Klasse zugreifen. Ein Objekt ist damit vor den indiskreten Zugriffen “fremder” Objekte und Funktionen geschützt. Mit dem Konzept der Freundschaft kann dieses Prinzip aufgehoben werden. Ein Freund (in C++) ist jemand, der die Privatsphäre nicht achten muss. Klassen können Funktionen und andere Klassen zu ihren Freunden erklären. Der Freund hat dann vollen Zugriff auf alle privaten Komponenten. Freundschaft beruht (zumindest in C++) nicht unbedingt auf Gegenseitigkeit. Wenn Klasse A eine Klasse B zu ihrem Freund erklärt, erlaubt sie B den Zugriff auf ihre Privatsphäre. Damit hat sie selbst aber noch lange keinen Zugriff auf die privaten Komponenten von B. Freunde einer Klasse sind Programmstücke (Klassen oder freie Funktionen) für welche die private–Beschränkungen dieser Klasse nicht gelten. 1.3.2 Funktionen als Freunde Im folgenden Beispiel erklärt die Klasse Vektor die freie Funktion void drucke(Vektor) zu ihrem Freund: class Vektor { friend void drucke (Vektor); // drucke ist mein Freund Programmierung II 10 public: ... private: float x, y; }; // und darf auf x und y zugreifen // drucke darf hier zugreifen drucke darf damit auf die privaten Komponenten x und y jedes Objekts vom Typ Vektor zugreifen: void drucke (Vektor v) { cout << "(" << v.x << ", " << v.y << ")\n"; } Befreundete freie Funktionen sind damit eine Alternative zu Methoden. Genau wie Methoden dürfen sie auf die privaten Komponenten einer Klasse zugreifen. Auch freie Operatoren können Freunde einer Klasse sein: class Vektor { friend void friend Vektor public: ... private: float x, y; }; drucke (Vektor); operator+ (Vektor, Vektor); // befreundeter freier Operator: Vektor operator+ (Vektor v1, Vektor v2) { Vektor res; res.x=v1.x+v2.x; res.y=v1.y+v2.y; return res; } // Operator als Methode: Vektor Vektor::operator- (Vektor v2) { Vektor res; res.x=x+v.x; res.y=y+v.y; return res; } Mit dem Konzept der Freundschaft sollte sparsam und mit Überlegung umgegangen werden. Es setzt private ausser Kraft und schaltet damit etwas ab, das allgemein als wichtiges und sinnvollen Prinzip der Software– Entwicklung angesehen wird. 1.3.3 Klassen als Freunde Wird eine Klasse zum Freund einer anderen erklärt, dann bedeutet dies, dass alle Methoden des Freundes Zugriff erhalten. Selbstverständlich erklärt man nur Klassen zu Freunden, die eng zusammenarbeiten. Beispiel: class Vektor { friend class Punkt; public: ... private: float x, y; }; class Punkt { public: // Klasse Punkt ist mein Freund Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 11 ... float entfernt_von (Punkt); private: Vektor pos; }; ... // Alle Methoden von Punkt duerfen auf alle // Komponenten von Vektor zugreifen. ... float Punkt::entfernt_von (Punkt p) { // Zugriff auf die privaten Komponenten von pos return sqrt ( (pos.x - p.pos.x)*(pos.x - p.pos.x) + (pos.y - p.pos.y)*(pos.y - p.pos.y)); } Hier darf jede Methode von Punkt auf alles von Vektor zugreifen, aber nicht umgekehrt. 4 Auch mit der Freundschaft zwischen Klassen sollte sparsam umgegangen werden. Nur Klassen die inhaltlich sehr eng zusammengehören sollten Freunde sein. 1.4 1.4.1 Überladene Operatoren Überladene Operatoren als freie Funktionen Operatoren können bekanntlich mit neuen Definitionen belegt werden. Beispielsweise kann die Addition von Vektoren als Operator definiert werden: struct Vektor { ... float x; float y; }; ... Vektor operator+ (Vektor a, Vektor b) { Vektor res; res.x = a.x + b.x; res.y = a.y + b.y; return res; } ... Vektor a, b, c; ... a = b + c; Die Definition eines solchen Operators unterscheidet sich kaum von der einer “normalen” freien Funktion. Die Anweisung a = b + c; ist als a = operator+ (b, c); zu interpretieren. 4 Man beachte, dass, ohne die erklärte Freundschft, Punkt keinesfalls auf pos.x und pos.y hätte zugreifen dürfen, obwohl pos eine Komponente von Punkt ist. Programmierung II 12 1.4.2 Überladene Operatoren in Klassen Statt als freie Funktionen können Operatoren auch als Methoden definiert werden. Das ist natürlich besonders interessant, wenn sie auf private Komponenten zugreifen müssen, was für eine freie Funktion (ohne Freundschaft) ja nicht möglich ist. Ein binärer Operator, z.B. der operator+, wird als Methode stets mit einem Argument definiert. Ist operator+ eine Methode (mit einem Argument) von a dann wird a+b als a.operator+(b) interpretiert. Unäre Operatoren werden entsprechend als Methoden ohne Argument definiert. Ist beispielsweise operator++ eine Methode (ohne Argument) von a dann wird ++a als a.operator++ () interpretiert. Die Vektoraddition mit “+” als Methode ist: class Vektor { public: ... Vektor operator+ (Vektor b); // binaeres + ... private: float x; float y; }; ... Vektor Vektor::operator+ (Vektor b) { // addiere b zu mir! Vektor res; res.x = x + b.x; res.y = y + b.y; return res; } ... Vektor x, y, z; x = y + z; Operatoren als Methoden können, im Gegensatz zu Operatoren als freie Funktionen, auf private Komponenten zugreifen. 1.4.3 Unäre Operatoren Unäre Operatoren können als Methode definiert werden: class Vektor { public: ... Vektor operator- () { Vektor res; res.x = -x; res.y = -y; return res; } // unaeres Minus // als Methode Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 13 ... private: float x; float y; }; // Benutzung: int main () { Vektor a, b; b = -a; } // unaeres Minus auf einem Vektor Sie können auch als freie Funktionen definiert werden: Vektor operator- (Vektor v) { // unaeres - als freie Funktion return Vektor (-v.x, -v.y); } Die freie Funktion wird genauso wie die Methode benutzt. 1.5 1.5.1 Inline Funktionen und Methoden Inline Funktionen Eine einfache Funktion wie etwa int max (int a, int b) { if (a > b) return a; else return b; } kann die Lesbarkeit eines Programms beträchtlich erhöhen. Man darf aber den erhöhten Aufwand der Funktion nicht vergessen. Wird eine solche kleine Funktion sehr oft aufgerufen – speziell in einer Schleife –, dann wird die verbesserte Lesbarkeit mit einem schlechteren Laufzeitverhalten erkauft. Mit inline Funktionen kann die Lesbarkeit verbessert werden, ohne dass die Effizienz des Programms leidet. inline int max (int a, int b) { if (a > b) return a; else return b; } Das Schlüsselwort inline weist den Compiler an, nicht wirklich eine Funktion zu erzeugen und aufzurufen, sondern jeden Funktionsaufruf im Quellprogramm durch den Code des Funktionskörpers zu ersetzen. Nur wirklich kleine und einfache Funktionen sollten inline sein. Zum einen erzeugt ein Funktionsaufruf nur einen geringen Zusatzaufwand und zum anderen kann mit der Expansion umfangreicher Funktionen der Maschinencode wesentlich größer werden, was eventuell auch negative Auswirkungen auf das Laufzeitverhalten hat. Komplexe Funktionen können u.U. auch vom Compiler gar nicht korrekt expandiert werden. Die Inline–Direktive ist darum nur ein Hinweis, den der Compiler befolgen kann, aber nicht befolgen muss. 1.5.2 Inline Methoden Methoden können natürlich ebenfalls als inline deklariert werden. Beispiel: class C { public: inline int f (); int x; }; inline int C::f () { return x; } Programmierung II 14 Das Schlüsselwort inline ist hier sowohl der Deklaration der Methode als auch der Definition vorgestellt. Eines der beiden kann weggelassen werden. 1.5.3 Inline Methoden innerhalb der Klassendefinition Inline Methoden können direkt in die Klassendefinition platziert werden. Beispiel: class C { public: inline int f () { return x; } int x; }; Da dies nur für inline Methoden möglich ist, ist das Schlüsselwort inline redundant und kann weggelassen werden: class C { public: int f () { return x; } int x; }; // Inline Methode Es ist üblich kleine Methoden in dieser Art inline zu definieren. 1.6 1.6.1 Konstruktoren und Destruktoren Methoden zur Initialisierung Der Klasse Menge von oben fehlt noch eine Initialisierungsmethode mit der ein Mengenobjekt in einen definierten Anfangszustand versetzt werden kann. Typischerweise wird man eine Menge als leere Menge initialisieren und die Initialisierungs–Methode init nennen: class Menge { public: void init void einfuege void entnehme bool istEnthalten private: int m[10]; int a; }; () { a = 0; } (int); (int); (int); Jede Variable vom Typ Menge muss dann vor der ersten Benutzung mit init initialisiert werden. ... Menge m1, m2; m1.init(); m2.init(); ... 1.6.2 Konstruktoren sind Initialisierungs–Methoden mit Compiler–Unterstützung Die Initialisierung ist eine Routineaktion. Als solche kann und sollte sie vom Compiler übernommen werden. Gibt man der Initialisierungs–Methode einer Klasse den Namen der Klasse und lässt den Ergebnistyp weg, dann erkennt der Compiler sie als Konstruktor, d.h. als Initialisierungsroutine für deren Aktivierung er verantwortlich ist. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 15 class Menge { public: Menge (); // Konstruktor (Deklaration) ... }; Menge::Menge () { a = 0; } // Konstruktor (Definition) bzw. class Menge { public: Menge () { a = 0; } ... }; // Konstruktor (inline) Durch diese einfache Konvention der Benennung kann also ein Konstruktor vom Compiler erkannt werden. Er nutzt sein Wissen, um an allen Stellen, an denen ein neues Exemplar des Typs Menge erzeugt wird, selbständig einen Aufruf des Konstruktors einzufügen: ... Menge m1, m2; // Hier fuegt der Compiler (in dem von ihm erzeugten Code) // automatisch die Aufrufe // m1.Menge() und // m2.Menge() ein ... Explizite Initialisierungen sind nicht mehr notwendig. Sie können somit auch nicht mehr versehentlich vergessen werden. Konstruktoren sind Initialisierungsroutinen für Objekte. Sie werden mit der Klasse definiert und automatisch an der Stelle der Variablendefinition aufgerufen. 1.6.3 Der Compiler erzeugt keine Konstruktordefinitionen Manche Klassen enthalten keinen Konstruktor. Das ist erlaubt. Klassen müssen keinen Konstruktor enthalten. Der Compiler erzeugt auch keine Konstruktoren automatisch. Objekte einer Klasse ohne Konstruktor werden wie Variablen jedes beliebigen Typs behandelt: globale Objekte werden mit 0 initialisiert und alle anderen enthalten Zufallswerte. Beispiel: class C { public: int a; }; C c1; int main () { C c2; } c1.a wird hier mit 0 initialisiert, c2.a enthält einen Zufallswert. Aufrufe von Konstruktoren werden also vom Compiler automatisch in das Quellprogramm eingefügt. Definitionen von Konstruktoren werden dagegen (im Allgemeinen) nicht automatisch erzeugt. 1.6.4 Konstruktoren mit Argumenten Konstruktoren können Argumente haben. Für eine Klasse können auch beliebig viele Konstruktoren definiert werden, wenn sie nur an Hand ihrer Parameterliste unterschieden werden können. Die Konstruktoren sind dann überladene Funktionen. Eine Menge kann beispielsweise mit keinem, einem oder zwei Elementen initialisiert werden: Programmierung II 16 class Menge { public: Menge (); // Konstruktor 1 Menge (int); // Konstruktor 2 Menge (int, int); // Konstruktor 3 ... }; Menge::Menge () { a = 0; } Menge::Menge (int x) { a = 1; m[0] = x; } Menge::Menge (int x, int y) { a = 2; m[0] = x; m[1] = y; } ... Menge m1; //Variablendef. plus Aufruf von Konstruktor 1 Menge m2 (1); //Variablendef. plus Aufruf von Konstruktor 2 Menge m3 (1, 2); //Variablendef. plus Aufruf von Konstruktor 3 Konstruktoren mit Argumenten werden aktiviert, indem man die Argumente in Klammern hinter die definierte Variable schreibt: Menge m2(1); oder den Konstruktor explizit aufruft: Menge m2 = Menge::Menge(1); 1.6.5 Konstruktor als Konversionsoperation Konstruktoren mit einem Argument werden bei Bedarf als Konversionsoperation behandelt. Dazu werden sie in dieser Funktion explizit über den Typnamen aufgerufen: Menge m2 = Menge(1); // explizite Konversion mit Typname als Konstruktor Eine implizite Konversion ist auch möglich: Menge m2 = 1; // OK: implizite Konversion mit Konstruktor Hier wird der int–Wert 1 implizit in ein Objekt vom Typ Menge konvertiert. Der Konstruktor Menge::Menge(int) dient dabei als implizite Konversionsfunktion. 1.6.6 explicit: der Konstruktor wird nur explizit aktiviert Mit Schlüsselwort explicit vor der Konstruktordefinition kann die implizite Verwendung dieses Konstruktors als Konversionsfunktion verhindert werden. Beispiel: class Menge { public: ... explicit Menge (int); ... }; ... Menge m1 (1); // OK: expliziter Aufruf des Konstruktors Menge m2 = 1; // FEHLER: nicht erlaubte Verwendung des Konstruktors zur Konversion Mit explicit sollen Fehler durch unbeabsichtigte Konversionen vermieden werden. 1.6.7 Default–Konstruktor: Konstruktor der ohne Argumente aktiviert wird Ein Konstruktor ohne Argumente wird Default–Konstruktor genannt. Achtung: er heißt nicht etwa so, weil der Compiler ihn automatisch erzeugt, sondern weil er vom Compiler automatisch (“per default”) aufgerufen wird, Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 17 wenn keine Argumente den Einsatz eines anderen Konstruktors vorschreiben! Man beachte dass der Default–Konstruktor ohne die Verwendung von Klammern aktiviert wird: Menge m; // OK Menge m(); // FALSCH statt Die Konstruktion Menge m() würde vom Compiler nicht als Definition einer Variablen m, sondern als Deklaration einer Funktion m mit Ergebnis vom Typ Menge missverstanden werden. 1.6.8 Klassen ohne Default–Konstruktor Eine Klasse muss nicht unbedingt einen Default–Konstruktor definieren. Wenn er benötigt wird, dann muss er aber vorhanden sein: class Menge { public: // KEIN DEFAULT-Konstruktor Menge (int); Menge (int, int); ... }; ... Menge m1; // FEHLER: Compiler sucht vergeblich Menge::Menge () Menge m2(1); // OK Enthält eine Klasse überhaupt keinen Konstruktor, dann muss auch kein Default–Konstruktor vorhanden sein: class Menge { public: // KEIN Konstruktor ... }; ... Menge m1; // OK: gar kein Konstruktoraufruf: // m1 hat Zufallswert, oder -- falls es global ist -// ist mit 0-en belegt, Menge m2(1); // FEHLER: Compiler sucht vergeblich Menge::Menge (int) Ist kein Konstruktor definiert, dann wird auch kein Default-Konstruktor erwartet. Gibt es irgendeinen Konstruktor, dann muss bei Bedarf auch ein Defaultkonstruktor zur Verfügung stehen. Man sollte von der Möglichkeit alle Konstruktoren wegzulassen jedoch nur überlegt Gebrauch machen. Jede Klasse sollte mit mindestens einem Konstruktor ausgestattet werden um fehlerhafte oder zufällige Initialisierungen schon bei der Übersetzung aufspüren zu können. Im Regelfall sollte auch ein Defaultkonstruktor definiert werden. Nur wenn keine sinnvollen allgemeinen Initialwerte für die Datenkomponenten angegeben werden können, kann er fehlen. In dem Fall sollte es dann aber mindestens einen anderen Konstruktor geben. 1.6.9 Initialisierungslisten Verbunde können durch Initialisierungslisten mit einem Wert belegt werden. Verbunde sind Klassen deren Komponenten alle öffentlich sind. Klassen, die nur öffentliche Datenkomponenten und keinen Konstruktor enthalten, können darum wie Verbunde mit einer Initialisierungsliste intialisiert werden: class Menge { public: // KEIN Konstruktor Programmierung II 18 // und ALLE Datenkomponenten public int m[10]; int a; }; ... Menge m = {{1,2,3,4,5,6,7,8,9,0}, 10} Initialisierungslisten eignen sich zur Belegung großer Strukturen. Generell sollten Objekte aber nicht mit Initialisierungslisten sondern mit Konstruktoren initialisiert werden. 1.6.10 Destruktoren Destruktoren sind komplementär zu Konstruktoren. Sie werden aufgerufen, bevor ein Objekt vernichtet wird. Beispiel: class Menge { public: Menge (); // Konstruktor ˜Menge (); // Destruktor ... }; .. f (...) { Menge m; // Beginn der Existenz von m: // Compiler-erzeugter Aufruf des Konstruktors // m.Menge(); ... ... // Ende der Funktion und damit // Ende der Existenz aller funktionslokalen Variablen: // Compiler-erzeugter Aufruf des Destruktors // m.˜Menge(); } Destruktoren werden für Aufräumarbeiten eingesetzt, die beim Ableben eines Objektes notwendig werden. Typischerweise werden im Destruktor die vom dahingehenden Objekt belegten Ressourcen freigegeben. Dies kann bei der Verwendung von Verweisen sinnvoll sein. Im Gegensatz zu Konstruktoren sind Destruktoren oftmals nicht notwendig. Sie werden wie Konstruktoren vom Compiler nicht automatisch erzeugt. Destruktoren werden später ausführlicher behandelt. 1.7 1.7.1 Initialisierung von Objekten, Initialisierer Initialisierung von einfachen Variablen Variablen können bei ihrer Definition mit einen ersten Wert belegt werden. Bei skalaren Typen und Feldern sieht das wie eine Zuweisung aus, z.B: int i = 5; int a[3] = 1, 2, 3 ; Klassen – inklusive ihr Sonderfall Struct – werden durch Konstruktoren initialisiert. Nur dann, wenn kein Konstruktor existiert und alle Komponenten öffentlich sind, ist die Zuweisungsinitialisierung auch bei ihnen erlaubt. struct S int x, y, z; 1, 2 , 3 ; S s = ; Ohne explizite Initialisierung werden statische Variablen mit Null belegt und alle anderen behalten ihre Zufallswerte. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 1.7.2 19 Initialisierungsreihenfolge Klassen enthalten üblicherweise Datenkomponten. Diese können wiederum Objekte einer Klasse sein, die selbst wieder Objekte als Komponenten enthält, etc. Ein Objekt kann also aus beliebig tief verschachtelten Unterobjekten bestehen. Bei der Initialisierung des Gesamtobjekts werden auch die Unterobjekte initialisiert. Die Reihenfolge der Initialisierung ist dabei: Zuerst werden alle Datenkomponenten eines Objekts in der Reihenfolge ihrer Definition initialisiert. Anschließend wird das Objekt als ganzes durch seinen Konstruktor initialisiert. Diese Regel gilt rekursiv für alle Objekte, Unterobjekte, Unter–Unterobjekte, etc. Die Regeln der Initialisierung ergeben sich immer aus dem Typ der Objekte und Unterobjekte. Das Vorgehen ist intuitiv einsichtig: zuerst werden die Einzelteile fertig gemacht, dann wird aus ihnen ein Ganzes konstruiert. Der Konstruktor ist für das Gesamte zuständig. 1.7.3 Beispiel Initialisierungsreihenfolge Als Beispiel betrachten wir wieder einmal Vektoren: class Vektor { public: Vektor (); Vektor (float, float); private: float x, y; }; Vektor::Vektor () { x = 0.0; y = 0.0; } Vektor::Vektor (float a, float b) { x=a; y=b; } Wird ein Objekt vom Typ Vektor angelegt, dann werden zuerst dessen x– und y–Komponenten initialisiert, und zwar nach den Regeln, die für float–Variablen gelten (also keinerlei Initialisierung außer für statische Objekte). Danach wird der Konstruktor aufgerufen. Etwas interessanter wird die Angelegenheit, wenn ein Objekt nicht aus skalaren Typen wie float zusammengesetzt ist, sondern aus “richtigen Objekten” besteht. Nehmen wir eine Gerade, die mit Hilfe von zwei Vektoren dargestellt wird: class Gerade { public: Gerade (); Gerade (Vektor, Vektor); private: Vektor o, r; }; Gerade::Gerade () { o = Vektor (0.0, 0.0); r = Vektor (1.0, 1.0); } Gerade::Gerade (Vektor po, Vektor pr) { o = po; r = pr; } Wird ein Objekt vom Typ Gerade angelegt, z.B. mit Gerade g; dann laufen folgende Initialisierungsaktionen ab: 1. g.o.x wird initialisiert (Unter-Unterobjekt) 2. g.o.y wird initialisiert (Unter-Unterobjekt) Programmierung II 20 3. g.o.Vektor() wird ausgeführt (Konstruktor Unterobjekt) 4. g.r.x wird initialisiert (Unter-Unterobjekt) 5. g.r.y wird initialisiert (Unter-Unterobjekt) 6. g.r.Vektor() wird ausgeführt (Konstruktor Unterobjekt) 7. g.Gerade() wird ausgeführt (Konstruktor Objekt). Das Prinizip, das die Initialisierungsreihenfolge bestimmt, ist insgesamt sehr einfach: Objekte werden von innen nach aussen initalisiert. Auf die Initialisierung der Komponenten folgt dabei der Aufruf des Konstruktors für das Objekt das diese Komponenten enthält. 1.7.4 Redundante Initialisierung Man beachte dass im letzten Beispiel die Komponenten prinzipiell zweimal mit Werten belegt werden. Zuerst durch ihren eigenen Konstruktor und dann durch eine Zuweisung im Konstruktor der Klasse deren Teilkomponente sie sind. Beispielsweise wird g.r zuerst durch den Defaultkonstruktor von Vektor g.r.Vektor() mit g.r.x=0.0, g.r.y=0.0 belegt, dann wird g.Gerade() aufgerufen, die Zuweisung r = Vektor (1.0, 1.0); in g.Gerade() ausgeführt und g.r erhält seinen endgültigen Wert g.r.x=1.0, g.r.y=1.0. Die letzte Zuweisung könnten wir uns sparen, wenn der Konstruktor für g.r gleich mit den richtigen Argumenten aufgerufen würde. Statt durch den Defaultkonstruktor müßte g.r also gleich mit g.r.Vektor(1.0, 1.0) belegt werden. Zu dem notwendigen gezielten Aufruf eines Konstruktors für Unterobjekte gibt es Initialisierer. 1.7.5 Initialisierer Initialisierer können in Konstruktoren verwendet werden, um die Initialisierung der Unterkomponenten zu steuern. Mit ihnen kann die oben angesprochene doppelte Initialisierung einer Komponente vermieden werden. Beispiel: class Gerade { public: Gerade (); Gerade (Vektor, Vektor); private: Vektor o, r; }; Gerade::Gerade ()// Defaultkonstruktor mit Initialisierer : o(0.0, 0.0), // Initialisierer: expliziter Aufruf des Konstruktors // Vektor::Vektor(float, float) fuer Komponente o // statt des Defaultkonstruktors Vektor::Vektor() r(1.0, 1.0) // Initialisierer: expliziter Konstruktoraufruf // fuer Komponente r {} // Keine Zuweisung da bereits korrekt initialisiert Gerade::Gerade (Vektor po, Vektor pr) : o(po), r(pr) {} Im Konstruktor Gerade::Gerade werden o und r jezt gleich durch den richtigen Konstruktoraufruf mit den endgültigen Werten belegt, statt sie erst durch ihren Default–Konstruktor zu initialisieren und dann durch eine Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 21 Zuweisung mit den richtigen Werten zu belegen. In dieser Variante ist die Initialisierungsreihenfolge immer noch die gleiche wie oben: 1. g.o.x wird initialisiert 2. g.o.y wird initialisiert 3. g.o.Vektor(0.0, 0.0) wird ausgeführt 4. g.r.x wird initialisiert 5. g.r.y wird initialisiert 6. g.r.Vektor(1.0, 1.0) wird ausgeführt 7. g.Gerade() wird ausgeführt Die Zuweisungen in g.Gerade() können allerdings wegfallen. Üblicherweise initialisiert man alle Komponenten durch Initialisierer, auch wenn es sich nicht um Klassen handelt: class Vektor { public: Vektor (); Vektor (float, float); private: float x, y; }; Vektor::Vektor () : x(0.0), y(0.0) {} Vektor::Vektor (float a, float b) : x(a), y(b) {} class Gerade { public: Gerade (); Gerade (Vektor, Vektor); private: Vektor o, r; }; Gerade::Gerade () : r(1.0, 1.0) {} Gerade::Gerade (Vektor po, Vektor pr) : o(po), r(pr) {} Ob man sich bei der Initialisierung gegebenenfalls auf den Defaultkonstruktor verlässt wie in: Gerade::Gerade (): r(1.0, 1.0) oder lieber alle Konstruktoren explizit aufruft: Gerade::Gerade (): o(0.0, 0.0), r(1.0, 1.0) das bleibt dem persönlichen Geschmack überlassen. Selbstvertändlich ist auch Gerade::Gerade (): o(Vektor(0.0, 0.0)), r(Vektor(1.0, 1.0)) möglich. Da Konstruktoren sehr oft aufgerufen werden, kann ihre Effizienz die des Gesamtprogramms stark beeinflussen. Initialisierer können die Laufzeit eines Programms beträchtlich verbessern. In der ersten Version von Gerade wird g.r.x zwei bzw. dreimal mit einem Wert belegt. In der zweiten Variante dagegen nur genau einmal. Werden Tausende von Geraden erzeugt, dann kann das die Laufzeit des des Programm schon beeinflussen. 1.7.6 Klassen mit Klassenkomponenten und automatisch generierte Default–Konstruktoren Bisher haben wir immer betont, dass Konstruktoren im Allgemeinen und der Default–Konstruktor im Besonderen nicht vom Compiler automatisch erzeugt werden, sondern explizit definiert werden müssen. Von dieser Regel gibt es eine Ausnahme: Wenn der Typ einer Komponente eine Klasse mit Default–Konstruktor ist, dann wird der Konstruktor der “Unterklasse” immer aktiviert; auch dann, wenn die Klasse selbst keinen Konstruktor hat: Programmierung II 22 class Float { // Klasse mit Konstruktor public: Float () { f = 0.0; } float f; }; class C { // Klasse ohne Konstruktor public: Float a; // Klassen-Komponente: wird mit Konstruktor initialisiert float b; // keine Klasse }; C c1; int main () { C c2; } Die Komponenten mit Klassentyp, c1.a und c2.a, werden mit ihrem Default–Konstruktor Float::Float() initialisiert, obwohl die Klasse C, zu der sie gehören, keinen Konstruktor hat. Der Compiler erzeugt dazu einen Defaultkonstruktor von C der a.Float() aufruft. Die Komponenten mit skalarem Typ werden wie üblich behandelt: c1.b wird mit 0 initialisiert, da c1 eine globale Variable ist; c2.b wird nicht initialisiert. Die Aktivierung der “Subkonstruktoren” übernimmt ein vom Compiler generierter Default–Konstruktor: class Float { public: Float () : f(0.0) {} float f; }; ... class C { // C () { a.Float(); } generierter Default-Konstruktor, public: // Pseudocode, nicht vollwertig Float a; float b; }; ... int main () { C c; // OK, Aufruf des automatisch erzeugten C::C initialisiert c.a } Der erzeugte Defaultkonstruktor ist nicht “vollwertig” und kann einen explizit definierten Defaultkonstruktor nicht ersetzen: class Float { public: Float () : f(0.0) {} float f; }; ... class C { // C () { a.Float(); } public: C (float x) : b(x) {} Float a; float b; }; ... int main () { C c; // FEHLER: generierter Default-Konstruktor, // Pseudocode, nicht vollwertig // <- explizit definierter Konstruktor, // erzwingt Existenz eines passenden Konstruktors // bei jeder Erzeugung eines C-Objekts Defaultkonstruktor von C fehlt Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 23 } Die Klasse C enthält hier einen explizit definierten Konstruktor, wenn ein C–Objekt erzeugt wird, muss darum der entsprechende Konstruktor definiert sein. Der vom Compiler erzeugt Default–Konstruktor ist kein vollwertiger Konstruktor und “gilt nicht”. Insgesamt gilt: Der Default–Konstruktor ist der Konstruktor, der “default–mäßig” aufgerufen wird. Er muss vorhanden sein, wenn er gebraucht wird – wenn also ein Objekt ohne expliziten Aufruf eines anderen Konstruktors angelegt wird, und wenn außerdem irgendein anderer Konstruktor definiert ist. Bei Klassen mit Klassen als Komponenten werden die Default–Konstruktoren der Subklassen auch dann aktiviert, wenn die Klasse selbst keinen Konstruktor hat. Dies setzt aber die Regeln über die notwendige Existenz eines Default–Konstruktors nicht außer Kraft. 1.8 1.8.1 Statische Komponenten einer Klasse Statische Datenkomponenten (Klassenvariablen) Komponenten – Daten und Methoden – einer Klasse können mit dem Schlüsselwort static als statisch deklariert werden. Die entsprechende Komponente ist dann klassen–spezifisch statt objekt–spezifisch. Bei statischen Daten– Komponenten bedeutet dies, dass die Komponente nur einmal für alle Instanzen (Objekte) der entsprechenden Klasse angelegt wird. Beispiel: class Vektor { public: Vektor (); Vektor (float, float); ˜Vektor (); static int vz; // <<--- Deklaration statische Datenkomponente private: float x, y; }; int Vektor::vz = 0; // <<--- Definition statische Datenkomponente Vektor::Vektor () { x=0; y=0; ++vz; } // Konstr. erhoeht vz Vektor::˜Vektor () { --vz; } // Destr. erniedrigt vz Vektor::Vektor (float a, float b) { x=a; y=b; ++vz; } // Konstr. erhoeht vz In diesem Beispiel wird eine statische Datenkomponente benutzt, um die Anzahl der Objekte der Klasse Vektor zu zählen. Die Anzahl wird für alle Objekte der Klasse Vektor gemeinsam in int Vektor::vz geführt. Diese Variable gibt es genau einmal, egal wieviele Vektor–Objekte gerade existieren. Jedesmal, wenn ein Objekt vom Typ Vektor angelegt wird, wird auch vz erhöht. Der Destruktor zählt vz wieder zurück. Wir erinnern uns, dass der Destruktor automatisch aufgerufen wird, wenn ein Objekt verschwindet. Statische Datenfelder müssen außerhalb der Klassendefinition angelegt (definiert) und bei Bedarf auch dort initialisiert werden: int Vektor::vz = 0; Programmierung II 24 Statische Datenkomponenten einer Klasse sind nicht Bestandteil der Objekte, sie sind wie globlale Variablen einmal im Programm vorhanden. Sie existieren also nicht wie “normale” Datenkomponenten einmal pro Objekt, sondern einmal pro Klasse. Man nennt sie darum auch Klassenvariablen. Klassenvariablen benutzt man typischerweise um Informationen zu verwalten, die sich nicht auf einzelne Objekte sondern auf die Klasse als Ganzes beziehen. Zusammengefasst, der Unterschied von “normalen” und statischen Datenkomponenten ist: Nicht–statische Datenkomponenten gehören zu den Ojekten. Jedes Objekt hat seine eigene Komponente. Statische Datenkomponenten (Klassenvaribalen) gehören zur Klasse. Es gibt sie genau einmal, unabhängig davon wieviele Objekte der Klasse gerade existieren. 1.8.2 Verwendung statischer Komponenten Normale Datenkomponenten werden über die Punktnotation angesprochen, z.B. Vektor v; v.x=0; // x von Vektor v auf 0 setzen Bei Klassenvariablen gibt es kein Objekt, dessen Komponente sie sein können. Man spricht sie darum über die Klasse an, z.B.: Vektor::vz=0; // vz der Vektoren-Klasse auf 0 setzen Ein etwas ausführlicheres Beispiel für die Verwendung dieser Vektor–Klasse ist: class Vektor { ... wie oben ... }; ... void f () { Vektor v; cout << Vektor::vz << endl; // Ausgabe: } int main () { cout << Vektor::vz << endl; // Ausgabe: Vektor v1, v2 (1.0, 2.5); cout << Vektor::vz << endl; // Ausgabe: f(); cout << Vektor::vz << endl; // Ausgabe: } 3 0 2 2 In diesem Beispiel wird während des Programmlaufs viermal die Zahl der gerade existierenden Vektoren ausgegeben. 1.8.3 Private Klassenvariablen Klassenvariablen dürfen natürlich auch privat sein. In dem Fall müssen sie über Methoden angesprochen werden: class Vektor { public: Vektor (); Vektor (float, float); ˜Vektor (); int wieViele (); private: float x, y; static int vz; }; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 25 int Vektor::vz = 0; Vektor::Vektor () { x=0; y=0; ++vz; } Vektor::˜Vektor () { --vz; } Vektor::Vektor (float a, float b) { x=a; y=b; ++vz; } int Vektor::wieViele () { return vz; } void f () { Vektor v; cout << v.wieViele() << endl; } int main () { // NICHT MOEGLICH --> Ausgabe: Vektor v1, v2 (1.0, 2.5); cout << v1.wieViele() << endl; f(); cout << v1.wieViele() << endl; } // Ausgabe: 3 0 // Ausgabe: 2 // Ausgabe: 2 Der Aufruf der Methode wieViele ist an ein Objekt gekoppelt. Das ist in gewisser Weise unnatürlich. Die aktuelle Zahl der Vektoren ist nichts, was etwas mit einem bestimmten Vektor zu tun hat. Es ist sogar unmöglich festzustellen, dass es aktuell gar keinen Vektor gibt. Es sollte also auch Methoden geben, die zur gesamten Klasse, statt zu einem einzelnen Objekt gehören. 1.8.4 Statische Methoden Neben statischen Datenkomponenten gibt es auch statische Methoden. Wie die statischen Datenkomponenten gehören statische Methoden nicht zu einzelnen Objekten sondern zu der Klasse insgesamt. Sie werden darum unabhängig von einem bestimmten Objekt ausgeführt. Beispielsweise kann die Angabe der Zahl der aktuell existierenden Vektoren als statische Methode definiert werden: class Vektor { public: Vektor (); Vektor (float, float); ˜Vektor (); static int wieViele (); // Deklaration statische Methode private: float x, y; static int vz; }; int Vektor::vz = 0; ... etc. wie oben ... int Vektor::wieViele () { return vz; } // Definition statische Methode // entspr. der "normaler" Methoden void f () { Vektor v; cout << v.wieViele() << endl; // Ausgabe: 3 } int main () { Programmierung II 26 cout << Vektor::wieViele() << endl; // Ausgabe: 0 Vektor v1, v2(1.0, 2.5); cout << Vektor::wieViele() << endl; // Ausgabe: 2 f(); cout << Vektor::wieViele() << endl; // Ausgabe: 2 Aufruf statische Methode } Man beachte den Aufruf der Methode wieViele ohne den Selektionspunkt aber mit der Bereichsangabe Vektor::. Damit kommt zum Ausdruck, dass wieViele zur Klasse Vektor gehört, aber nicht in Abhängigkeit von einem bestimmten Objekt ausgewertet wird. Statische Methoden haben keinen Zugriff auf ein Objekt. Sie verhalten sich damit wie freie Funktionen. Im Gegensatz zu diesen haben sie – als Bestandteile ihrer Klassen – aber Zugriff auf deren private Komponenten. 1.8.5 Methoden, statische Methoden und freie Funktionen Methoden, statische Methoden und freie Funktionen bieten ähnliche Möglichkeiten. Betrachten wir dazu noch einmal das Mengenbeispiel mit drei Varianten der Vereinigung: class Menge { ... Menge operator+ (Menge); // Methode (und Operator) static Menge vereinige (Menge, Menge); // statische Methode ... }; Menge operator+ (Menge, Menge); // freie Funktion (und Operator) Die wesentlichen Eigenschaften und Unterschiede von Methoden, statischen Methoden und freien Funktionen sind: Methoden operieren auf “ihrem” Objekt. Sie können als Operatoren definiert werden und haben Zugriff auf alle Komponenten. Statische Methoden verhalten sich wie freie Funktionen. Sie gehören zwar zu einer Klasse, haben aber im Gegensatz zu normalen Methoden keinen Bezug zu einem bestimmten Objekt. Sie können nicht als Operatoren definiert werden. Freie Funktionen haben nur auf öffentliche Komponenten Zugriff. Sie können als Operatoren definiert werden. 1.9 1.9.1 Konstante Komponenten, konstante Parameter Konstante Datenkomponenten Wir erinnern uns, dass mit const Konstanten im Programm definiert werden können. Beispiel: const float pi = 3.1415; Komponenten einer Klasse können mit dem Schlüsselwort const ebenfalls als “konstant” erklärt werden. Konstante Datenkomponenten verhalten sich genau wie Konstanten auf Programmebene: sie sind unveränderlich. Wollen wir beispielsweise, dass die Registriernummer eines Buchs nicht verändert werden kann, dann erklären wir sie als konstant: class Buch { public: Buch (string, string); ... Th Letschert, Fachbereich MNI, FH Giessen–Friedberg private: const int regNr; ... }; 27 // konstante Datenkomponente Jedes Buch hat damit seine eigene unveränderliche Registriernummer regNr. Man beachte den Unterschied von const und static. Das Schlüsselwort const erklärt Komponenten als unveränderlich. Die Komponente existiert einmal pro Objekt, kann aber nicht modifiziert werden. Mit static deklariert man Komponenten als klassenspezifisch. Die Komponente existiert einmal für die ganze Klasse, ist aber veränderlich. static und const können natürlich auch kombiniert werden. Die Komponente ist dann sowohl klassenspezifisch als auch unveränderlich. 1.9.2 Const und Initialisierer Da Zuweisungen an Konstanten nicht möglich sind, können sie ihren Wert nur durch einen Initialisierer erhalten. Bei Konstanten auf Programm– oder Funktionsebene wird der Initialisierer direkt zur Konstanten geschrieben: const float pi = 3.1415; 1.9.3 \\ Konstante \\ Initialisierer Initialisierer von Klassenkomponenten Konstante Klassenkomponenten müssen ebenfalls durch einen Initialisierer mit einem Wert belegt werden. Sie werden beim Konstruktor in der speziellen Notation der Initialisierer angegeben: class Buch { public: Buch (string, string); ... private: string titel, autor; const int regNr; static int naechsteNr; }; Buch::Buch(string p_autor, string p_titel) : regNr (naechsteNr) // Initialisierer { autor = p_autor; titel = p_titel; ++naechsteNr; } Konstante Klassenkomponenten können nur mit Initialisierern belegt werden. Umgekehrt kann jede Datenkomponente – nicht nur die konstanten – mit einem Initialisierer belegt werden: Buch::Buch(string p_autor, string p_titel) : regNr (naechsteNr++), // Initialisierer fuer alle Komponenten titel (p_titel), autor (p_autor) {} // Rumpf des Konstruktors jetzt leer Nicht–konstante Komponenten können (und sollten), konstante Komponenten müssen mit einem Inititialisierer initialisiert werden! Programmierung II 28 1.9.4 Konstante Methoden Eine Methode kann wie eine Datenkomponente als konstant erklärt werden. Hier ist die Bedeutung allerdings nicht “diese Komponente ist unveränderlich”, sondern – da Methoden selbst etwas Aktives sind – : “diese Methode verändert nichts!”. Beispiel: class Vektor { public: Vektor (); Vektor (float, float); float xWert () const; // Konstante Methoden float yWert () const; // veraendern ihr Objekt nicht private: float x, y; }; ... float Vektor::xWert () const { return x; } float Vektor::yWert () const { return y; } Die Methoden xWert und yWert verändern das Objekt nicht, es sind konstante Methoden. Wird eine Methode als konstant erklärt, dann kann sie auch aktiviert werden, wenn das Objekt zu dem sie gehört selbst konstant ist. Beispiel: const Vektor null(0.0, 0.0); cout << null.xWert() << endl; // konstantes Objekt // Aufruf konstanter Methoden erlaubt Es ist guter Stil alle Datenkomponenten als privat zu erklären und, soweit notwendig, mit speziellen Lese– und/oder Schreib–Methoden zu versehen. Die Leseoperationen sollten dann wie im Beispiel oben konstant sein. 1.9.5 Konstante Parameter Auch die formalen Parameter einer Funktion (freie Funktion oder Methode) können als konstant erklärt werden. Die Funktion sagt damit: Ich werde diesen Parameter nicht verändern!. Das Versprechen den Parameter nicht zu verändern ist natürlich nur interessant, wenn eine Veränderung, die innerhalb der Funktion erfolgt, auch eine Wirkung nach außen hätte. Das ist nur bei Referenzparametern möglich. Von Wertparametern werden ja lokale Kopien erzeugt, deren Modifikation außerhalb der Funktion irrelevant ist. Konstante Wertparameter sind darum unsinnig. Konstante Referenzparameter sind dagegen sinnvoll. Sie stellen eine wichtige Alternative zu Wertparametern dar. Wie bei Wertparametern ist der Aufrufer sicher, dass seine aktuellen Parameter nicht verändert werden. Es wird allerdings bei der Übergabe keine vollständige Kopie erzeugt, sondern lediglich eine Referenz übergeben. Die Parameterübergabe hat damit die Effizienz der Übergabe per Referenz und die Sicherheit der Übergabe per Wert. Beispiel: Vektor operator+ (const Vektor &v1, const Vektor &v2) { return Vektor (v1.xWert() + v2.xWert(), v1.yWert() + v2.yWert()); } Beim Aufruf von operator+ werden jetzt zwei Referenzen übergeben, statt zwei Objekte vom Typ Vektor vollständig neu zu erzeugen und mit Kopien zu belegen. Der Aufrufer kann aber trotz der Referenzübergabe sicher sein, dass die aktuellen Parameter nicht modifiziert werden. Konstante Objekte dürfen nur als Wert– oder konstante Referenzparameter übergeben werden: Vektor f (Vektor v) Vektor g (Vektor &v) { ... } { ... } // Wertparameter // Referenzparameter Th Letschert, Fachbereich MNI, FH Giessen–Friedberg Vektor h (const Vektor &v) { ... } ... const Vektor null (0.0, 0.0); f(null); g(null); h(null); // OK // // FEHLER // // OK // 29 // konstanter Referenzparameter f erzeugt eine Kopie von null, diese darf veraendert werden g erhaelt Zugriff auf null, verspricht aber nicht es unveraendert zu lassen h erhaelt Zugriff auf null und verspricht es unveraendert zu lassen Man beachte, dass der Inhalt der Funktionen hier völlig unerheblich ist. Auch wenn g seinen Parameter nicht verändert, die Übergabe eines konstanten Parameters an g ist verboten. 1.9.6 Konstante Methoden mit konstantem Parameter Die Kombination konstante Methode mit konstantem Parameter wird häufig eingesetzt: class Vektor { public: Vektor (); Vektor (float, float); Vektor operator+ (const Vektor &v) const; private: float x, y; }; ... Vektor Vektor::operator+ (const Vektor &v) const { return Vektor (x+v.x, y+v.y); } Das erste const sagt, dass der rechte Operand der Addition nicht verändert wird, das zweite const garantiert, dass der linke Operand der Addition nicht verändert wird. 1.9.7 const oder nicht const Das Schlüsselwort const kann zu einer Flut von Fehlermeldungen und Warnungen führen. Es wird trotzdem empfohlen mit Konstanten, konstanten Parametern und konstanten Methoden zu arbeiten und gegebenenfalls allen Warnungen und Fehlermeldungen nachzugehen. Konstante Objekte und Klassen–Komponenten erhöhen die Sicherheit durch Schutz vor versehentlichem Überschreiben. Sie machen zudem die Intentionen des Programm–Autors klar und erhöhen so die Lesbarkeit des Programms. Warnungen und Fehlermeldungen zu const sind stets ein Anzeichen, dass in der Konzeption des Programms etwas nicht in Ordnung ist: Etwas ist entweder nicht wirklich konstant, oder aber etwas, das konstant sein sollte, wird irrtümlicherweise verändert. 1.9.8 Philosophie einer Const–Definition Die unterschiedlichen Bedeutungen, die mit const ausgedrückt werden, sind: Variablendefinition mit Const const T x; Die Variable x darf nicht verändert werden! Damit wird die Verwendung eingeschränkt: Sie darf nicht zugewiesen werden, nur an konstante Parameter übergeben werden und nur ihre statischen Methoden dürfen aktiviert werden. Programmierung II 30 Parameter mit Const T1 f (const T & x) Der Parameter x wird in der Funktion / Methode nicht verändert! Damit wird der Einsatz der Funktion / Methode erweitert: Sie kann auch auf konstante Objekte angewendet werden. Methodendefinition mit Const T1 T::m(...) const Die Methode m wird ihr Objekt nicht verändern! Damit wird der Einsatz der Methode erweitert: Sie kann auch bei konstanten Objekten aufgerufen werden. Der Einsatz von const ist wie der von static letztlich eine “philosophische Frage”. Alle Elemente der Klassendefinition müssen zusammenpassen und insgesamt ein möglichst einfach benutzbares, konsistentes, logisches – kurz “schönes” – Konstrukt ergeben. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 1.10 31 Übungen Aufgabe 1 1. Definieren Sie als äquivalente Klasse: struct Vektor float x, y; ; 2. Ist struct S ... private: ... ; erlaubt. Wenn ja: was bedeutet es? 3. Welche Zugriffe auf Klassen–Komponenten sind in folgendem Programm erlaubt, welche sind nicht erlaubt: class S { int y; public: int x; int f (S); private: int g (S); }; int S::f (S ps) { x = 1; y = 2; ps.x = 1; ps.y = 2; return ps.g(ps) + ps.y; } int S::g (S ps) { x = 1; y = 2; ps.x = 1; ps.y = 2; return ps.f(ps) + ps.y; } int h (S ps) { ps.x = 1; ps.y = 2; return ps.f(ps) + ps.g(ps); } 4. Kann jedes Programm, in dem Klassen vorkommen, in ein äquivalentes Programm ohne Klassen umgesetzt werden? (Äquivalent: Bei gleicher Eingabe wird die gleiche Ausgabe erzeugt.) Wenn nein, geben Sie ein Beispiel an, bei dem die Umsetzung nicht möglich ist. Wenn ja, wozu gibt es denn überhaupt Klassen? 5. (a) Kann eine Funktion ein Freund einer Klasse sein? (b) Kann eine Klasse ein Freund einer Funktion sein? (c) Kann eine Klasse ein Freund einer Klasse sein? 6. Welche Fehler enthält folgende Klassendefinition. Wie wird das Gewollte korrekt formuliert? class C { public: C (int x) { c=x; } void incA () { ++a; } static void incB () { ++b; } private: int a = 0; static int b = 1; Programmierung II 32 const int c; }; int main () { C c; c.incA(); c.incB(); } 7. Welchen Fehler enthält: class Vektor { public: Vektor (); ... private: float x, y; }; class Punkt { public: Punkt (); ... float entfernt_von (Punkt); private: Vektor pos; }; ... float Punkt::entfernt_von (Punkt p) { return sqrt ( (pos.x - p.pos.x)*(pos.x - p.pos.x) + (pos.y - p.pos.y)*(pos.y - p.pos.y)); } Wie ist er zu korrigieren? 8. Welche Fehler enthält: class S { public: S() : y(y+1) {} // y um 1 ˜S() : y(y-1) {} // y um 1 static void f () { cout << x << ", " << y<< } private: int x = 1; // x mit static int y = 0; // y mit }; erhoehen reduzieren endl; // x und y ausgeben 1 initialisieren 0 initialisieren Wie wird das jeweils Gemeinte – soweit es nicht schon korrekt ist – korrekt ausgedrückt? 9. Klassenvariablen müssen außerhalb der Klasse definiert werden: class C { public: static int i; }; int C::i = 1; Müssen sie darum public sein, oder ist auch folgendes erlaubt: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 33 class C { private: static int i; }; int C::i = 1; Was ist mit class C { private: static int i; }; int main () { int C::i = 1; ... } 10. Komplexe technische Systeme sind aus Komponenten aufgebaut, die jeweils eine Schnittstelle und eine Implementierung haben. Nennen Sie Beispiele und erläutern Sie wie dieses Prinzip auf Softwareprodukte übertragen wird. 11. Erläutern Sie den Begriff der Kopplung am Beispiel einer Mengen–Klasse und ihrer Benutzer. Aufgabe 2 1. Erläutern und begründen Sie jeden Einsatz von const in folgender Definition: class Menge { public: Menge (); // leere Menge Menge (int); // ein-elementige Menge Menge operator+ (const Menge &) const; // Vereinigung Menge operator* (const Menge &) const; // Schnitt bool istEnthalten (int) const; // ist Element private: void fuegeEin (int i); // interne Hilfsfunktion void entferne (int i); // interne Hilfsfunktion int m[10]; int a; }; //Test: int main () { Menge m1, m2(2); Menge m3, m4; m1 = m1 + Menge(2); m1 = m1 + Menge(3); m2 m2 m3 m4 = = = = m2 m2 m1 m1 + + + * Menge(3); Menge(4); m2; m2; } 2. Begründen Sie die Klassifikation der Methoden und Datenkomponenten in öffentlich und privat. 3. Warum ist das erste const in folgender Deklaration unsinnig, das zweite jedoch nicht? Menge operator+ (const Menge) const; Programmierung II 34 4. Wieso ist das erste const in folgender Deklaration nicht unsinnig? Menge operator+ (const Menge &) const; 5. Wodurch unterscheiden sich die beiden Methoden: Menge operator+ (Menge) const; Menge operator+ (const Menge &) const; Was sind die Vor– und Nachteile? 6. Ergänzen Sie die Klasse Menge um die Definition der Methoden. Benutzen Sie dabei soweit wie möglich Initialisierer. 7. Formulieren Sie das Beispiel so um, dass Vereinigung und Schnitt freie Operatoren sind. Die privaten Komponenten dürfen dabei nicht öffentlich gemacht werden! 8. Formulieren Sie das Beispiel so um, dass Vereinigung und Schnitt statische Methoden sind. 9. Definieren Sie die Ausgabe von Mengen in drei Varianten: (a) Eine Ausgabe–Methode. (b) Eine freie Ausgabe–Funktion. (c) Eine statische Ausgabe–Methode. 10. Definieren Sie die Operatoren “+” und “-” in: class Menge { public: Menge (); // leere Menge Menge (int); // ein-elementige Menge Menge operator+ (const Menge &) const; // Vereinigung Menge operator* (const Menge &) const; // Schnitt bool istEnthalten (int i) const; // ist Element private: void fuegeEin (int i); void entferne (int i); int m[10]; int a; }; als befeundete freie Operatoren. Aufgabe 3 Konten haben einen Besitzer, einen Wert und einen Zinssatz. Der Zinssatz ist für alle Konten gleich: 3 Prozent für Guthaben, 5 Prozent für Kredite (= negative Guthaben). Von einem Konto zum anderen können beliebige Beträge überwiesen werden. Das Guthaben bzw. der Kredit eines Kontos kann für einen bestimmten Zeitraum verzinst werden; das Guthaben wird dabei entsprechend verändert. Definieren Sie die Klasse der Konten mit geeigneten Methoden und/oder Funktionen. Aufgabe 4 1. Was versteht man unter einem Konstruktor und was ist der Default–Konstruktor? 2. Stimmt es, dass ... (a) eine Klasse immer mindestens / genau / höchstens einen Konstruktor haben muss? (b) eine Klasse immer einen Default–Konstruktor haben muss? Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 35 (c) der Compiler einen Default–Konstruktor erzeugt, wenn für eine Klasse keiner definiert wurde. (d) Objekte immer mit einem Konstruktor initialisiert werden? (e) Objekte, deren Klasse mindestens einen Konstruktor hat, immer mit einem Konstruktor initialisiert werden. (f) Klasssen mit Konstruktor auch einen Destruktor haben müssen? (g) Klassen mit Destruktor auch einen Konstruktor haben müssen? 3. Welche Methoden dürfen Initialisierer enthalten? 4. Unter welchen Umständen muss ein Initialisierer vorhanden sein? 5. Was ist an folgender Definition falsch: class A { public: A(int p) : x(p) {} private: int x; }; class B { public: B(A a) { y = a; } private: A y; }; dagegen aber class A { public: A(int p) : x(p) {} private: int x; }; class B { public: B(A a) : y(a) {} private: A y; }; korrekt? Was unterscheidet die beiden Programmstücke? Geben Sie eine Variablendefinition für eine Variable b vom Typ B an und erläutern Sie die Reihenfolge und das Ergebnis der Initialisierung, je nach dem ob b global oder lokal in einer Funktion definiert wird. 6. Ist folgendes Programmstück korrekt? Wenn nein: warum nicht? Wenn ja: Wird der Konstruktor von X aktiviert? class X { public: X () : f(0.0) {} float f; }; class C { public: X a; float b; Programmierung II 36 }; int main () { C c; } 7. Was ist mit class X { public: X () : f(0.0) {} float f; }; class C { public: C (X x) : a(x) {} X a; float b; }; int main () { C c; } Ist es korrekt? Wenn nein: Warum nicht? Wenn ja: Welche Konstruktoren werden in welcher Reihenfolge aufgerufen? 8. Geben Sie ein Beispiel an, für den (seltenen) Fall eines Compiler–erzeugten Default–Konstruktors. 9. Eine Klasse erklärt eine andere zum Freund. Welche Auswirkung hat dies auf die Ausführung der Konstruktoren? Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 2 37 Objektorientierte Programmierung: Entwurf von Klassendefinitionen 2.1 2.1.1 Referenzen Referenzparameter Praktisch einsetzbare vollständige Klassendefinitionen benötigen meist Referenztypen. Eine Referenz ist ein Hinweis auf ein Objekt. Wir kennen das Konzept bereits von den Referenzparametern. Bei der Referenzübergabe wird nicht das Objekt selbst an eine Funktion übergeben, sondern nur ein Hinweis – eine Referenz – auf dieses Objekt. Rufen wir uns zunächst noch einmal die Funktionsweise von Referenzparametern an einem Beispiel in Erinnerung: void f (int wp, // int &rp) { // wp = wp + 1; // rp = rp + 1; // } ... int main () { int a = 1, b = 2; f (a, b); // a == 1, b == 3 .. } Wertparameter Referenzparameter erhoehe wp erhoehe das worauf rp zeigt f hat einen Wertparameter wp und einen Referenzparameter rp. Innerhalb von f wird ein lokales Objekt wp angelegt und mit dem Wert des aktuellen Parameters a initialisiert (a wird also nach wp kopiert). Die Anweisung wp = wp + 1; bezieht sich auf dieses lokale Objekt und hat somit keine Wirkung nach außen. Bei der Referenzübergabe von b wird dagegen nicht dessen Wert nach rp kopiert. rp ist innerhalb von f nur eine andere Bezeichnung für den aktuellen Parameter b. Alle Aktionen wie etwa rp = rp + 1; beziehen sich auf das “Original” b und haben somit eine Wirkung nach außen. Eine Implementierung dieses Sprachkonzeptes kann (und wird in der Regel) so aussehen, dass innerhalb von f ein lokales Objekt rp angelegt und mit der Speicheradresse von b initialisiert wird. Man beachte, dass der Compiler dann für die beiden Zuweisungen innerhalb von f unterschiedlichen Code erzeugen muss. Im Gegensatz zu wp enthält rp nicht den neuen Wert, sondern die Variable, deren Adresse (= Referenz) in rp zu finden ist. Der Zugriff geht darum indirekt über rp auf b. 2.1.2 Referenzergebnisse Eine Funktion kann statt eines Wertes (genauer eines r–Werts) auch eine Referenz als Ergebnis liefern. Referenzen sind stets l–Werte. Damit ist es möglich, Funktionsaufrufe als l–Wert zu benutzen, etwa als linke Seite in einer Zuweisung. Beispiel: int & f (int &a) { return a; } int main () { int i, j; f(i) = 1; f(j) = 2; } // Funktion mit Referenzergebnis // Funktionsaufruf liefert l-Wert: // Funktionsaufruf darf links stehen In diesem Beispiel kommt genau das gleiche aus der Funktion als Ergebnis heraus, wie als Argument in sie hinein gegeben wurde: Eine Referenz auf eine Int–Variable. Die Funktion f gibt den l–Wert a zurück. Man vergleiche das mit der Definition int f (int & a) return a; Programmierung II 38 bei der der l–Wert a vor der Rückgabe in einen r–Wert konvertiert wird, das System also von der Variablen zu deren Inhalt übergeht. Ein Referenzergebnis muss nicht unbedingt als l–Wert genutzt werden. Da l–Werte in der Regel in r–Werte konvertiert werden können, kann die Funktion f sowohl links, als auch rechts vom Zuweisungszeichen auftauchen. int & f () {...} ... f() = f() + 1; Natürlich sollte man vermeiden, dass eine Referenz auf eine Variable geliefert wird, die an der Aufrufstelle der Funktion nicht mehr existiert, wie z.B. hier: int & f () { int x = 1; return x; // FEHLER: liefert Referenz auf lokale Variable x } int g () { int x = 1; return x; // OK: liefert Wert 1 } 2.1.3 Referenzvariablen Referenzvariablen sind Variablen, deren Inhalt eine Referenz ist. Referenzparameter sind somit Referenzvariablen. Referenzvariablen können auch explizit mit einer Variablendefinition angelegt werden: int i; int & j = i; // Lege eine Variable i vom Typ int an // j ist eine Referenzvariable, sie enthaelt eine Referenz auf i j wird hier als Referenz auf – und quasi als ein anderer Name von – i deklariert. Alle Aktionen auf j beziehen sich “in Wirklichkeit” auf i. Im Gegensatz zu einer “normalen” Variablen ist bei Referenzdefinitionen die Initialisierung unbedingt notwendig. Ebenfalls im Gegensatz zu “normalen” Variablen sind Initialisierung und Zuweisung bei Referenzen völlig unterschiedliche Aktionen: int i = 1; int & j = i; .. j = i; j = k; // Lege eine Variable vom Typ int an und belege sie mit 1 // j ist eine Referenzvariable, belegt mit einer Referenz auf i // Sinnlose Zuweisung an sich selbst (entspricht i = i;) // Zuweisung des Wertes von k an i (!), d.h. an das, auf das die // Referenz j zeigt Der Wert der Variable j wird nur bei der Initialisierung verändert. Alle anderen Aktionen auf j verändern i, also das, auf das j verweist. 2.1.4 Referenzinitialisierung Nach der Definition (mit Initialisierung) kann eine Referenz nicht mehr verändert werden. D.h. wenn j eine Referenz auf i ist, dann bleibt das so bis zum Ende von j. Anders als Referenzparameter und –Ergebnisse, haben Variablen mit Referenztyp nur wenige sinnvolle Anwendungen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 2.1.5 39 Referenzen sind l–Werte Referenzen sind l–Werte: Sie können auf der linken Seite einer Zuweisung stehen. Allerdings können sie (wie alle anderen l–Werte) auch auf der rechten Seite einer Zuweisung auftreten – oder ganz allgemein in jedem Kontext, in dem ein r–Wert benötigt wird. In diesem Fall sorgt der Compiler dafür, dass aus dem l–Wert ein r–Wert erzeugt wird: Statt der Variablen (l–Wert) wird ihr Inhalt (r–Wert) genommen. Aus r–Werten kann dagegen nicht so einfach ein l-Wert konstruiert werden. Beispiel: int i = 5; int & j = i; int f (int & pf) { //liefert r-Wert, verlangt l-Wert pf = pf + 1; return pf; } int & g (int pg) { //liefert l-Wert, verlangt r-Wert pg = pg + 1; return i; } ... j = 4; // OK: j ist l-Wert; 4 ist r-Wert 5 = i; // FEHLER: 5 ist kein l-Wert; i = j; // OK: i ist l-Wert; // j ist l-Wert -> Konversion in r-Wert moeglich; i = f (5); // KEIN FEHLER: 5 ist kein l-Wert, der Compiler legt eine // Variable (l-Wert) mit Inhalt 5 an. // WIRD NICHT EMPFOHLEN ! j = g (i); // OK: j ist l-Wert, i ist l-Wert, f(i) = 12; // FEHLER: Aufruf von f ist kein l-Wert g(i) = 5; // OK: i ist l-Wert -> Konversion in r-Wert moeglich; // g(i) ist l-Wert; i = g(5); // OK: i ist l-Wert; 5 ist r-Wert; // g(5) ist l--Wert -> Konversion in r-Wert moeglich; In diesem Beispiel sind alle Stellen, an denen ein l–Wert dort auftaucht, wo ein r-Wert erwartet wird, ohne Fehler. Jedes Vorkommen eines r-Werts dort, wo ein l-Wert verlangt wird, ist jedoch ein Fehler. 2.2 Ein– und Ausgabe selbst definierter Typen 2.2.1 Ein–/Ausgabe–Operatoren überladen Referenzen werden für praktische Zwecke öfters benötigt. Das erste Beispiel ist die Ein– und Ausgabe selbst definierter Typen. Bisher haben wir Ein–/Ausgabe für eine Klasse als Methoden definiert. Definiert man sie als befreundete überladene Operatoren, dann können Objekte selbstdefinierter Typen genau so wie Objekte vordefinierter Typen ein– und ausgegeben werden. Beispiel: class Vektor { friend ostream & operator<< (ostream &, const Vektor &); // Ausgabe- und friend istream & operator>> (istream &, Vektor &); // Eingabe-Operatoren public: // sind Freunde ... private: float x, y; }; //-- Ausgabe als befreundete freie Funktion: ostream & operator<< (ostream &os, const Vektor &v) { os << x << ", " << y; Programmierung II 40 return os; } //-- Eingabe als befreundete freie Funktion: istream & operator>> (istream &is, Vektor &v) { is >> v.x >> v.y; return is; } ... //-- Verwendung Vektor v; cin >> v; // Einlesen und cout << v; // Ausgeben wie bei vordefinierten Typen Der Ausgabeoperator << und der Eingabeoperator >> (die Shift–Operatoren) werden hier zur Aus– und Eingabe eines Objektes mit dem selbst definiertem Typ Vektor überladen. Man muss dabei beachten, dass << eine Referenz auf ein ostream–Objekt (output stream Objekt) als Argument hat und ein solches liefern muss. Entsprechendes gilt für >>. Die Operatoren liefern Ein/Ausgabeströme als Referenzen und haben entsprechende Parameter. Man merkt sich dieses Beispiel am Besten als Muster für beliebige Klassen mit definierten Ein/Ausgabefunktionen: // MUSTER ZUR DEFINITION VON EIN-/AUSGABE-OPERATOREN // fuer beliebige selbst definierte Klassen class T { friend ostream & operator<< (ostream &, const T &); friend istream & operator>> (istream &, T &); ... }; ostream & operator<< (ostream &os, const T &t) { ... t auf os ausgeben ... return os; } istream & operator>> (istream &is, Vektor &t) { ... t von is lesen ... return is; } ... T tt; cin >> tt; cout << tt; 2.3 2.3.1 Zustands– und Wertorientierte Klassen Was ist die richtige Klassendefinition? Die Mechanik der Klassendefinition zu verstehen ist eine Sache. Eine andere ist, die richtigen Klassen zu einer bestimmten Anwendung zu finden. Es gibt in der Regel nicht die richtige Klasse Menge zur Darstellung von “Mengen”, oder die richtige Klasse TelefonNr zur Darstellung von “Telefonnummern”. Es gibt nur eine für bestimmte Anwendungen mehr oder weniger geeignete Klasse zur Darstellung von “Mengen”, “Telefonnummern” oder anderen Dingen. Bisher haben wir uns bei einem strukturierten Datentyp in erster Linie gefragt: “Was ist ein Objekt; aus was besteht es?” und die Datentypen wurden entsprechend definiert. Dieses Denken ist nicht falsch, es ist aber zu ergänzen um die Frage, wie Objekte der zu definierenden Klasse verwendet werden sollen. Angenommen wir wollten ein Klasse “Punkt” definieren, deren Objekte Punkte in der Ebene darstellen. Die erste Frage – “Was ist / aus was besteht ein Punkt” – ist schnell beantwortet: Ein Punkt ist eine Position im zweidimensionalen Koordinatensystem und wird durch seine Koordinaten beschrieben: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 41 class Punkt { ... float x, y; ... }; 2.3.2 Die Verwendung definiert die Schnittstelle einer Klasse Jetzt stellt sich die Frage nach der Verwendung: “Wer benutzt Punkte zu welchem Zweck”. Wir stellen uns dazu zwei Szenarien vor: PunktA, mathematische Punkte: In einer mathematisch orientierten Anwendung soll beispielsweise der Schnitt von Geraden als Punkt berechnet, oder die Entfernung von zwei Punkten bestimmt werden. PunktB, graphische Punkte: In einer graphisch orientierten Anwendung werden Punkte als Positionen auf dem Bildschirm verwendet. Punkte dieser Variante sollen beispielsweise in der Ebene (auf dem Bildschirm) umherwandern können. Entsprechend der unterschiedlichen Verwendung haben unsere Punkte unterschiedliche Schnittstellen, also unterschiedliche öffentliche Methoden. 2.3.3 PunktA: Punkte als reine Werte Die “mathematischen Punkte” sind: class PunktA { // mathematische Punkte public: Punkt (float px, float py) : x(px), y(py) {} float distanzZu (const Punkt &p) const { return sqrt((x-p.x)*(x-p.x) + (y-p.y)*(x-p.x)); } private: const float x, y; }; // Punkte erzeugen // Distanz zweier Punkte // Koordinaten Punkte müssen demnach immer mit bestimmten Koordinaten erzeugt werden. Diese können dann nicht mehr verändert werden. Die “Philosophie” dieser Klassendefinition ist: Punkte sind reine unveränderliche Werte ohne eigene Individualität. Die Zahl ist ewig und unveränderlich. Ich kann keine zu einer machen, indem ich zwei dazu addiere. ergibt als Wert, aber die ist keine veränderte . Genauso gibt es einen einzigen Punkt und der liegt für immer und ewig an der Position . So wie nicht zu einer werden kann, so wenig kann der Punkt an eine andere Position wandern. Punkte heben keine Individualität. Zwei Punkte, die an der Position liegen sind nicht unterscheidbar, sie sind identisch, also letztlich der gleiche Punkt. 2.3.4 PunktB: Punkte als zustandsorientierte Klasse Die “graphischen Punkte” sind: class PunktB { // graphische Punkte public: Punkt() : x(0), y(0) {} void bewege (float dx, float dy) { x = x+dx; y = y+dy; } // Punkte erzeugen // Punkte in x- und y-Richtung bewegen Programmierung II 42 private: float x, y; }; // Koordinaten Punkte werden immer an der Position erzeugt. Von dort können sie mit der Methode bewege verschoben werden. Punkte sind in diesem Verständnis veränderliche Wesen. Sie haben einen Zustand – ihre aktuelle Position – der jederzeit verändert werden kann. Die “Philosophie” dieser Klassendefinition ist jetzt: Punkte sind Dinge mit einem veränderlichen Zustand und eigenener Individualität. Ein Punkt kann erzeugt werden. Er hat gewisse Eigenschaften. Diese können sich ändern. Das nennt man “Zustandsänderung”. Bei allen Zustandsänderungen bleibt der Punkt aber immer der Gleiche. Zwei Punkte, die beide an der Position liegen, sind keinesfalls die gleichen Punkte: der eine wandert gleich vielleicht nach oben und der andere nach unten. 2.3.5 Unterschiedliche Anwendungen benötigen unterschiedliche Punkte Eine wichtige Leitlinie beim Entwurf einer Klasse ist also die Frage, wie die Objekte der Klasse verwendet werden sollen. Hierbei kann man zwei prinzipielle Arten unterscheiden: Sind die Objekte der Klasse im Verständnis der Anwendung reine unveränderliche Werte oder haben sie einen Zustand der sich im Laufe ihres Lebens ändern kann. Die Antwort auf diese Frage führt dann zu einer wertorientierten oder einer zustandsorientierten Klassendefinition. Die Unterscheidung ist allerdings nicht dogmatisch zu sehen, es ist ein Hinweis für den Entwurf einer Klassendefinition. Ein erster und wichtiger Hinweis, aber nur ein Hinweis. 2.3.6 Stapel, zustandsorientiert Das natürliche Verständnis eines Stapels ist zustandsorientiert. Ein Stapel hat eine Identität, die er bei allen Veränderungen im Laufe seines Lebens beibehält. Zwei Stapel sind nie derselbe auch wenn auf beiden das Gleiche aufgestapelt ist. Ein Stapel hat einen Zustand: das was gerade auf ihm gestapelt ist. Sein Zustand kann verändert werden, indem man etwas auf ihm ablegt oder wegnimmt. Es bleibt aber derselbe Stapel. Eine von dieser Sicht inspirierte Definition eines Stapels (engl. Stack) von Integer–Werten ist: class Stack { public: Stack (); // Konstruktor, erzeugt den leeren Stapel // Leseoperationen, mit Ergebnis, konstant char top () const; // liefert das oberste Element bool empty () const; // ist der Stapel leer bool full () const; // ist der Stapel voll // Schreiboperationen, ohne Ergebnis, nicht konstant void pop (); // veraendert Stapel-Zustand: entfernt Element void push (int); // veraendert Stapel-Zustand: legt ein Element ab private: int count; // aktuelle Zahl der Elemente int a[10]; // Ablageplatz fuer die Elemente }; Ein Stapel–Objekt hat einen Zustand, der sich aus den in ihm abgelegten Elementen ergibt. Mit lesenden Zugriffsoperationen kann man etwas über den Zustand erfahren. (Meist konstante Methoden mit Ergebnis.) Mit schreibenden Zugriffsoperationen kann der Zustand verändert werden. (Meist nicht–konstante Methoden ohne Ergebnis.) Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 2.3.7 43 Verwendung zustandsorientierter Klassen Eine von der zustandsorientierten Sicht inspirierte Klasse wird in der Regel dazu verwendet eine begrenzte Zahl von Objekten zu erzeugen, die dann wechselnde Zustände durchlaufen. Beispielsweise könnte die Klasse Stack wie folgt verwendet werden: Stack s; int i; ... s.push(i); ... if (!s.empty()) { i = s.top (); s.pop (); ... } // Ablegen // Ist er leer ? // Entnehmen Derartige Klassen entsprechen dem klassischen Verständnis von Objektorientierung, nach dem eine Klasse ein Modell einer endlichen Anzahl von Dingen mit jeweils eigener Identität ist. Ein Objekt wird dabei als “Automat” mit wechselnden Zuständen betrachtet. 2.3.8 Komplex: Eine Klasse mit wertartigen Instanzen Komplexe Zahlen sind etwas anderes als Stapel. Legt man auf einem Stapel etwas ab, dann verändert er sich. Addiert man dagegen zu einer komplexen Zahl eine andere, dann verändert sich nicht etwa eine der beiden, sondern als Ergebnis entsteht eine dritte. Werte haben zwar auch eine interne Struktur, diese stellt aber keinen veränderlichen Zustand dar. Definition und Verwendung einer wertartigen Klasse zur Modellierung komplexer Zahlen können etwa wie folgt aussehen: class Komplex { public: Komplex (); Komplex (float, float); Komplex operator+ (const Komplex &) const; private: const float re; const float im; }; ... Komplex k1, k2, k3; ... k3 = k1 + Komplex(0.0, 2.3); Typische Eigenschaften einer wertartigen Klassendefinition sind: In einem Programmlauf werden viele Instanzen erzeugt. Instanzen werden auch per Wertübergabe an Funktionen übergeben. Funktionen geben Objekte dieser Klasse als Wert zurück. Variablen vom entsprechenden Typ werden zugewiesen. Programmierung II 44 1/2 1/3 2/3 5/6 Wert Variable Modell Modell class Bruch { Bruch b1, b2; Modell für Werte und ihre Operationen public: Bruch (); Bruch operator+ (const Bruch &) const; . . . }; b1 = b2; Abbildung 5: Eine wertorientierte Klasse 2.3.9 Brüche als wertorientierte Klasse Nehmen wir an, dass Brüche zu verarbeiten seinen. (Siehe Abbildung 5.) “Bruch” ist ein Wertekonzept. Die Objekte der Klasse Bruch modellieren reine Werte. Ein Wert kann Inhalt einer Variablen sein, Eine Variable kann ihren Wert wechseln, ein Bruch – ein Wert – dagegen kann sich niemals ändern. Eine Variable vom Typ Bruch kann beliebige Objekte der Klasse Bruch als Wert enthalten. Nicht die Variable repräsentiert den Bruch sondern der Inhalt der Variablen – der Wert. 2.3.10 Reiner Wert oder Zustandsorientiert? Man kann den Stapel auch als reine Werte ansehen und komplexe Zahlen oder Brüche als etwas zustandsbehaftetes ansehen. In diesem Fall wäre etwa push eine Funktion, die nicht einen Stapel verändert, sondern einen neuen Stapel erzeugt: class Stack { // wertorientierter Stapel: SELTSAM public: Stack (); Stack push (Stack, int) const; // erzeugt neuen Stapel ... }; ... Stack s1, s2; ... s2 = push (s1, 5); // neuen Stapel als Wert von s2 erzeugen, // s1 bleibt unveraendert Die Addition als Methode komplexer Zahlen mit Zustand wäre: class Komplex { public: Komplex (); // zustandsorientierte komplexe Zahlen: SELTSAM Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 45 void add (const Komplex &k) { // addiere zu mir re = re+k.re; im = im+k.im; } private: float re; float im; }; ... Komplex k1, k2; ... k1.add(k2); // die komplexe Zahl k1 wird durch Addition von k2 modifiziert Wertorientierte Klassen sind “mathematisch” indem sie ewige unvergängliche Konzepte modellieren. Zustandsorientierte Klassen modellieren die “Wirklichkeit” mit ihren veränderlichen, greifbaren vergänglichen Dingen. Ob ein Sachverhalt eher dem einen oder dem anderen Muster folgt, legt oft schon das “Gefühl” nahe. Stapel, die man nicht verändern kann, und komplexe Zahlen, die einen veränderlichen Zustand haben, sind nicht völlig falsch, aber doch zumindest sehr “eigenwillig”. Die Entscheidung, ob eine zu definierende Klasse als reiner Wert oder mit veränderlichem Zustand verwendet werden soll, ist zwar nicht immer so offensichtlich, wie in diesen beiden Beispielen, aber glücklicherweise doch recht oft. 2.3.11 Konzeptionelles Chaos vermeiden Ein klares Konzept für eine Klasse ist immer wichtiger als das “richtige” Konzept. Eine Klasse für komplexe Zahlen, deren Schnittstelle beide Konzepte mischt, ist verwirrend kompliziert und sollte vermieden werden: class Komplex { // PFUI, TOTAL VERMURKSTE komplexe Zahlen public: Komplex (); void add (const Komplex &); // zustandsorientierte Addition Komplex sub (const Komplex &) const; // wertorientierte Subtraktion private: float re; float im; }; ... Komplex k1, k2; ... k1.add(k2); k2 = k1-k2; Hinter der Schnittstelle, im privaten Bereich der Klasse, ist natürlich alles erlaubt. Die Schnittstelle definiert die mögliche Verwendung und die sollte auf einem möglichst einfachen und klaren Konzept beruhen. class Komplex { // Wert-orientierte komplexe Zahlen public: // Schnittstelle, hier achten wie auf Stil // (Was sollen sonst die Leute denken ..) Komplex (); Komplex (float r, float i) : re(r), im(i) {} Komplex add (const Komplex &k) const { // wertorientiert Kompex res = k; k.add( Komplex(re, im) ); return res; } private: Programmierung II 46 // Implementierung: erlaubt ist, was funktioniert void add (const Komplex &k) { re = re+k.re; im = im+k.im; } float re; float im; }; ... Komplex k1, k2; ... k1.add(k2); // die komplexe Zahl k1 wird durch Addition von k2 modifiziert 2.4 2.4.1 Von Abstrakten zu Konkreten Datentypen: Zuweisung und Kopie Abstrakte und konkrete Datentypen Datentypen wie int, char, float, etc., sind konkret. Sie werden von der Programmiersprache direkt unterstützt. Man kann Variablen von diesem Typ definieren, ihnen Werte zuweisen und mit speziellen Operatoren verknüpfen. Abstrakte Datentypen existieren dagegen nicht wirklich. Sie sind eine Fiktion des Programms bzw. seiner Autoren. In einem Programm einer Finanzbehörde etwa könnten Mengen von Hunden verwaltet werden: Die Hunde für welche die Hundesteuer schon bezahlt wurde, diejenigen bei denen sie noch aussteht und die Menge der Hunde, die steuerbefreit sind. Es gibt zwar keine Programmiersprache die den Datentyp “Hund”, oder gar “Hunde–Menge” anbietet5, aber mit Feldern, Verbunden, Klassen oder anderen Datentypen sowie einigen geeigneten Routinen kann man einen Datentyp “Hund” oder “Hunde–Menge” simulieren: “Hunde” und “Hunde–Menge” sind abstrakte Datentypen (ADT, Abstract Data Type). 2.4.2 Klassen können zur Definition abstrakter Datentypen verwendet werden C++ wurde unter anderem mit dem Ziel definiert, die Arbeit mit derartigen “Datenabstraktionen” zu erleichtern. Das Klassenkonzept unterstützt die Definition abstrakter Datentypen direkt: Man kann eine Klasse Hund mit allen benötigten Methoden definieren und hat dann den Datentyp “Hund”. 2.4.3 Konkrete Datentypen: mustergültige Klassen Eine Klasse kann verwendet werden, um Variablen anzulegen. Der abstrakte Datentyp entspricht in dieser Hinsicht einem konkreten vordefinierten Typ. Das gilt aber nicht immer für alle Aspekte eines Typs. Ein Klasse ist nicht unbedingt automatisch einem vordefinierten Typ gleichwertig. Es ist eine besondere Eigenschaft von C++, dass es möglich ist eigene Datentypen – als Klassen – so zu definieren, dass sie sich genau so verhalten wie die “eingebauten” konkreten Datentypen. Was dazu zu tun ist, ist Thema dieses Abschnitts. Verhält sich eine Klasse wie ein vordefinierter Typ, dann besteht kein Grund mehr sie mit einem so abschreckenden Begriff wie “abstrakt” zu versehen.6 Konkrete Typen sind also nicht nur die vordefinierten, sondern, außer diesen, auch alle selbstdefinierten, die sich wie vordefinierte verwenden lassen. Es sind damit mustergültige Klassendefinitionen. Um solche Musterknaben von konkreten Datentypen selbst zu definieren, müssen wir zunächst einmal herausfinden, was ein vordefinierter – also ein von “Natur aus” konkreter – Datentyp kann. 2.4.4 Fähigkeiten konkreter Datentypen Ein vordefinierter Typ unterstützt in jedem Fall folgende Aktionen: 5 Zumindest keine, die 6 Abstraktion ist dem Autor bekannt ist ein Kernkonzept der Informatik. Der Begriff “abstrakt” sollte für Informatiker nicht abschreckend klingen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 47 Variablendeklarationen Zuweisung Parameterübergabe per Referenz Parameterübergabe per Wert Dazu kommen dann in der Regel noch eine Reihe von Operatoren. Ein selbst definierter Datentyp ist konkret, wenn auch mit ihm all dies möglich ist. Mit Klassen können selbst definierte konkrete Datentypen realisiert werden. Nur bei ihnen hat man die Möglichkeit die Wirkung einer Zuweisung etc. selbst zu bestimmen. 2.4.5 Komponenten konkreter Datentypen Damit Objekte einer selbstdefinierten Klasse so flexibel verwendet werden können wie Werte vordefinierter Typen, muss die Klassendefinition einige Standard–Komponenten enthalten: Defaultkonstruktor (nicht vordefiniert): Ein parameterloser Konstruktor, der sogenannte Default–Konstruktor. Er wird bei einer einfachen Variablendefinition benötigt, sowie dann, wenn ein Objekt als Feldelement oder als Datenelement eines anderen Objektes angelegt wird. Kopierkonstruktor (vordefiniert): Der Kopier–Konstruktor erzeugt eine identische Kopie eines Objektes. Er wird vor allem bei der Wertübergabe eines Objekts benötigt. Zuweisungsoperator (vordefiniert): Diese Komponente wird bei Zuweisungen benötigt. Vergleichsoperator (nicht vordefiniert): In vielen Fällen wird man die Objekte mit == vergleichen wollen, dazu wird eine Definition des Vergleichsoperators benötigt. Sollten diese Komponenten nicht explizit im Programmcode definiert sein, dann werden sie nur zum Teil (nur Zuweisung und Kopie !) vom Compiler automatisch erzeugt. Der automatisch erzeugte Zuweisungsoperator und der Kopierkonstruktor kopieren einfach alle Datenkomponenten. Auf diese Automatik sollte man sich aber nur mit Bedacht verlassen. 2.4.6 Vektoren als konkreter Datentyp Als Beispiel wollen wir einen konkreten Datentyp Vektor definieren. Vektoren als konkreter Datentyp: class Vektor { // Vektoren als konkreter Datentyp, Minimalversion public: Vektor () : x(0.0), y(0.0) {} // Defaultkonstruktor Vektor (float a, float b) : x(a), y(b) {} // Konstruktor bool operator== (const Vektor &) const { // Vergleich return (x == v.x) && (y == v.y); } private: float x, y; }; Der Vergleichsoperator muss definiert werden, weil sonst keine Vergleiche möglich wären. Ein Destruktor ist nicht notwendig: beim Tod eines Vektors muss nichts weiter geschehen. Programmierung II 48 2.4.7 Kopierkonstruktor Der Kopier–Konstruktor wird bei der Wertübergabe benötigt. Bei der Wertübergabe wird vom aktuellen Parameter eine Kopie erzeugt und an die Funktion übergeben. Der Compiler ruft den Kopierkonstruktor auf, um diese Kopie zu erzeugen. Wurde – so wie hier – der Kopier–Konstruktor nicht explizit definiert, dann erzeugt der Compiler ihn selbst. Der automatisch erzeugte Kopier–Konstruktor kopiert einfach alle Datenelemente. Wir definieren einen Kopierkonstruktor, der dem automatisch erzeugten entspricht: class Vektor { public: ... // Kopierkonstruktor: Vektor(const Vektor &v) : x(v.x), y(v.y) {} // initialisiere mich mit ... // einer Kopie von v }; Man beachte dass der Parameter per Referenz übergeben werden muss: der Kopierkonstruktor definiert die Wertübergabe, kann sie also nicht selbst benutzen. 2.4.8 Zuweisungen und der Zuweisungsoperator Zuweisungen von einer Variablen an eine andere sind jederzeit auch ohne besondere Definitionen möglich. D.h. Vektoren können ohne weiteres zugewiesen werden: Vektor v1, v2; v1 = v2; Zu jeder Klasse, zu der nicht explizit ein Zuweisungsoperator definiert wurde, erzeugt ihn der Compiler automatisch. Von einem solchen implizit definierten Zuweisungsoperator werden einfach alle Datenkomponenten kopiert. Wie beim Kopierkonstruktor ist die automatisch generierte Zuweisung oft – aber nicht immer – völlig korrekt und ausreichend. Auch im Fall unserer Vektoren ist der automatisch erzeugte Zuweisungsoperator völlig ausreichend. Der automatisch erzeugte Zuweisungsoperator entspricht folgender expliziten Definition: class Vektor { public: ... Vektor & operator= (const Vektor &v) { // Zuweisungsoperator x=v.x; y=v.y; // Belege mich mit einer Kopie von v return *this; // und liefere eine Referenz auf mich } ... }; Der Zuweisungsoperator sieht dem Kopierkonstruktor recht ähnlich. Das ist sicher nicht erstaunlich, sie haben ja sehr ähnliche Aufgaben. Die Unterschiede sind: Keine Initialisierer im Zuweisungsoperator: Da der Zuweisungsoperator kein Konstruktor ist, können keine Initialisierer verwendet werden. Rückgabewert im Zuweisungsoperator: Der Kopierkonstruktor darf als Konstruktor keinen Wert liefern. Der Zuweisungsoperator dagegen muss einen Wert liefern. 2.4.9 this: dieser da, das bin Ich! Der Zuweisungsoperator verändert das Objekt für das er aufgerufen wird und liefert eine Referenz auf dieses veränderte Objekt: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 49 Vektor & operator= (const Vektor &v) { ... return *this; } this ist ein Schlüsselwort, das in jeder Methode verwendet werden kann, und dort mit einem Verweis auf das aktuelle Objekt belegt ist. Ist beispielsweise x.m() aktiv, dann bezeichnet this in X::m() ... this ... einen Verweis auf das Objekt x vom Typ X. *this bezeichnet im Gegensatz zu this nicht den Verweis auf das Objekt, sondern das Objekt selbst. Der Sternoperator “*” wird im nächsten Kapitel ausführlich besprochen. Wir merken uns hier einfach, dass jeder Zuweisungsoperator mit return *this; endet. 2.4.10 Die Zuweisung liefert eine Referenz auf ihre linke Seite Wie bei allen anderen dyadischen Operatoren (solchen mit zwei Operanden) gilt auch bei der Zuweisung folgende Äquivalenz: a=b; a.operator=(b); Der Zuweisungsoperator muss als Resultat einen Wert liefern, weil in C++ (wie in C) Zuweisungen als Ergebnis stets einen Wert liefern. Es ist notwendig weil a = b = c; erlaubt ist und – wenn man Seiteneffekte bei der Auswertung von a, b und c ignoriert – das Gleiche wie: b = c; a = b; bedeutet: a=b=c; a.operator=(b.operator=(c)); a=(b=c); Das Ergebnis der Zuweisung wird hier zugewiesen. Das macht es noch nicht notwendig, dass die Zuweisung einen l–Wert liefert, ein r–Wert wäre schon ausreichend. Allerdings ist die Form (a=b)=c; (a.operator=(b)).operator=(c); ebenfalls erlaubt. Hier wird jetzt ein Wert (der von c) an das Ergebnis einer Zuweisung (das durch die erste Zuweisung veränderte a) zugewiesen. Das Ergebnis muss darum ein l–Wert sein. Der Zuweisungsoperator muss also einen Wert liefern, dieser Wert muss ein l–Wert sein und der Typ eines l–Werts ist ein Referenztyp. Wem das zu kompliziert ist, merke sich einfach das verbindliche Muster eines Zuweisungsoperators, ohne darüber nachzudenken, warum er so aussieht. 2.4.11 Vergleichsoperator Ein ordentlicher Datentyp bietet die Möglichkeit seine Mitglieder mit dem Vergleichsoperator == zu vergleichen. Wir definieren darum auch einen Vergleichsoperator für unsere Vektoren. class Vektor { public: ... bool operator== (const Vektor &) const { // Vergleichsoperator return (x == v.x) && (y == v.y); // Vergleiche mich mit v } ... }; Im Gegensatz zum Zuweisungsoperator und Kopierkonstruktor wird der Vergleichoperator niemals automatisch erzeugt. 2.4.12 Vektor als konkreter Datentyp Zum Schluss noch einmal die gesamte Definition des konkreten Datentyps Vektor: Programmierung II 50 class Vektor { // Vektoren als konkreter Datentyp public: Vektor () : x(0.0), y(0.0) {} // Defaultkonstruktor Vektor (float a, float b) : x(a), y(b) {} // Konstruktor Vektor(const Vektor &v) : x(v.x), y(v.y) {} // Kopierkonstruktor // (so schon vordefiniert) bool operator== (const Vektor &v) const { // Vergleich return (x== v.x) && (y == v.y); } Vektor & operator= (const Vektor &v) { // Zuweisung x=v.x; y=v.y; // (so schon vordefiniert) return *this; } Vektor operator+ (const Vektor &v) const { // Vektoraddition return Vektor(x+v.x, y + v.y); } private: float x, y; // Datenkomponenten }; Zuweisungsoperator und Kopierkonstruktor entsprechen dem was der Compiler automatisch erzeugt hätte. Natürlich wird man in einer realistischen Anwendung noch weitere Methoden definieren. Die besondere Eigenschaft von C++ besteht darin Zuweisung und Kopie selbst definieren zu können. Im Beispiel oben haben wir das getan. Allerdings war das keine so besonders mitreißende Aktion: Hätten wir es nicht getan, dann hätte der Compiler exakt die gleichen Definitionen automatisch erzeugt. Wenn die Freiheit, Kopie und Zuweisung selbst definieren zu können, zu mehr nutze ist als nur beliebigen Unsinn treiben zu können, dann muss es Klassen geben, für die der Compiler Kopie und Zuweisung eben nicht korrekt erzeugen kann – und die gibt es tatsächlich. 2.4.13 Vektoren die ihre Exemplare zählen Die Klasse Vektor soll in dieser Variante die Zahl ihrer Objekte zählen. Das gibt uns Grund einen Destruktor zu definieren, sowie einen Kopierkonstruktor der sich von dem unterscheidet den der Compiler automatisch erzeugen würde. Vektoren mit Objektzählung als konkreter Datentyp: class Vektor { // Vektoren mit Objektzaehlung friend ostream & operator<< (ostream &, const Vektor &); friend istream & operator>> (istream &, Vektor &); public: Vektor (); // Defaultkonstruktor Vektor (float, float); // Konstruktor ˜Vektor (); // Destruktor Vektor(const Vektor &); // Kopierkonstruktor bool operator== (const Vektor &) const; // Vergleich Vektor & operator= (const Vektor &); // Zuweisung Vektor operator+ (const Vektor &) const;// dyadischer Operator static int zV (); // statische Methode private: static int vc; // statische Datenkomponente float x, y; // Datenkomponenten }; // statische Datenkomponente (Klassenvariable) definieren int Vektor::vc = 0; // Konstruktoren (muessen alle definiert werden): Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 51 Vektor::Vektor () : x(0.0), y(0.0) {++vc;} Vektor::Vektor (float a, float b) : x(a), y(b) {++vc;} // Kopierkonstruktor (entspricht NICHT dem vordefinierten) Vektor::Vektor (const Vektor &v) : x(v.x), y(v.y) {++vc;} // Destruktor: Vektor::˜Vektor() {--vc;} // Zuweisung (entspricht der vordefinierten): Vektor & Vektor::operator= (const Vektor &v) { x=v.x; y=v.y; return *this; } // Vergleich bool Vektor::operator== (const Vektor &v) const { return (x== v.x) && (y == v.y); } // Operator + Vektor Vektor::operator+ (const Vektor &v) const { return Vektor (x + v.x, y + v.y); } // statische Methode int Vektor::zV () { return vc; } // Ein-/Ausgabe ostream & operator<< (ostream &os, const Vektor &v) { return os << "(" << v.x << ", " << v.y << ")"; } istream & operator>> (istream & is, Vektor & v) { char c; is >> c; if (c != ’(’) {cout << "FEHLER: ( erwartet!\n"; return is;} is >> v.x; is >> c; if (c != ’,’) {cout << "FEHLER: , erwartet!\n"; return is;} is >> v.y; is >> c; if (c != ’)’) {cout << "FEHLER: ) erwartet!\n"; return is;} return is; } Hier muss der Kopierkonstrutor selbst definiert werden. Bei der Erzeugung eines Vektors durch Kopie wird die Zahl der Vektoren erhöht. Dies muss sich im Wert von vc niederschlagen. Der automatisch erzeugte Kopierkonstruktor würde vc nicht verändern und wäre darum nicht korrekt. Wir haben es hier also mit einem Beispiel dafür zu tun, dass der Programmierer selbst definieren muss, was es bedeutet, ein Objekt zu kopieren. Diese Erklärung liefert er im Kopierkonstruktor. Der Kopierkonstruktor erlaubt es, die Aktion des Kopierens eines Objekts bei Bedarf selbst zu definieren. Der Zuweisungsoperator erlaubt es, die Aktion der Zuweisung eines Objekts bei Bedarf selbst zu definieren. Die explizite Definition des Zuweisungsoperators ist auch in diesem Beispiel eigentlich überflüssig. Die vordefinierte Methode verhält sich genauso wie die hier definierte. Programmierung II 52 Das Beispiel dient als Muster einer wertorientierten Klasse. So macht man es im Prinzip immer! Alle wertorientierten Klassen sollten nach diesem Muster als konkrete Datentypen definiert werden! (Die statischen Komponenten sind eher untypisch.) 2.4.14 Vordefinierte überladene Operatoren Für alle benutzerdefinierten Klassen haben folgende Operatoren eine vordefinierte Bedeutung: = Zuweisung & Adressoperator (Adresse eines Objekts) , Komma–Operator (Hintereinander–Auswertung von Ausdrücken) Alle anderen Operatoren müssen bei Bedarf selbst definiert werden. Der Zuweisungsoperator muss nur dann selbst definiert werden, wenn er nicht – wie vordefiniert – als komponentenweise Zuweisung arbeiten soll. Es wird aber empfohlen alle benötigten Operatoren selbst zu definieren, auch die Zuweisung. Man beachte dabei, dass die kombinierten Operatoren, wie etwa “+=”, eigenständige Operatoren sind, die nicht automatisch mit definiert sind, wenn ihre Komponenten, wie “+” und “=”, definiert sind. 2.4.15 Konkrete Datentypen müssen nicht wertorientiert sein Unser Beispiel für konkrete Datentypen, die Vektorenklasse, ist rein wertorientiert. Das muss nicht so sein. Ein konkreter Datentyp muss nicht wertorientiert sein. Die Konzepte sind unabhängig voneinander. Konkret ist ein “technischer” Begriff: Eine Klasse ist konkreter Datentyp, wenn sie in technischer Hinsicht vollständig mit allem Notwendigen ausgestattet ist, damit ihre Objekte in allen Situationen korrekt funktionieren. Wertorientiert und zustandsorientiert sind “philosophische” Begriffe: Eine Klasse ist wertorientiert, wenn ihre Objekte als reine unveränderliche Werte verwendet werden; sie ist zustandsorientiert, wenn ihre Objekte veränderlich sind. Wertorientierte Klassen sollten immer auch konkrete Datentypen sein. Ihre Objekte können nicht verändert werden, man verwendet sie darum zwangsläufig in Zuweisungen, als Parameter und Funktionsergebnisse, etc. Sie müssen dort also korrekt funktionieren. 2.4.16 Zustandsorientierte konkrete Datentypen Ein Stapel dient dazu Werte zwischenzuspeichern. Er ist naturgemäß zustandsorientiert. Sollte er auch ein konkreter Datentyp sein? Nicht unbedingt. Einen Stapel kann man – im Gegensatz zu einem Vektor – sinnvoll verwenden, ohne ihn von einer Variablen an eine andere zuzuweisen, oder ihn an eine Funktion zu übergeben. Sollte er aber zugewiesen oder übergeben werden, dann muss die Klasse das auch können: Zuweisungsoperator, Kopierkonstruktor und das was sonst noch gebraucht wird, muss definiert sein und korrekt arbeiten. Der Stapel wird damit zum konkreten Datentyp: class Stack { public: Stack (); // Default-Konstruktor Stack (const Stack &); // Kopierkonstruktor Stack & operator= (Stack &); // Zuweisungsoperator bool operator== (const Stack &) const; // Vergleich char top () const; // liefert das oberste Element bool empty () const; // ist der Stapel leer bool full () const; // ist der Stapel voll Th Letschert, Fachbereich MNI, FH Giessen–Friedberg void pop (); void push (int); private: ... }; ... Stack s1, s2; int i; Stack f( Stack ); ... while (!s1.full()) { cin >> i; s1.push(i); } if ( s1 == s2 ) ... s2 = s1; s1 = f(s2); 53 // entfernt das oberste Element // legt ein Element ab // normale zustandsorientierte Verwendung // Vergleich, Aufruf von operator== // Zuweisung, Aufruf von operator= // Wertuebergabe, Aufruf von Stack (const Stack &) Zuweisungen und Wertübergabe sind bei Stapeln sicher ungewöhnlich; sie sind aber nicht undenkbar. Als Autor einer Klasse über deren Verwendung man nicht, oder noch nicht, genau Bescheid weiß, sollte man aber alle möglichen Verwendungen in Betracht ziehen. Alle Verwendungen in Betracht ziehen heißt, sie zum konkreten Datentyp zu machen. 2.4.17 Zusammenfassung Unsere Diskussion über abstrakte und konkrete Datentypen ist kurz zusammengefasst: Abstrakte Datentypen sind selbst implementierte Typen. C++ bietet das Klassenkonzept. Mit ihm können abstrakte Datentypen gut definiert werden. Konkrete Datentypen sind vordefinierte Typen sowie Klassen die wie vordefinierte Typen verwendet werden können. Klassen können immer so definiert werden, dass sie konkrete Datentypen darstellen, d.h sich wie vordefinierte Typen verhalten. Um eine Klasse zum konkreten Datentyp zu machen wird geprüft, ob die automatisch erzeugten Methoden für Zuweisung und Kopie korrekt funktionieren und – wenn nicht – dann werden Kopierkonstruktor und Zuweisungsoperator selbst definiert. Bei zustandsorientierten Klassen ist es empfehlenswert sie konkret zu machen, bei wertorientierten Klassen ist es unabdingbar. 2.5 2.5.1 Beziehungen zwischen Klassendefinitionen Die Benutzt–Relation Eine Klassendefinition kommt meist nicht allein. Ernsthafte Programme enthalten in der Regel mehrere davon. Diesen Definitionen stehen zum großen Teil auch in Beziehung (Relation) zueinander. Die allgemeinste Form einer Beziehung zwischen Klassen (–Definitionen) ist die Benutzt–Relation: Eine Klasse benutzt eine andere Klasse Definition von abhängt. , wenn die Definition von in irgendeiner Form von der Eine Benutzt–Relation besteht beispielsweise dann, wenn ein Parameter einer Methode von den Typ hat: Programmierung II 54 class A { // benutzt B ... void m (B b); // hier wird B benutzt ... }; class B { ... } Die Benutzt–Relation besteht praktisch immer dann, wenn in der Definition von irgendwo ein auftaucht. Sie sagt nicht mehr, als dass die Definition von sich ändern könnte, wenn die von geändert wird. Man beachte, dass es um Relationen zwischen Klassen–Definitionen geht! In UML wird die Benutzt–Relation als gestrichelter Pfeil dargestellt (siehe Abbildung 6). A B void m(B); Abbildung 6: Benutzt–Relation: 2.5.2 benutzt Benutzt–Relation mit Kennzeichnung Eine Benutzt–Relation besteht auch zwischen Freunden. Ist Klasse ein Freund von Klasse , dann benutzt Klasse Klasse . In UML können solche speziellen Varianten der Benutzt–Relation durch textuelle Kennzeichnungen verdeutlicht werden (siehe Abbildung 7). <<friend>> A class A { .. benutzt B Komponenten .. }; B class B { friend class A; ...}; Abbildung 7: Freundschaft als Benutzt–Relation mit Kennzeichnung Der Pfeil geht hier vom Freund zu dem der die Freundschaft erklärt. Das ist auf den ersten Blick vielleicht etwas verwirrend, aber konsistent. Wenn die Klasse die Klasse benutzt, dann bedeutet das, dass eine Veränderung von Auswirkungen auf haben kann. Ist ein Freund von , dann benutzt private Komponenten von . Verändert man , dann sollte entsprechend angepasst werden. Eine Veränderung von hat dagegen keine Auswirkungen auf . 2.5.3 Komposition Die Enthält–Relation – auch Komposition genannt – ist, wie die Freundschaft, eine spezielle Variante der Benutzt– Relation: Eine Klasse enthält eine andere Klasse , wenn Datenkomponenten vom Typ hat. Diese Variante der Benutzt–Relation ist so wichtig, dass ihr in UML ein spezielles Symbol zugeordnet wurde (siehe Abbildung 8). Auch die Komposition ist eine Beziehung zwischen Klassendefinitionen. Wir können aus ihr aber auch eine konkrete Beziehung zwischen Objekten ableiten. Wenn Klasse A Klasse B enthält, dann besteht auch jedes A–Objekt aus mindestens einem B–Objekt. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 55 A B Ganzes Komponente class A { ... B b; ... }; Abbildung 8: Komposition 2.6 2.6.1 Beispiel: Geometrische Objekte Klassendefinitionen Ein etwas umfangreicheres aber immer noch einfaches Beispiel mehrerer Klassendefinitionen, die zueinander in Beziehung stehen, sind die geometrischen Objekte Vektor, Punkt, Gerade. Wir beginnen mit der Definition der Vektoren: class Vektor { friend ostream & operator<< (ostream &, const Vektor &); friend istream & operator>> (istream &, Vektor &); public: Vektor (); // Nullvektor Vektor (float, float); Vektor (const Vektor &); // Kopierkonstruktor Vektor & operator= Vektor operator+ Vektor operatorbool operator== (const (const (const (const Vektor Vektor Vektor Vektor &); &) const; &) const; &) const; // // // // Zuweisung Vektoraddition Vektorsubtraktion Vergleich float x () const; // x-Koordinate float y () const; // y-Koordinate static bool kollinear (const Vektor &, const Vektor &); static float determinante (const Vektor &, const Vektor &); private: float x_; float y_; }; Punkte sind durch ihre Position im Koordinatensystem bestimmt, und diese wird durch einen Ortsvektor festgelegt: class Punkt { public: Punkt (const Vektor &); Punkt (float, float); Vektor pos () const; // Position des Punkts private: Vektor ov_; // ein Punkt ist durch seinen Ortsvektor defininiert }; Geraden werden in Punkt–Richtungs–Form angegeben. Der Einfachheit halber wird der Punkt durch seinen Ortsvektor angegeben: class Gerade { friend ostream & operator<< (ostream &, const Gerade &); friend istream & operator>> (istream &, Gerade &); Programmierung II 56 public: Gerade Gerade Gerade Gerade (); (const Vektor &, const Vektor &); (const Punkt &, const Vektor &); (const Gerade &); Gerade & operator= (const Gerade bool operator== (const Gerade bool operator|| (const Gerade Vektor operator* (const Gerade &); &) const; &) const; &) const; // // // // Zuweisung Vergleich Parallel Schnittpunkt Vektor p () const; // Punkt Vektor a () const; // Richtung static const Gerade x_Achse; static const Gerade y_Achse; private: Vektor Vektor }; p_; a_; Gerade Punkt Vektor Abbildung 9: Relationen zwischen den drei Geometrieklassen 2.6.2 Klassendiagramm Die Beziehungen der Klassen lassen sich durch ein Klassendiagramm darstellen (siehe Abbildung 9). Die freien Funktionen und Operatoren können nicht dargestellt werden, da das Konzept einer freien Funktion in UML nicht existiert. Geraden und Punkte haben jeweils Vektoren als Komponenten. Ein Parameter eines Konstruktors der Geraden ist vom Typ Punkt. Dies lässt sich nur allgemein als Benutzung darstellen. Wir haben damit drei Klassen und drei Beziehungen zwischen ihnen: Punkt enthält Vektor. Gerade enthält Vektor. Gerade benutzt Punkt. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 2.7 57 Übungen Aufgabe 1 1. Welche Konversionen von einem l–Wert in einen r–Wert und/oder umgekehrt finden in folgendem Programm statt: int i = 50; int & j = i; int & f (int & p1, int p2) { p1 = p1 + p2; p2 = p2 - p1; return p1; } int g (int p1, int p2) { p1 = p2; return p1; } int main () { i = j + 1; j = i + 1; f (i, j) = f (i+1, j) + g (i+2, j); } 2. Ist folgendes Programm korrekt? Wenn nein, warum nicht, wenn ja, welche Werte liefert es? #include <iostream> int a = 10; int & b = a; int c = a; int & f (int &b) { b++; return b; } using namespace std; int main () { int &x = c; x = a; f(c) = f(c) + f(x); cout << f(b)++ << endl; cout << f(b) + f(b) << endl; } 3. Was ist alles falsch an folgender Definition eines Zuweisungsoperators: class X { public: ... X & operator= (const X x) { X res; res.i = x.i; return res; } private: int i; }; Programmierung II 58 4. Ist die Zuweisung links– oder rechts-assoziativ? Ist also a=b=c; das Gleiche wie (a=b)=c; oder das Gleiche wie a=(b=c);? Sind beide geklammerten Formen erlaubt, haben sie die gleiche oder eine unterschiedliche Wirkung? 5. Warum liefert der Zuweisungsoperator als Ergebnis eine Referenz? 6. Welche Konsequenz hat es, wenn der Zuweisungsoperator als Ergebnis eine konstante Referenz liefert: class C { ... const C & operator= (const C &); ... }; Welches Konstrukt wird damit unmöglich gemacht? Aufgabe 2 Definieren Sie die Klasse der Brüche als wertorientierten konkreten Datentypen so, dass mit Ihrer Definition Programme wie beispielsweise: int main () { Bruch b0, b1(2), b2(2,3), b3 = -Bruch (4,6); b0 = 4; // // // // b0 b1 b2 b3 = = = = 0 2 2/3 -(4/6) (unaeres Minus) // implizite Konversion int->Bruch // Gemischte Typen, int und Bruch: b3 = Bruch(5,7) + (2 + (b1 - b2) + 3) - b0; // Ein- und Ausgabe: cin >> b1; cout << b1; } ausgeführt werden können. Vergessen Sie nicht zu prüfen, ob der vom Compiler erzeugte Kopierkonstruktor und Zuweisungsoperator korrekt sind – wenn nicht dann müssen Sie sie selbst definieren. Aufgabe 3 1. Definieren Sie einen Stapel als zustandsorientierten Datentyp. 2. Erläutern Sie, warum wertorientierte Klassen als konkrete Datentypen definiert werden sollten und warum dies bei zustandsorientierten Klassen nicht immer notwendig ist. 3. Erweitern Sie Ihren Stapel zu einem zustandsorientierten konkreten Datentyp. Aufgabe 4 1. Vervollständigen Sie folgenden konkreten Datentyp Menge durch die fehlenden Definitionen der Methoden: class Menge { public: Menge (); Menge (int); // leere Menge // ein-elementige Menge Th Letschert, Fachbereich MNI, FH Giessen–Friedberg Menge operator+ (Menge) const; Menge operator* (Menge) const; bool istEnthalten (int i) const; private: void fuegeEin (int i); void entferne (int i); int m[10]; int a; }; 59 // Vereinigung // Schnitt // ist Element 2. Handelt es sich um eine wertorientierte oder um eine zustandsorientierte Klasse? 3. Erweitern/modifizieren Sie die Definition von Menge derart, dass die aktuelle Zahl aller Mengenobjekte jederzeit festgestellt mit der Methode static int anzahl();// aktuelle Anzahl aller Objekte festgestellt werden kann. Müssen in dieser Variante ein Kopierkonstruktor und Zuweisungsoperator definiert werden? 4. Definieren Sie Ein– und Ausgabe–Funktionen für die Menge als freie Shift–Operatoren. Aufgabe 5 Zuweisungsoperator und Kopierkonstruktor sind sich immer sehr ähnlich. Sie haben beide die Aufgabe Kopien zu erzeugen. Trotzdem ist es bei den Vektoren, die ihre Exemplare zählen, notwendig den Kopierkonstruktor selbst zu definieren. Der vom Compiler erzeugte Zuweisungsoperator ist aber völlig ausreichend und es ist nicht notwendig einen eigenen Zuweisungsoperator zu definieren. Warum? Erläutern Sie! Programmierung II 60 3 Verweise und dynamische Objekte 3.1 Zeiger (Pointer) 3.1.1 Verweis: Referenz oder Zeiger Ein Verweis ist ein Hinweis auf ein Objekt. Verweise gibt es in zwei Varianten: einmal als Referenz (engl. Reference) und einmal als Zeiger (engl. Pointer). Referenzen haben wir bereits im letzten Kapitel kennengelernt. Zeiger und Referenzen sind ähnlich aber nicht das Gleiche. Beide beziehen sich indirekt auf ein Objekt. Der Unterschied besteht darin, dass Zeiger als Werte (r–Werte) einer Variablen erscheinen und direkt manipuliert werden können, Referenzen dagegen nicht. Verweise – Zeiger und Refernzen – werden als Speicheradessen implementiert. Jeder Zeiger und jede Referenz ist darum letztlich nichts anderes, als die Adresse einer Speicherstelle. 3.1.2 Zeiger sind Verweise Zeiger sind, wie auch Referenzen, Verweise auf Variablen. Beispiel: int int int ... x = p = p = x; y; * p; // x ist eine Variable vom Typ "int" // y ist eine Variable vom Typ "int" // p ist eine Variable vom Typ "Zeiger auf int" 1; &x; &y; // x hat (enth"alt) den Wert "1" // p hat (enth"alt) den Wert "Zeiger auf (= Adresse von) x" // p hat (enth"alt) den Wert "Zeiger auf (= Adresse von) y" In int * p, der Definition der Variablen p, ist der Ausdruck “int *” die Bezeichnung des Zeiger–Typs “Zeiger auf ein int– Objekt”. Generell bezeichnet für jeden Typ T T * den Typ “Zeiger auf eine Variable vom Typ T”. In der Zuweisung p = &x ist “&” der Adressoperator, mit ihm wird die Adresse von p bestimmt. Für jede Variable l bezeichnet &l deren Adresse (= den Zeiger auf die Variable). Man beachte die unterschiedlichen Bedeutungen von & in Ausdrücken, bei Referenzparametern und bei der Definition einer Referenz: 3.1.3 p = &l; Bestimme die Adresse von l. void f (int &l) l ist ein Referenzparameter int &l; l ist vom Typ “Referenz auf int”. Zeiger sind r–Werte Referenzen und Zeiger sind Verweise auf Objekte. Beide werden intern mit Hilfe von Speicheradressen realisiert. Bei den Referenzen agieren die Adressen immer nur “hinter den Kulissen”. Zeiger dagegen sind ganz offen AdressWerte: es sind – anders als Referenzen – r–Werte. Der Unterschied zwischen Zeigern und Referenzen, also zwischen Adressen als r– und l–Werten, zeigt sich an folgendem Beispiel: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg int i; int x; int & r = x; int & s = i; // r ist eine Referenz (Typ: "int &") auf x // Referenzen m"ussen initialisiert werden int * p; int * q; // p ist eine Zeiger-Variable (Typ "int *") // Zeiger m"ussen nicht initialisiert werden. r = 2; r = &x; // OK: x (== r) hat jetzt den Wert "2" // FEHLER: &x hat den (falschen) Typ "int *" statt "int" p = 2; p = &x; // FEHLER: 2 hat den (falschen) Typ "int" statt "int *" // OK: p hat (enth"alt) jetzt den Wert "Adresse von x" 61 if (p == q) { ... // Wird ausgef"uhrt wenn p und q auf die gleiche Variable ... // zeigen (p und q beinhalten die gleiche Adresse.) ... // Der Inhalt der Variablen auf die p und q zeigen ... // ist nicht relevant. } if (r == s) { ... // Wird ausgef"uhrt wenn der Inhalt von x (= Referenz ... // von r) und i (= Referenz von s) gleich ist. ... // Die Adresse der Variablen ist nicht relevant } Bei der Zuweisung an eine Referenz r r = 2; wird “das auf das r verweist” (nämlich die Variable x) mit dem Wert “2” belegt. Bei der Zuweisung an einen Zeiger p, wie in p = &x; wird p selbst mit dem Wert “Adresse von x” belegt. Die Zuweisung r = &x; ist nicht erlaubt. Die Referenz r kann nicht verändert werden. Eine Zuweisung an r wird als Zuweisung an x interpretiert. 3.1.4 Referenzen und Zeiger unterschiedlichen Typs Man kann Referenzen auf Variablen von beliebigem Typ setzen, auch auf Zeiger–Variablen: int int int ... p = q = x, y; * p; * &q = p; // p ist eine Zeiger-Variable (Typ "int *") // q ist eine Referenz auf p (Typ "int * &") &x; &y; // p hat jetzt den Wert "Adresse von x" // p (== q) hat jetzt den Wert "Adresse von y" Mit dem Adressoperator kann die Adresse beliebiger Variablen bestimmt werden: struct S { int a; char b; }; int char S x = 1; y = ’a’; s = {1, ’B’}; Programmierung II 62 int * char * S * p = &x; q = &y; r = &s; Hier werden sechs Variablen mit unterschiedlichen Typen und Werten definiert: x Typ: int, Wert: . y Typ: char, Wert: . s Typ: S, Wert: p Typ: int *, Wert: Zeiger auf x. q Typ: char *, Wert: Zeiger auf y. r Typ: S *, Wert: Zeiger auf s. . Man beachte, dass sowohl Zeiger als auch Referenzen unterschiedliche Typen haben, wenn sie auf Variablen von unterschiedlichem Typ verweisen: char c; char * pc; int i; int * pi, pi1; int ** ppi; .. pi = &i; // OK pc = pi; // FEHLER: Typen passen nicht pi1 = pi; // OK ppi = &pi; // OK ppi = pi; // FEHLER: Typen passen nicht 3.1.5 Zeigergrafiken Oft sind einfache Grafiken hilfreich, bei denen Zeiger als Pfeile dargestellt werden. Der Pfeil führt dabei von der Zeigervariablen zu dem Objekt auf das ihr Wert zeigt (siehe Abbildung 10). ... ... 4724 4716 4718 4720 A 4722 Adresse von p 4724 ... Speicher Speicheradressen Adresse von c A p entprechende Zeigergraphik c char *p; char c = ’A’; p = &c; dargestellte Situation Abbildung 10: Zeigergrafik: Speicheradressen (Zeiger) als Pfeile Der Wert einer Zeigervariablen ist eine Speicheradresse. In einer Zeigergrafik wird diese Adresse als Pfeil dargestellt. Der Pfeil führt zur der Speicherstelle mit dieser Adresse. Die durch Th Letschert, Fachbereich MNI, FH Giessen–Friedberg struct int char S int * char * S * S x y s p q r { = = = = = = 63 int a; char b;}; 1; ’a’; { 1, ’B’ }; &x; &y; &s; erzeugte Situation kann beispielsweise durch das Zeigerdigaramm in Abbildung 11 in recht übersichtlicher Form dargestellt werden. x 1 p y a s q 1 B r Abbildung 11: Beispiel Zeigergrafik Uninitalisierte Variablen enthalten Zufallswerte. Das gilt auch für Zeigervariablen. Ein Zeigervariable mit Zufallswert kann – zufällig – auf eine definierte Speicherstelle zeigen. In der Regel zeigt sie aber “irgendwo in die Landschaft”: Ihr Inhalt ist keine zulässige Adresse. 3.1.6 Dereferenzierung von Zeigern Zeiger zeigen auf Variablen. Zu einer Variablen – generell zu einem l–Wert – kann man jederzeit mit Hilfe des Adressoperators & den Zeiger bestimmen, der auf sie zeigt. Der Adressoperator macht dabei aus einem l–Wert (“Variable”) den äquivalenten r–Wert (“Zeiger auf die Variable”). Adressoperator & &x x & Dereferenzierungsop. * p *p * Abbildung 12: Adress– und Dereferenzierungsoperator Der Dereferenzierungs–Operator * ist die Umkehrfunktion zum Adressoperator. Er führt vom Verweis auf die Variable, auf die gezeigt wird: int i; int * p; .. i = 5; p = &i; // p hat den Wert ‘‘Adresse von i’’ *p = 6; // *p ist die Variable i; jetzt hat i den Wert 6, // der Wert von p wurde NICHT veraendert. Der Operator * nimmt einen Zeiger – also einen r–Wert – und macht aus ihm den äquivalenten l–Wert, indem er “zu der Variablen geht, auf die der Zeiger zeigt”. *p ist ein l–Wert und kann darum links vom Zuweisungszeichen stehen. Programmierung II 64 Adressoperator & : Variable (l–Wert) Dereferenzierung * : Zeiger (r–Wert) Zeiger (r–Wert) auf die Variable Variable (l–Wert) auf die der Zeiger zeigt Wie andere l–Werte auch darf *p natürlich links und rechts vom Zuweisungszeichen auftreten. Rechts wird es in einen r–Wert konvertiert: in den Wert der Variablen “auf die gezeigt wird”. Beispiel: int i; int * p; .. p = &i; // *p = 6; // i = *p + 1; // *P = *p + 2, // p enthaelt einen Zeiger auf i *p links = Variable i; jetzt hat i den Wert 6, *p rechts = Inhalt der Variable i; jetzt hat i den Wert 7, jetzt hat i den Wert 9 Die ersten drei Zuweisungen haben folgende Wirkung: In p = &i; ist &i der r–Wert “Adresse von i”, er wird in p abgespeichert. In *p = 6; ist *p der l–Wert “Variable i”. In i = *p + 1; ist *p zunächst l–Wert “Variable i”, er wird aber – wir sind auf der rechten Seite – in den r–Wert “Inhalt der Variable i” konvertiert. Man beachte den unterschiedlichen Gebrauch des Zeichens *. In int * p; ist “int *” der Typ “Verweis auf eine int–Variable”. In *p = 6; dagegen ist “*p” der l–Wert “Ergebnis der Anwendung des Dereferenzierungsoperators * auf den Inhalt von p”, oder kurz: “das auf das p zeigt”. Der Inhalt von p ist dessen r–Wert. Hier im Beispiel der Zeiger “&i”, der auf i zeigt. Eine uninitalisierte Zeigervaribale enthält einen Zufallswert, der meist nicht als legale Speicheradresse interpretiert werden kann. Der Versuch eine uninialisierte Zeigervariable zu derefenzieren führt darum meist zu einem Programmabsturz. 3.2 Definition von Zeigervariablen und –Typen Die Theorie der Referenzen, Zeiger, l– und r–Werte ist beim ersten Durchgang nicht ganz leicht zu verstehen. Glücklicherweise ist die Praxis intuitiv recht eingängig. Betrachten wir einige Beispiele: int * p; p ist eine Zeigervariable für Verweise auf eine int Variable. Man kann diese Definition so lesen: Wenn p mit *p dereferenziert wird, dann ist das Ergebnis vom Typ int. Mögliche Operationen auf p sind: int * p; int a = 4; p = &a; *p = 12 + a - *p; int *p[10]; Hier wird p als ein Feld definiert. Es enthält 10 Zeiger auf int–Variablen. int * Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 65 ist der Typ der Feldelemente, und mit p[10] wird gesagt, dass p 10 Elemente hat. int *p[10]; ist kein Verweis auf ein Feld mit 10 int–Komponenten, denn die Klammern [] binden stärker als der Stern *. p ist zuerst ein Feld, dessen Inhalt sind dann Zeiger, die schließlich auf int– Variablen zeigen. Man liest stets von der zu definierenden Variablen weg. An p hängen der Stern und die eckige Klammer. Die eckige Klammer bindet stärker als der Stern, also ist p ein Feld. Wir wissen jetzt, dass p ein Feld ist. Die nächste Frage ist, welchen Inhalt die Feldkomponenten haben. Es kommt – von p weg – der Stern, er sagt, dass der Inhalt des Feldes Verweise sind. Als letztes ist zu klären, worauf die Verweise zeigen. Wir finden, am weitesten von p weg, “int”, es sagt, dass die Verweise auf int–Werte zeigen. p in int *p[10]; ist also von p weg gelesen: 1. [] : ein Feld 2. * : von Zeigern, 3. int : die auf int zeigen Mögliche Operationen auf p sind: int *p[10]; int a=4; p[2] = &a; *p[2] = 12 + a - *p[2]; int * (p[10]); Diese Definition ist identisch zu der vorhergehenden. int (*p)[10]; Mit den runden Klammern wird die Priorität der eckigen Klammern außer Kraft gesetzt. p ist ein Verweis auf ein Feld mit 10 int–Komponenten. Also zuerst ein Verweis – der Stern ist an p geklammert –, dann ein Verweis auf ein Feld – eckige Klammern – und schließlich zeigen die Verweise auf int. p in int (*p)[10]; ist also von p weg gelesen: 1. * : ein Verweis 2. [] : auf ein Feld, 3. int : von int Mit einer expliziten Typdefinition durch typedef kann das Gleiche meist etwas klarer ausgedrückt werden: typedef int A[10]; // A ist der Typ ’Feld mit 10 ints’ A * p; // p ist eine Variable mit Verweisen auf A’s Ein Anwendungsbeispiel ist: typedef int A[10]; A * p; int a[10] = {1,2,3,4,5,6,7,8,9,0}; p = &a; (*p)[2] = 12 - (*p)[2]; int * f(); Hier wird eine Funktion f deklariert. f ist eine parameterlose Funktion die einen Verweis auf ein int liefert. Genau wie die eckigen Klammern binden auch die runden Klammern stärker als der Stern. Ein Anwendungsbeispiel ist: Programmierung II 66 int * f(); // Deklaration int a = 5; int main () { int *p; p = f(); cout << *p + 2 << endl; // gibt 7 aus } int * f () { return &a; } // Definition int (* f)(); f ist eine Variable, die Verweise enthält. Jeder der Verweise zeigt auf eine Funktion, die einen int–Wert liefert und kein Argument hat. Ein Anwendungsbeispiel ist: int g () { return 5; } int main () { int (* f)(); f = &g; cout << (*f)() << endl; // gibt 5 aus } Bei Zeigern auf Funktionen gibt es die Sonderregel, dass der Stern– und der Adressoperator auch weggelassen werden können. Statt wie oben kann man darum auch schreiben: int g () { return 5; } int main () { int (* f)(); f = g; cout << f() << endl; } Zeiger auf Funktionen und Funktionen werden also fast gleich behandlet. int **p; p enthält Zeiger, die auf Variablen zeigen, deren Inhalt Zeiger auf int–Variablen sind. Mögliche Operationen auf p sind: int **p; int a=1; int *q; p = &q; *p = &a; **p = 12 - *q + **p; int **p[10]; p enthält 10 Zeiger auf Zeiger, die auf int–Variablen zeigen. int *(*p[10]); Identisch zum Vorherigen. int *(*p)[10]; p enthält einen Zeiger auf ein Feld mit 10 Zeigern auf int–Variablen (siehe Abbildung 13). int *(*f)(int); Dies ist die Definition einer Variablen f. f enthält Zeiger auf eine Funktion die einen Zeiger auf eine int– Variable liefert und ein int Argument hat (siehe Abbildung 14). Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 67 ... p Abbildung 13: Zeiger auf Feld von Zeigern int f int * Abbildung 14: Zeiger auf eine Funktion typedef int * (*PF)(float *); PF (*p)[10]; Dies ist die Definition eines Typs PF und einer Variablen p. p zeigt auf ein Feld mit 10 Elementen, das Objekte vom Typ PF enthält. Objekte vom Typ PF sind Zeiger auf Funktionen, die Zeiger auf Floats in Zeiger auf Ints abbilden (siehe Abbildung 15). Ein Anwendungsbeispiel (mit einem Feld der Größe 2 statt 10) ist: int i = 10, j = 20; // Zwei Funktionen: int *f (float *x) { i = i + int (*x); int *g (float *x) { j = j + int (*x); return &i; } return &j; } int main () { // Typ des Feldinhalts: Zeiger auf Funktionen wie f und g typedef int * (*PF)(float *); // Feld a, auf dieses Feld wird p zeigen: PF a[2] = {&f, &g}; // Variable p: PF (*p)[2] = &a; for (int i=0; i<2; ++i) { float y = i; cout << *(*p)[i](&y) << endl; // gibt 10 und 21 aus } } Komplexere Definitionen sollte man stets mit typedefs strukturieren. So ist int * (*)(float *) a[2] = &f, &g; int * (*)(float *) (*p)[2] = &a; das Gleiche wie: typedef int * (*PF)(float *); PF a[2] = &f, &g ; Programmierung II 68 float * float * float * ... int * int * int * ... a p Abbildung 15: Zeiger auf ein Feld mit Zeigern auf Funktionen PF (*p)[2] = &a; Die erste Version ist sicher noch etwas schwieriger zu verstehen als die zweite. (An der ersten scheitern sogar manche Compiler.) Ebenfalls äquivalent und noch etwas übersichtlicher ist: typedef int * F(float *); // Typ der Funktionen typedef F *PF; // Typ der Zeiger auf Funktionen typedef PF A[2]; // Typ des Feldes // Das Feld A a = &f, &g ; A *p = &a; // Der Zeiger auf das Feld 3.3 3.3.1 Verkettete Objekte, der Null–Zeiger Zeiger auf Verbunde Ein Zeiger kann auf einen Verbund oder ein Objekt einer Klasse zeigen: struct S { int a; int b; }; S s = {2, 3}; S * p = &s; int i = (*p).a + (*p).b; // entspricht: int i = s.a + s.b; S t = *p; // entspricht hier: S t = s; int j = *p.a; // FALSCH: p hat keine Komponente a Über p kommt man jetzt nicht nur an den gesamten Verbund s sondern auch an seine beiden Komponenten: *p =s (der gesamte Verbund) (*p).a = s.a (die erste Komponente) (*p).b = s.b (die zweite Komponente) Man beachte die Klammern! In (*p).a und (*p).b wird zuerst p dereferenziert und dann wird die Komponente selektiert. Der Punkt bindet stärker als der Stern und *p.a ist das Gleiche wie *(p.a). Da p kein Verbund ist, ist die Zuweisung *p.a = *(p.a); // FALSCH nicht erlaubt. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 3.3.2 69 Der Pfeiloperator -> Ein Ausdruck wie (*p).a ist etwas unhandlich. Es gibt darum eine äquivalente vereinfachte Notation: p->a p->a ist exakt das Gleiche wie (*p).a, es schreibt und liest (“p Pfeil a”) sich nur etwas einfacher. 3.3.3 Verkettete Verbunde Eine Komponente eines Verbunds kann auch ein Verweis sein. Der Verweis kann selbst wieder auf einen Verbund vom gleichen Typ zeigen. Beispiel: struct S { int a; S * l; }; ... S s; s.a = 7; s.l = &s; s zeigt hier mit seiner zweiten Komponenten auf sich selbst (siehe Abbildung 16): a 7 l s Abbildung 16: Verbund mit Zeiger auf sich selbst Man kann s.l natürlich auch auf einen anderen Verbund zeigen lassen und so Ketten oder Ringe erzeugen. struct S { int a; S * l; }; ... S x, y; x.a = 7; y.a = 8; x.l = &y; y.l = &x; x zeigt hier auf y und y auf x (siehe Abbildung 17): x 7 y 8 Abbildung 17: Ein Zeigerring Eine Kette von Verbunden kann jetzt von ihrem Anfang her durchlaufen werden: x.l->a = 2; //== (*x.l).a = 2 Programmierung II 70 x 7 y 2 Abbildung 18: Modifizierter Zeigerring Mit dem Ergebnis (siehe Abbildung 18): Wir können auch noch einen einfachen Verweis auf den Anfang der Kette zeigen lassen und von ihm aus die Kette durchlaufen: ... // S, S * p = p->a = p->l->a = x, y wie oben &x; 3; 4; //== (p->l)->a Damit enthalten die Datenkomponeten von x und y die Werte x 3 und (siehe Abbildung 19): y 4 p Abbildung 19: Zeigerring mit Verweis auf den Anfang 3.3.4 Der Null–Zeiger zeigt auf nichts Gelegentlich ist es nützlich einen Zeiger zu haben, der auf nichts zeigt. Beispielsweise um das Ende einer Kette anzuzeigen. Der Null–Zeiger – mit dem Wert 0 – zeigt auf nichts. Mit ihm kann unser Ring zu einer zweielementigen Kette gemacht werden (siehe Abbildung 20): ... y.l = 0; x 3 y 4 0 p Abbildung 20: Zeigerkette Der Wert vom Typ “Zeiger auf irgendwas” ist etwas anderes als der Wert Typprüfung von C++ verhindert, dass es hier zu Verwechslungen kommt. 7 vom Typ int oder float. Die 7 In C ist die Typprüfung etwas laxer als in C++, darum hat sich unter C–Programmierern die Sitte eingebürgert statt 0 eine definierte Konstante NULL zu verwenden. Dies ist bei C++ jedoch nicht notwendig (aber auch nicht schädlich). Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 3.3.5 71 Kettendurchlauf in einer Schleife Zeigerketten können sehr einfach in einer Schleife durchlaufen werden. Bauen wir zunächst eine Kette aus drei Verbunden x, y und z auf. Der Zeiger p zeigt auf den Kettenanfang (siehe Abbildung 21): struct S { int a; S * l; }; S x, y, z; S * p = &x; x.a x.l y.a y.l z.a z.l = = = = = = 7; &y; 8; &z; 9; 0; x 7 y 8 z 9 0 p Abbildung 21: eine dreielementige Zeigerkette Mit einer einfachen Schleife können jetzt alle Datenfelder in der Kette verzehnfacht werden: for (S* r = p; r != 0; r = r->l) r->a = r->a * 10; r ist die Laufvariable vom Typ “Verweis auf S”. Sie zeigt auf einen Verbund nach dem anderen. r != 0 testet ob das Ende der Kette erreicht ist und r = r->l versetzt die Laufvariable auf das nächste Kettenelement. 3.3.6 Zeiger auf Komponenten Bis jetzt haben die Zeiger auf eine Variable insgesamt verwiesen. Bei zusammengesetzten Variablen können sie aber auch auf einzelne Komponenten zeigen. Ein Feld wie etwa int a[10]; besteht beispielsweise aus 10 Komponenten vom Typ int. Ein Zeiger kann, wie im folgenden Beispiel, auf eine einzelne Feldkomponente zeigen: int a[10]; int *p = &a[1]; // Zeiger auf die zweite Komponente von a Ähnliches gilt für Verbunde und Klassen: struct S { int a; S s; int * p = &s.a; float * q = &s.b; S * r = &s; float b;}; // Zeiger auf erste Komponente // Zeiger auf zweite Komponente // Zeiger auf gesamten Verbund Programmierung II 72 3.4 3.4.1 Dynamisch erzeugte Objekte: Der new– und delete–Operator Speicherstruktur eines Programms int i; inf f (int x) { int j; ... } x Instanz von f Funktionsaufruf j Stack int main () { int k; ... .. f(k).. ... } k i Instanz von main Funktionsende globale Daten Abbildung 22: Der statische Speicherbereich Wir kennen bereits zwei der Speicherbereiche, die jedes C++–Programm für seine Objekte zur Verfügung hat (siehe Abbildung 22): Den globalen Speicherbereich: In ihm werden die globalen Variablen, die statischen lokalen Variablen von Funktionen und Methoden, sowie die statischen Datenkomponenten von Klassen abgelegt. Hier findet sich alles, was von Programmbeginn bis zu seinem Ende existiert. Den Stapel (Stack): In ihm befinden sich die Instanzen der gerade aktiven Funktionen und Methoden mit ihren lokalen nicht– statischen Variablen. 3.4.2 Lebensdauer der Objekte Die Lebensdauer der Objekte ist streng durch die Struktur der Programme bestimmt: Die statischen und globalen Variablen leben so lange wie das gesamte Programm. Die lokalen nicht–statischen Variablen leben genauso lange wie die Instanz der Funktion zu der sie gehören. Mit jeder Instanz werden sie neu erzeugt und mit ihrem Ende vernichtet. Der Programmierer hat weder die Pflicht noch die Möglichkeit sich um die Erzeugung oder die Vernichtung der Objekte seines Programms zu kümmern. Beides passiert automatisch. Es kann durch keine Anweisung beeinflusst werden. Die textuelle Struktur des Programms (d.h. die Programmstatik) legt den Zeitpunkt des Entstehens und Vergehens fest. Die Variablen werden darum statische Variablen genannt. 3.4.3 Der Heap: Sammlung dynamisch erzeugter Objekte Um Objekte unabhängig von der Programmstruktur willkürlich zur Laufzeit – d.h. dynamisch – erzeugen und vernichten zu können, gibt es einen weiteren dritten Speicherbereich für jedes aktive C++–Programm: den sogenannten Heap (“Haufen”). Er heißt so, weil in ihm Objekte nach Belieben und völlig ungeordnet angelegt und wieder zerstört werden können. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 3.4.4 73 Speicherplatzerzeugung mit new Objekte im Heap werden mit new erzeugt und delete wieder freigegeben. Beispiel: int * p; p = new int; // p = Zeiger auf einen neuen int-Platz Mit p = new int; wird eine anonyme (namenlose) Variable vom Typ int erzeugt und p ein Verweis auf diese Variable zugewiesen. p ist also nicht (!) eine neue Int–Variable, sondern an p wird ein Zeiger auf eine neue Int–Variable zugewiesen (siehe Abbildung 23). Der Ausdruck new int hat also zwei Funktionen: Im Heap wird Platz für einen int–Wert reserviert, außerdem liefert er einen Verweis auf diesen Platz. Man nennt dies auch Speicheranforderung oder Speicherallokation (engl: Memory Allocation) T *p T *p p = new T; T delete p; T *p T Stack Heap T *p Stack T Heap Abbildung 23: Der new– und der delete–Operator 3.4.5 Speicherplatzfreigabe mit delete Mit delete p; wird dieser Platz wieder freigegeben. Achtung: die delete–Anweisung richtet sich nur an die Verwaltung des Heap. Im Programm selbst wird mit ihr nichts verändert. Insbesondere enthält p immer noch den alten Zeiger. Über diesen Zeiger kann man weiter auf einen Datenbereich im Heap zugreifen – auch wenn die Heap–Verwaltung wieder über ihn verfügt und ihn eventuell inzwischen anderweitig vergeben hat. Es empfieht sich freigegebene Verweise sofort mit 0 zu belegen, um so Zugriffskonflikte zu vermeiden: int *p = new int; //.. *p verwenden delete p; p = 0; Natürlich muss delete aufgerufen werden bevor p auf 0 gesetzt wird. delete p; sagt der Speicherverwaltung, dass der Speicherplatz auf den p zeigt freigegeben werden soll. p muss darum auf einen vorher reservierten Speicherplatz zeigen. Der Aufruf von delete ist nur dann erforderlich, wenn andernfalls mehr Speicher vom Heap gefordert werden könnte, als dem Programm insgesamt zugeteilt wurde. Bei einfachen Programmen, die nur wenig dynamischen Speicher anfordern, kann darum auf delete verzichtet werden. Bei seinem Ende gibt ein Programm immer den gesamten statischen und dynamischen Speicher frei. Programmierung II 74 new 5 delete Heap new Aufruf p Instanz von f Aufruf Funktionsende q Funktionsaufruf Stack Instanz von main Funktionsende Funktionsende Programmstart i globale Daten Programmende Abbildung 24: Statischer und dynamischer Speicher 3.4.6 Die Lebensdauer von statischen und dynamischen Variablen Dynamische Variablen, d.h. Objekte im Heap, existieren unabhängig von der Programmstruktur. Ihre Existenz wird durch new und delete gesteuert. Die Existenz statischer Variablen dagegen wird durch das Betreten und Verlassen von Funktionen bestimmt. Beispiel (siehe Abbildung 24): int * f () { // Variable p wird erzeugt. int *p = new int; // im Heap wird eine neue (anonyme) Variable // angelegt. *p = 5; // Die anonyme Variable wird mit 5 belegt. return p; // Ein Zeiger auf die anonyme Variable } // wird zurueckgegeben. Die // Variable p wird vernichtet (= freigegeben), // die anonyme Variable lebt weiter. int main () { int *q = f(); // Jetzt wird q erzeugt und mit einem Verweis ... // auf die anonyme Variable belegt. delete q; // Die anonyme Variable im Heap wird vernichtet q = 0; // (= freigegeben), q lebt weiter. ... } // Variable q wird vernichtet. 3.4.7 Strukturen im Heap Daten können im Heap genauso abgespeichert werden wie im Stack oder im globalen Speicherbereich. Betrachten wir ein einfaches Beispiel (siehe Abbildung 25): struct S { int a; S * l; }; ... S *x, *y; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 75 x = new S; y = new S; x->a = 7; y->a = 8; x->l = y; y->l = x; y x statischer Speicherbereich (Stack) 7 dynamischer Speicherbereich (Heap) 8 Abbildung 25: Ein Zeigerring im Heap x und y sind Variablen vom Typ “S *” im statischen Speicherbereich. Daneben existieren noch zwei Variablen vom Typ “S” im Heap. Sie sind anonym, haben also keinen Namen. Variablen im Heap sind immer anonym. Auf sie kann darum nur über Zeiger zugegriffen werden. 3.4.8 Beispiel: Verkettete Liste im Heap Der Heap ist immer dann der richtige Platz zur Speicherung von Daten, wenn nicht im Voraus bekannt ist, wieviel Platz im Laufe des Programms benötigt wird. n r p 1 2 ... 8 0 9 0 Abbildung 26: Verkettete Liste einlesen: vor Einketten des Letzten Betrachten wir ein Programm das Zahlen einliest und in einer verketten Liste von Verbunden im Heap ablegt: #include <iostream> struct S { int a; S * l; }; int main () { S * r = 0; // Anfang der Liste S * p = 0; // Letztes Element der Liste int i; // zuletzt eingelesene Zahl Programmierung II 76 n r p 1 2 ... 8 9 0 Abbildung 27: Verkettete Liste einlesen: nach Einketten des Letzten // Listenelemente einlesen und in der Liste speichern: cin >> i; while (i!=0) { S * n = new S; // neues letztes Element erzeugen n->a = i; // und mit Werten belegen. n->l = 0; if (p != 0) p->l = n; // das alte zeigt auf das neue letzte else r = n; // das neue ist der Anfang p = n; // Das neue ist jetzt das letzte cin >> i; } // Listenelemente ausgeben for (S* x = r; x != 0; x = x->l) cout << x->a << endl; } Für jede eingegebene Zahl, die nicht gleich Null ist, wird mit S * n = new S; ein Verbund vom Typ S im Heap reserviert. In ihm werden die eingegebene Zahl und ein Verweis auf den nächsten Verbund gespeichert. Zunächst gibt es noch keinen nächsten, darum wird hier der Null–Zeiger gespeichert: n->a = i; n->l = 0; Nach der Eingabe von 1 ... 9 entspricht die Situation jetzt Abb. 26. Der vorherige letzte ist – falls es ihn gibt – jetzt nicht mehr der letzte, er muss darum auf den neuen letzten zeigen: p->l = n; p = n; Damit ergibt sich Abb. 27. 3.5 3.5.1 Zeiger und Felder Felder im Heap: new[] und delete[] Im Heap können ganze Felder von Objekten mit einer einzigen new–Anweisung angelegt und mit einer delete– Anweisung wieder freigegeben werden: int * p; p = new int[10]; //Feld allokieren (Speicher im Heap anfordern) Th Letschert, Fachbereich MNI, FH Giessen–Friedberg ... delete[] p; 77 //Feld freigeben Bei new muss dabei die Zahl der Elemente in eckigen Klammern angegeben werden. Bei delete reichen die eckigen Klammern aus. Die Heapverwaltung des Systems hat sich die Anzahl der Elemente (und ihre Größe) gemerkt. 3.5.2 Felder sind eigentlich Zeiger und damit r–Werte Zeiger und Felder sind eng verwandt. Felder “sind” eigentlich nichts anderes als Zeiger. Sie sind damit reine r– Werte und keine l–Werte. Zuweisungen an ein ganzes Feld sind darum nicht möglich. “a ist ein r– und kein l–Wert” bedeutet: a ist ein Adresswert. Es ist keine Variable, die eine Adresse hat und eventuell eine, als Zeiger, enthält. Betrachten wir dazu ein kleines Beispiel (siehe auch Abbildung 28): Heap Stack p a Abbildung 28: Feld im Stack und im Heap int main () { int * p = new int[10]; // // int a[10]; // // p[2] = 7; // OK a[2] = 3; // OK 10 ints im Heap anlegen und deren Adresse in p speichern 10 ints im Stack anlegen und deren Adresse heisst a *p = 1; // OK *a = 1; // OK: *a ist das gleiche wie a[0] // (*a ist l-Wert und somit zuweisbar) p = a; a = p; // OK: implizite Konversion int[10] -> int * // FEHLER: a ist nicht zuweisbar (a ist r-Wert, kein l-Wert!) } int a[10]; legt ein Feld von 10 ints im Stack an. a ist jetzt der Name der Startadresse dieser 10 ints und damit natürlich gleichzeitig die Adresse des ersten Elementes a[0]. a[0] und *a sind darum äquivalent. int * p = new int[10]; legt ein Feld von 10 ints im Heap an. Die Startadresse dieser 10 ints wird in der Variablen p abgelegt. p ist ein l–Wert und somit zuweisbar. a dagegen enthält nicht die Adresse eines Speicherbereichs, es ist der Name des Feldes, also ein Synonym für die Adresse an der das Feld beginnt – es ist ein r–Wert. Die Zuweisung a = p; ist darum nicht erlaubt – an den Namen einer Adresse kann man nichts zuweisen. p = a; dagegen macht keine Probleme. p ist eine normale Zeigervariable und damit zuweisbar. Man merkt sich am besten, dass bei der Definition eines statischen Feldes im statischen Speicherbereich entsprechend Platz geschaffen wird und der Feldname dann ein Synonym für dessen Adresse ist. Diese Adresse wird aber nicht in einer Variablen abgelegt. Programmierung II 78 3.5.3 Mehrdimensionale Felder Die Äquivalenz von Zeigern und Feldern gilt auch für mehrdimensionale Felder. Man betrachte dazu folgendes Beispiel: char a[2]; char *p = a; // entspricht: p = &a[0]; char b[2][3]; typedef char T[3]; T *q = b; // entspricht: q = &b[0]; char * r[2]; r[0] = new char [3]; r[1] = new T; char **s; s = r; // entspricht: s = &r[0] for (int i=0; i<2; ++i) for (int j=0; j<3; ++j) { b[i][j] = 1; r[i][j] = 2; s[i][j] = 3; } b r q s b[1][1] q[1][1] r[1][1] s[1][1] Abbildung 29: Die Indexberechung ist ahängig vom Typ Die Felder a und b werden als Zeiger auf ihr erstes Element interpretiert. Das erste Element von a hat den Typ char, darum ist die Zuweisung p = a korrekt. Das erste Element von b hat den Typ T, dem entsprechend ist auch q = b; korrekt. Auf b, q, r und s kann interessanterweise mit den gleichen indizierten Ausdrücken zugegriffen werden. Man sollte sich dadurch aber nicht zu dem Glauben verleiten lassen, dass b[i][j] und q[i][j] sowie r[i][j] und s[i][j] jeweils in den gleichem Maschinencode übersetzt werden. Der Compiler kennt die Typen von b q, r und s und kann darum die Indexrechnung korrekt umsetzen (siehe auch Abbildung 29). Beim Zugriff über q bzw. s muss einmal mehr dereferenziert werden, als bei Zugriff über b bzw. r. Der Compiler erkennt dies daran, dass q und s im Gegensatz zu b und r l–Werte sind. 3.5.4 Felder als Wertparameter: Zeigerübergabe Felder werden also durch einen Zeiger auf ihr erstes Element repräsentiert. Als solche werden sie auch bei einer Wertübergabe an Funktionen übergeben. Bei der Wertübergabe eines Felds wird nicht geprüft, ob die Feldgröße des übergebenen Feldes mit den Erwartungen der Funktion übereinstimmt. Beispiel: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 79 void f (char x[10]) { for (int i=0; i<10; i++) x[i] = ’0’; } int main () { char a[3] = {’u’, ’v’, ’w’}; ... f (a); // OK: Die Adresse a wird uebergeben obwohl ... // das Feld nur 3 statt 10 Elemente enthaelt. } Dieses Programm provoziert keine Fehlermeldung des Compilers, obwohl es in übelster Weise seinen statischen Speicherbereich überschreibt. Die for–Schleife in f beschreibt gnadenlos zehn Speicherstellen – egal was sich dort befindet. Man lässt darum bei formalen Wertparametern für Felder die Größenangabe besser weg – sie ist sowieso irrelevant. Die folgenden Parameterdefinitionen sind völlig äquivalent: void f (char x[10]); void f (char x[]); void f (char *x); Will man eine Information über die Größe des aktuellen Feld–Parameters in die Funktion hinein schleusen, dann übergibt man die Feldgröße am besten explizit in einem extra Parameter: void f (char x[], int size) { for (int i=0; i<size; i++) x[i] = ’0’; } int main () { char a[3] = {’u’, ’v’, ’w’}; ... f (a, 3); ... } 3.5.5 Felder als Referenzsparameter: Referenzübergabe Felder können – statt als Zeiger – auch als Referenzen an eine Funktion übergeben werden: void f (char (&x)[10]); Man beachte die Klammern um &x, ohne sie wäre der Parameter x ein Feld von Referenzen statt einer Referenz auf ein Feld. Zwischen Zeigern und Referenzen besteht kein wesentlicher Unterschied und in der Tat wird zur Laufzeit auch der gleiche Wert – die Adresse des Feldanfangs – zur Funktion transferiert. Der Unterschied zeigt sich zur Übersetzungszeit: Bei der Referenzübergabe eines Feldes prüft der Compiler ob die Feldgröße des formalen mit der des aktuellen Parameters übereinstimmt: char a[5]; void f1 (char x [10]); void f2 (char (&x)[10]); int main () { f1 (a); // OK (nach Meinung des Compiler) f2 (a); // FEHLER-Meldung des Compilers!! } Inhaltlich hat das Programm zweimal den gleichen Fehler. Bei der Übergabe eines Feldes als Zeiger wird er vom Compiler ignoriert, bei der Übergabe als Referenz dagegen nicht. Wenn Felder von bekannter und fester Größe an eine Funktion übergeben werden sollen, dann übergibt man sie am besten per Referenz: Programmierung II 80 .. f (... T (&x)[size], ...); Sollen der Funktion Felder unterschiedlicher Größe übergeben werden, dann ist die Wertübergabe mit einem zusätzlichem Parameter für die Zahl der Feldelemente angebracht: .. f (... T x[], int size, ...); 3.5.6 Stringliterale Stringliterale sind Zeichenfolgen in Anführungszeichen, z.B. "klausi". Sie werden als Felder (also als Zeiger auf eine Folge) von chars im statischen Speicherbereich des Programms abgespeichert. Hinter dem letzten Zeichen steht als Abschlussmarke das Zeichen mit dem numerischen Wert 0 (numerische Null, das ist nicht das Zeichen ’0’!). Das Literal "hugo emilie" wird beispielsweise als Feld der Länge 12 mit dem Inhalt: ’h’,’u’,’g’,’o’,’ ’,’e’,’m’,’i’,’l’,’i’,’e’,0 gespeichert. Wegen der Endemarkierung mit Literal. ist die Feldlänge stets um eins größer als die Zahl der Zeichen im Die beiden Felder a und b im folgenden Beispiel werden darum auf den exakt gleichen Wert initialisiert: // a und b werden den gleichen Wert initialisiert: char a[4] = {’A’, ’B’, ’C’, 0}; char b[4] = "ABC"; 3.5.7 C–String: Zeiger auf 0–terminiertes char–Feld Zeiger auf char–Felder deren letzter Wert der int(!) Wert ist, sind in C die Standard–Darstellung von Zeichenstrings, sie werden darum i.A. C–Strings genannt. C–Strings kann man leicht in “richtige” (C++–) Strings umwandeln: #include <string> int main () { char c[4] = "ABC"; string s1 ("abc"); string s2 (c); string s3 = c; s1 = "xyz"; s1 = string(c); s1 = c; } // // // // // // // Feldinitialisierung mit Stringliteral String-Konstruktor mit Stringliteral String-Konstruktor mit char-Feld String-Initialisierung mit char-Feld (C-String) Zuweisung eines Sringliterals, implizite Konversion explizite Konversion C-String -> String Zuweisung eines char-Felds, implizite Konversion c ist hier ein C–String, s1, s2 und s3 sind (C++–) Strings. Das Programmbeispiel zeigt die verschiedenen Möglichkeiten einen String mit den Zeichen eines C–Strings zu belegen. Für den umgekehrten Weg von einem (C++–) String zu einem C–String (= Null-terminiertes Char–Feld) gibt es die Methode c str von string. Beispiel: #include <string> int main () { string s ("abc"); const char *c; c = s.c_str(); } s.c str() liefert einen Zeiger auf ein Char–Feld, das die Zeichen von s gefolgt von einer “0” enthält. Das Schlüsselwort const zeigt an, dass auf den Speicherbereich, auf den c zeigt und der von s.c str() beschafft wird, nicht verändernd zugegriffen werden darf. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 81 Will man die Zeichen in einen Speicherbereich kopieren, der unter der Kontrolle des Programms steht, dann muss explizit umkopiert werden: #include <string> int main () { string s ("abc"); const char *c = s.c_str(); // c zeigt auf fremden Speicherplatz char *p = new char[s.length()+1]; // p zeigt auf eigenen Speicherplatz //Kopieren von *c nach *p: for (char *q = p; *c != 0; ++c, ++q) *q = *c; ... } 3.5.8 Vordefinierte Funktionen auf C–Strings Zur Vereinfachung der Arbeit mit C–Strings sind folgende Funktionen (aus cstring) vordefiniert: int strlen (const char *): liefert die Länge int strcmp (const char *, const char *): testet auf Gleichheit char * strcpy (char *, const char *): kopiert. Damit vereinfacht sich das letzte Beispiel zu: #include <string> #include <cstring> int main () { string s = "abc"; const char *c = s.c_str(); char *p = new char[s.length()+1]; //Kopieren: *c -> *p: strcpy (p, c); ... } Bei der Verarbeitung von Texten wird generell empfohlen mit C++–Strings statt mit C–Strings zu arbeiten. 3.5.9 Mehrdimensionale Felder im Heap Im Heap können auch mehrdimensionale Felder angelegt werden. Eine Konstruktion wie etwa int a[][5] = new int[3][5]; // FALSCH ist allerdings nicht erlaubt. Auf new folgt ein Typ und dann eine Größenangabe. Wir müssen darum zuerst den Typ der Zeile mit einem typedef definieren und können dann drei Zeilen anlegen: typedef int A[5]; // Zeilenlaenge muss dem Compiler bekannt sein // Zeilenzahl kann dynamisch sein (z.B. eingelesen werden) int main () { A *a = new A[3]; // Ein 3x5 Feld im Heap for (int i=0; i<3; ++i) for (int j=0; j<5; ++j) a[i][j] = 10*i+j; } Programmierung II 82 Man beachte, dass die Länge der Zeilen zur Übersetzungszeit bekannt sein muss. Der Compiler kann sonst einen Ausdruck wie a[i][j] nicht korrekt übersetzen. Sollen beide Dimensionen eines zweidimensionalen Feldes eingelesen werden, dann muss die Indexrechnung selbst organisiert werden. Beispiel: int main () { int n, m; cin >> n; cin >> m; int *a = new int[n*m]; // Ein NxM Feld im Heap for (int i=0; i<n; ++i) for (int j=0; j<m; ++j) a[i*m + j] = 10*i+j; // eigene Adressrechnung } 3.6 3.6.1 Zeiger, Konstruktoren und Destruktoren new ruft einen Konstruktor Jeder Aufruf von new für eine Klasse aktiviert den Default–Konstruktor für das neu erzeugte Objekt. Durch Angabe einer Parameterliste kann man auch den Aufruf eines anderen Konstruktors veranlassen. Betrachten wir als Beispiel Namen, die als verkettete Liste im Heap gespeichert werden: struct NamenListe { NamenListe (); NamenListe (string, NamenListe *); ˜NamenListe (); string name; NamenListe * vorheriger; }; NamenListe::NamenListe () : name (""), vorheriger (0) {} NamenListe::NamenListe (string p_n, NamenListe *p_l) : name (p_n), vorheriger (p_l) {} Der Default–Konstruktor erzeugt in diesem Beispiel eine einelementige Liste mit dem leeren String als einzigem Namen. Der zweite Konstruktor setzt vor die übergebene Liste (2. Parameter) einen Eintrag mit dem übergebenen Namen (1. Parameter). Beide Konstruktoren können durch new aktiviert werden (siehe auch Abbildung 30): NamenListe * p1 = new NamenListe; // Aufruf Default--Konstruktor NamenListe * p2 = new NamenListe ("Hugo", p1); // Aufruf 2-ter Konstruktor name "Hugo" vorheriger p2 name "" vorheriger 0 p1 Abbildung 30: Mit Konstuktoren erzeugte Zeigerkette Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 3.6.2 83 delete ruft den Destruktor Objekte der Klasse NamenListe liegen nicht nur selbst im Heap, sie haben auch eine Komponente die im Heap liegt: Das worauf vorheriger zeigt. Wird mit delete ein Objekt der Klasse NamenListe freigegeben, dann sollte vorher auch das worauf es zeigt freigegeben werden, und davor dessen Vorgänger und davor ... bis zum letzten in der Liste der Vorgänger, der als erstes freizugeben ist. Andernfalls bleiben nicht mehr zugreifbare “Leichen” im Heap liegen: Objekte auf die kein Zeiger mehr zeigt, die aber nicht freigegeben wurden. Glücklicherweise kann die Freigabe leicht arrangiert werden, denn delete ruft automatisch den Destruktor auf – soweit vorhanden. Im Destruktor gibt man einfach mit delete den Vorgänger frei: struct NamenListe { ... ˜NamenListe (); ... NamenListe * vorheriger; }; NamenListe::˜NamenListe () { delete vorheriger; } Der Mechanismus arbeitet rekursiv (siehe Abbildung 31): Ein delete auf den Vorgänger aktiviert dessen Destruktor, der wiederum ruft delete für den Vor–Vorgänger auf, etc. bis zu einem delete auf den Null–Zeiger des letzten Listenelements das ohne Wirkung ist. delete Destruktor delete Destruktor ... delete 0 Generell sollten alle Klassen mit Komponenten im Heap einen Destruktor definieren, in dem diese Komponenten freigegeben werden. new struct NamenListe { ... NamenListe * vorheriger; }; delete in: ~NamenListe() new klara 0 hugo delete p2 int main () { NamenListe *p1, p2; ... aktiviert ~NamenListe() HEAP STACK NamenListe nl("emil", 0); p1 = new NamenListe ("klara", 0); p2 = new NamenListe ("hugo", p1); ... Aufruf emil 0 nl delete p2; //Aufruf p2->~NamenListe() p1 }// Aufruf: nl.~NamenListe() p2 Funktionsende Abbildung 31: Kooperation von Destrukor und delete 3.6.3 Destruktor für statische und dynamische Objekte Der Destruktor eines Objekts o der Klasse C wird insgesamt also nach folgenden Regeln aktiviert: main Programmierung II 84 o ist eine globale Variable: Der Destruktor wird bei Programmende aktiviert. o ist eine lokale Variable oder ein Parameter: Der Destruktor wird aktiviert, wenn die Funktion oder der Block verlassen wird, in dem o definiert ist. o ist eine anonyme Variable im Heap: Der Destruktor von *p wird aufgerufen wenn p auf o zeigt und delete p; ausgeführt wird. Zeiger sind keine Variablen, darum werden beim Verlassen einer Funktion zwar alle lokalen Zeigervariablen weggeräumt, es wird aber keinfalls ein Destruktor ausgeführt für eines der Objekte auf die sie zeigen. Beispiel: struct S { S(); ˜S(); ... }; int f (...) { S * p; S s; p = new S; ... } // Aufruf von s.˜S(); // aber KEIN Aufruf von p->˜S() Fassen wir noch einmal kurz die wesentlichen Dinge in Bezug auf die Zeiger, Konstruktoren und Destruktoren zusammen: Der Konstruktor konstruiert nicht, er initalisiert nach der Erzeugung. Der Destruktor vernichtet nicht, er macht Aufräumarbeiten vor der Vernichtung. new erzeugt und ruft danach den Konstruktor. delete vernichtet und ruft vorher den Destruktor. 3.6.4 Beispiel: Verkette Liste An dem einfachen Beispiel einer verketteten Liste zeigen wir das Zusammenspiel von Konstruktoren, Destruktoren und Zeigern. Eine Liste besteht aus einem Objekt der Klasse Liste, die auf eine Folge von Objekten der Klasse Knoten verweist. In den Knoten sind die Listenelemente in einer Komponente v gespeichert und eine Komponente n zeigt auf den nächsten Knoten. class Knoten { public: Knoten () : v(0), n(0) {} Knoten (int i, Knoten * p_n) : v(i), n(p_n) {} ˜Knoten () { delete n; } int v; Knoten * n; }; Die beiden Konstruktoren erzeugen Knoten, in denen 0 bzw. die übergebenen Argumente gespeichert werden. Der Destruktor gibt seinen Nachfolger frei (und dieser rekursiv seinen etc.). Die Liste selbst besteht aus einem Verweis auf den Anfang der Knoten und den Listen–Methoden: class Liste { public: Liste () : anfang (0) {} ˜Liste () { delete anfang; } // leere Liste // der Listenanfang wird freigegeben, // und damit rekrusiv die gesamte Liste Th Letschert, Fachbereich MNI, FH Giessen–Friedberg void schreib () const; void fuegEin (int i); // alle Listenwerte ausgeben // einen Wert in die Liste einfuegen Knoten *anfang; // Verweis auf die verkettete Liste der Werte 85 }; void Liste::schreib () const { for (Knoten *p = anfang; p != 0; p = p->n) cout << p->v << endl; } void Liste::fuegEin (int i) { anfang = new Knoten (i, anfang); // neuer Wert vor die alten } Wird in einer Funktion – ob sie nun main oder anders heißt – eine lokale Variable vom Typ Liste angelegt, dann aktiviert der Funktionsanfang den Konstruktor und das Funktionsende den Destruktor der Liste: int main () { Liste l; // Compiler aktiviert Konstruktor Liste::Liste() : // sorgt fuer Speicherplatz und die richtige Initialisierung ... ... // Compiler aktiviert Destruktor Liste::˜Liste() : // sorgt dafuer dass alle Listen- (Leichen-) Teile // im Heap beseitigt werden. } Mit dem Funktionsende verschwinden automatisch alle Bestandteile der Liste, die auf dem Stack angelegt wurden. Für die Beseitigung der Bestandteile, die im Heap angelegt wurden, muss der Destruktor sich aktiv kümmern. Kümmern muss sich genau genommen immer der Destruktor der Klasse, die Unter–Komponenten im Heap hat: Objekte der Klasse Liste enthalten – in anfang – einen Zeiger auf einen Knoten der garantiert im Heap liegt – so wurde die Liste eben programmiert. Liste muss darum einen Destruktor haben, der diesen Knoten wegräumt. Objekte der Klasse Knoten haben – in n – einen Zeiger auf ein Objekt im Heap. Knoten muss darum einen Destruktor enthalten, der dieses Objekt wegräumt. Der Destruktor eines Objekts ist also nicht für die Beseitigung des Objekts selbst verantwortlich, sondern immer für die Objekte, auf die seine Zeiger zeigen. Das Objekt selbst wird vom Mechanismus der Stackbereinigung oder vom Destruktor eines anderen Objekts weggekehrt. (siehe Abbildung 32) 3.7 3.7.1 Zeiger und const Konstanter Zeiger oder konstantes Gezeigtes Eine konstante Variable wie const int bufSize = 256; wird vom Compiler daraufhin überwacht, dass ihr Wert nicht verändert wird. Kommen Zeiger ins Spiel, wie etwa bei char * pc; dann gibt es zwei Variablen die konstant sein können: Die Zeigervariable pc selbst, und Programmierung II 86 v 1 Liste::~ Liste() delete n v 2 Knoten::~ Knoten delete Der Destruktor von l beseitigt dieses Objekt (mit delete) Die Beseitigung ruft den Destruktor, der Destruktor beseitigt das nächste n v 3 n 0 Knoten::~ Knoten delete Heap Stack Liste l anfang Stackbereinigung Die Stackbereinignug beseitigt: sie ruft den Destruktor dieses Objekts und räumt es dann weg Abbildung 32: Kooperation von Destrukor und delete das auf das pc zeigt. In C++ gibt es – wenig überraschend – die Möglichkeit beides als konstant zu erklären und damit vom Compiler überwachen zu lassen (siehe Abbildung 33): Konstanter Zeiger: char * const pc; Konstantes Gezeigtes: const char * pc; konstanter Zeiger char * const pc char c const char *pc const char c konstantes Gezeigtes Abbildung 33: Konstanter Zeiger und konstantes Gezeigtes 3.7.2 Konstantes Gezeigtes Mit const char * pc; wird ausgedrückt, dass das “Gezeigte”, also das auf das pc zeigt konstant ist. pc selbst kann unterschiedliche Werte annehmen, aber auf was immer es zeigt, darf nicht (über pc) verändert werden. Beispiel: const char * pc = 0; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg char 87 a[5]; pc = a; // OK obwohl pc jetzt auf nicht-konstantes zeigt *pc = 3; // FEHLER: "uber pc darf nichts ver"andert werden a[0] = 3; // OK (obwohl a[0] == *pc) Die Zuweisung an *pc ist hier ein Fehler, obwohl *pc und a[0] exakt die gleiche Speicherstelle bezeichnen. const char *pc; sagt also nichts aus über das, auf das gezeigt wird!. Es bedeutet schlicht, dass über den Zeiger in pc nichts verändert werden darf – völlig unabhängig davon, auf was er tatsächlich zeigt! Der Compiler hat dabei den Auftrag dies zu überwachen – er passt einfach auf, dass an *pc nicht zugewiesen wird. Die Definition “const char *pc” interpertiert man also eher als: “Über den Zeiger in pc wird nichts verändert”, statt als “pc zeigt auf etwas Konstantes”. 3.7.3 Zeiger auf Konstanten Ein Zeiger vom Typ const char * kann also durchaus auf etwas Veränderliches zeigen. Es kann eben nur nicht über pc verändert werden. Natürlich darf pc auch auf etwas zeigen, das selbst – sozusagen aus sich heraus – konstant ist: const char a = ’X’;// a ist konstant const char *pc = &a; // konstantes Gezeigte, pc zeigt // tats"achlich auf eine Konstante Das ist gewissermaßen der Idealfall. a hat den Typ “const char” und pc ist ein Zeiger auf “const char”. Lässt man const bei pc weg, zeigt der Compiler einen Fehler oder eine Warnung an: const char char *pc; pc = &a; *pc = ’Y’; a = ’X’; // FEHLER: die Konstantheit von a geht ueber p verloren // OK ! (const-Eigenschaft wurde ja eben weggeworfen) Die Zuweisung an p ist fehlerhaft, denn sie überträgt die “Konstantheit” von a nicht auf p: Nach dieser Zuweisung kann das konstante a über *p verändert werden. Der Compiler kommentiert dies mit einer Meldung wie: assignment to ‘char *’ from ‘const char *’ discards const. An der nächsten Zuweisung findet er dagegen nichts auszusetzen! Der Compiler macht nicht einmal in diesem offensichtlichen Fall den Versuch herauszufinden, auf was pc tatsächlich zeigt, um mit diesem Wissen Fehler aufzudecken. Der Ausdruck const char * pc; sagt, dass *pc konstant ist. Man liest in der üblichen Art “von pc weg nach links”: pc ist ein Zeiger, ein Zeiger auf ein char, ein char das const ist. Insgesamt ist es also ein variabler Zeiger auf einen konstanten Wert. 3.7.4 Konstante Zeigerwerte Will man zum Ausdruck bringen, dass der Zeigerwert selbst nicht veränderlich ist, definiert man: Programmierung II 88 char * const pc; Wieder ist “von pc weg nach links” zu lesen: pc ist const, es ist ein konstanter Verweis, ein konstanter Verweis auf ein (nicht konstantes) char Insgesamt ist es also ein konstanter Zeiger auf einen variablen Wert. Beispiel: char b = ’Y’; char * const pc = &b; // Konstanten muss man initialisieren ! *pc = ’X’; pc = &b; // OK: *pc ist nicht konstant // FEHLER: pc ist konstant Bedauerlicherweise ist es auch erlaubt const zwischen char und * zu platzieren: char const * pc; Diese Definition ist aber äquivalent zu const char * pc; Am besten merkt man sich zur Festellung, was denn konstant ist folgendes Verfahren: Geht man vom Namen der Variablen immer weiter weg, trifft man irgendwann auf ein const, das was man direkt vorher passierte ist konstant. const char *pc; char - das worauf pc zeigt - ist konstant char * const pc; pc selbst ist konstant char const * pc; *pc - das worauf pc zeigt - ist konstant Abbildung 34: Konstantheit Die beiden Arten der Konstantheit können selbstverständlich kombiniert werden: const char b = ’Y’; const char * const pc = &b; // konstanter Zeiger auf konstante Variable *pc = ’X’; pc = &b; 3.7.5 // FEHLER: *pc ist konstant // FEHLER: pc ist konstant Parameter und const Eine häufige Verwendung findet const in Parameterlisten. Hier bedeutet es, wie bereits in Kapitel 1 erläutert, dass die Funktion garantiert, dass sie den Parameterwert nicht verändern wird: void f (const char * pc) ... f verspricht hier, dass es das, auf das sein Parameter zeigt, nicht zu verändern. Der Parameter ist nicht konstant, sondern das worauf er zeigt. Konstante Wertparameter selbst sind sinnlos, Wertparameter werden niemals verändert: void f (char * const pc) ... // UNSINN ! Konstante Referenzparameter können dagegen sinnvoll sein (siehe Abbildung 35): Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 89 Referenzübergabe eines Zeigers auf char. Der Zeiger wird nicht verändert: void f (char * const &pc) ... Referenzübergabe eines Zeigers auf char. Das worauf der Zeiger zeigt ist konstant: void f (const char * &pc) ... char * const &pc const char * &pc Referenz Referenz Zeiger char * const Zeiger char const char * const char Abbildung 35: Referenzübergabe und const Beispiel: char b = ’Y’; void f (char * const &pc) { // konstanter Referenzparameter // f wird pc nicht "andern *pc = ’X’; // OK pc = &b; // FEHLER: pc ist konstant } void g (const char * &pc) { // Konstantes Gezeigtes // g wird nichts "uber pc "andern *pc = ’X’; // FEHLER: *pc ist konstant pc = &b; // OK } Die Übergabe – ob per Wert oder per Referenz –, bei der das Gezeigte konstant ist: ...(const char *p)... ist insbesondere in Zusammenhang mit C–Strings wichtig, da diese sehr oft als const char * dargestellt werden. Beispiel: void f (const char *p) {...} void g (char *p) {...} int main () { string s ("AB"); f(s.c_str()); g(s.c_str()); // OK // Fehler/Warnung: c_str() liefert "const char *" f("AB"); g("AB"); // OK // NICHT OK: VERMEIDEN ! const char * p1 = "Hugo"; char * p1 = "Charlotte"; // OK // NICHT OK: VERMEIDEN ! } Stringliterale stellen Zeiger dar, die in einen Speicherbereich zeigen, der mit großer Wahrscheinlichkeit nicht beschreibbar ist. Dieser Speicherschutz wird zur Laufzeit vom Betriebssystem überwacht. Ein Versuch, den Inhalt eines geschützten Speicherbereichs zu verändern, führt zum Programmabsturz. Stringliterale sollten darum immer Programmierung II 90 nur an konstante Variablen zugewiesen und an Funktionen mit konstantem Parameter übergeben werden. Damit kann der Compiler zur Übersetzungszeit ausschießen, dass das Betriebssystem zur Laufzeit einen Grund zum Programmabbruch finden kann. void f (const char *p) { p[0] = 0; // FEHLER bei der Uebersetzung } void g (char *p) { p[0] = 0; // OK (fuer den Compiler) } int main () { f("AB"); // OK g("AB"); // PROGRAMMABSTURZ: Speicherzugriffsfehler } 3.7.6 Zeiger in UML: Aggregation Wenn Objekte einer Klasse A Objekte der Klasse B als Bestandteil haben, dann besteht zwischen A und B eine Kompositionsrelation. Eine Komposition ist nach UML als fester Bestandteil zu verstehen. Die Relation zwischen etwas lockerer zusammengesetzten Dingen wird als Aggregation bezeichnet. An dieser Stelle wollen wir nicht auf die philosophische Tiefe des Unterschieds zwischen einer “lockeren” einer “festen” Zusammensetzung nachdenken.Es hat sich jedenfalls eingebürgert die lockere Variante der Aggregationen zur Darstellung einer Verzeigerung zu verwenden. A B class A { ... B *pb; .. }; Abbildung 36: Aggregation: zeigt auf Enthalten Objekte der Klasse A Zeiger auf Objekte der Klasse B, dann besteht zwischen A und B eine Aggregation– Beziehung (siehe Abbildung 36). Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 3.8 91 Übungen Aufgabe 1 Geben Sie zu den folgenden graphisch dargestellten Situationen geeignete Definitionen und Anweisungen an, die zu dieser Situation führen. 1. +---------------+ | +---------|---------+ | | +-------|---------|--------+ v V V | | | +---+ +---+ +-|-+ +-|-+ +-|-+ | 0 | | 1 | | + | | + | | + | +---+ +---+ +---+ +---+ +---+ x y p q t 2. +---------------+ | +--------|------------------+ v V | q | +---+ +---+ +-|-+ +---+ +-|-+ | 0 | | 1 | | + |<-+ | + | | + | +---+ +---+ +---+ | +-|-+ +---+ x y p +----+ t 3. +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ x p y q z r t o Welche Wirkung haben – falls sie korrekt sind – jeweils in jeder der drei Situationen die Anweisungen: 1. t = x; 2. *t = x; 3. t = *x; 4. *t = *x; 5. *p = *q; 6. p = &x; 7. q = &t; Zeichen Sie zu jeder Situation und jeder dort zulässigen Anweisung ein Diagramm zur Darstellung der neuen Situation nach Ausführung der Anweisung. Aufgabe 2 1. Zeichen Sie ein Diagramm zur Darstellung der durch folgendes Programm erzeugten Situation: const int Size = 5; struct S { S (); S (int, S *); Programmierung II 92 int v; S *n; }; S::S () : v(0), n (0) {}; S::S (int pv, S *pn) : v(pv), n (pn) {}; int main () { S a[Size]; S b(-1, &a[0]); for (int i=0; i<Size; i++) { a[i].v = i; a[i].n = &a[(i+1)%(Size -1)]; } (*((*(b.n)).n)).v = 125; ... 2. Ersetzen Sie in obigem Programm alle geigneten Komponentenselektionen und Dereferenzierungen (also Kombinationen von “*” und “.”) durch den Operator ->. Aufgabe 3 1. i habe den Typ int, und p den Typ int *. Welcher der folgenden Ausdrücke ist korrekt bzw. nicht korrekt. Geben Sie zu den korrekten Ausdrücken den Typ an. (a) i + 1 (b) *P (c) &p (d) **(&p) (e) &i == p (f) i == *p (g) *p + i > i 2. Definieren Sie folgende Variablen und Typen: (a) Ein Feld von Zeigern auf float–Variablen. (b) Eine Funktion die einen Zeiger auf eine float–Variable als Argument hat und einen Zeiger auf eine int–Variable liefert. (c) Ein Feld von Zeigern auf Funktionen die einen Zeiger auf float–Variablen als Argument und Ergebnis haben. (d) Ein Feld von 10 Verbunden (structs) die einen Zeiger auf int und eine Methode als Komponenten haben. Die Methode soll Zeiger auf float in Zeiger auf int abbilden. (e) Eine Variable, die einen Zeiger auf ein float–Objekt enthält. (f) Eine Variable, die einen Zeiger auf eine Variable enthält, deren Inhalt auf ein float–Objekt zeigt. (g) Den Typ der Zeiger auf float–Objekte. (h) Den Typ S wobei jedes Objekt vom Typ S aus einem int und einem Zeiger auf ein bool, sowie einem Zeiger auf ein S Objekt besteht. (i) Eine Variable die aus 10 S Objekten besteht. (j) Eine Variable die aus 10 Zeigern auf S Objekte besteht. (k) Den Typ den ein Feld von Zeigern auf float–Variablen hat. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 93 (l) Den Typ den eine Funktion hat, die einen Zeiger auf eine float–Variable als Argument hat und einen Zeiger auf eine int–Variable liefert. 3. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die p[i] mit der Adresse von a[i] belegt (i = 0..9). 4. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die a[i] mit dem Wert dessen belegt, auf das p[i] zeigt (i = 0..9). 5. Wird der Adressoperator & auf l–Werte oder r–Werte angewendet, hat er einen l–Wert oder einen r–Wert als Ergebnis? 6. Wird der Dereferenzierungsoperator * auf l–Werte oder r–Werte angewendet, hat er einen l–Wert oder einen r–Wert als Ergebnis? 7. Welche der Zuweisungen in int & f (int &i) { return i; } int main () { int x; int *p; p = &i; p = &(*p); p = &f(x); p = &f(&(*p)); } sind erlaubt und wenn ja welche Wirkung haben sie? Aufgabe 4 1. Es sei definiert: struct S { int x; }; S s; S * p = &s; Welcher Ausdruck ist korrekt und welchen Typ haben die korrekten Ausdrücke: *p *p.x (*p).x *(p.x) p->x x->p p->.x *p->x Geben Sie zu jedem korrekten Ausdruck ein Anwendungsbeispiel an. 2. Es sei definiert: Programmierung II 94 struct S { int a; S * l; }; S x, y, z; S * p = &x; int i = 0; Stellen Sie graphisch die Situation dar, die durch folgende Anweisungsfolge erzeugt wird: x.l = &y; y.l = &z; z.l = 0; for (S* r = p; r != 0; r = r->l){ r->a = i; i++; } 3. Schreiben Sie eine Schleife, die in der erzeugten Struktur in jedem Verbund das Datenfeld um eins erhöht und dann ausgibt. Aufgabe 5 1. Warum ist: int * get_int1 () { int * p; p = new int; return p; } eine sinnvolle Methode einen Verweis auf neue int Variable zu erzeugen, dagegen int * get_int2 () { int i; int p = &i; return p; } völlig ungeeignet? 2. Es seien die Definitionen / Anweisungen: int * p; p = new int; *p = 17; ausgeführt worden. Welchen Wert haben p und *p nach Ausführung von delete p; 3. Warum ist die Aufruffolge: p = 0; delete p; unsinnig dagegen aber Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 95 delete p; p = 0; sinnvoll? 4. Kann ein Objekt (Variable) im Heap einen Namen tragen? 5. Zeichen Sie eine Graphik der von folgendem Programmstück erzeugten Struktur. struct S { int a; S * l; }; int main () { S *x, *y; x = new S; y = new S; x->a = 0; y->a = 1; x->l = y; y->l = 0; ... } Geben Sie zu jedem Objekt in Ihrer Graphik an, in welchem Speicherbereich es angelegt wird. 6. Zeichen Sie eine Graphik der von folgendem Programmstück erzeugten Struktur. struct S { int a; S * l; }; int main () { S * r = 0; S * l = 0; for (int i = 0; i< 5; i++) { S * n = new S; if (i == 0) { n->a = 1; r = n; } else { n->a = l->a * i; l->l = n; } l = n; n->l = 0; } ... 7. Ergänzen Sie das obige Programmstück um eine Ausgabeschleife, welche die Datenfelder der verketteten Verbunde in der Reihenfolge ihrer Verkettung ausgibt. 8. Schreiben Sie ein Programm in dem eine Zahlenfolge (z.B. bis zur Eingabe von 0) eingelesen und in umgekehrter Reihenfolge in verketteten Verbunden im Heap abgelegt wird. Die zuletzt eingelesene Zahl soll also am Anfang der Liste stehen. Programmierung II 96 Aufgabe 6 Geben Sie Typdefinitionen, Variablendefinitionen und Anweisungen an, mit denen folgende Strukturen programmintern erzeugt werden können: 1. A-->B-->C-->D-->E-->F-->G 2. 1-->2-->3-->4-->5-->6-->7 | | | | | | | V V V V V V V A B C D E F G 3. 1-->2-->3-->4-->5-->6-->7--8 ˆ | | | +--------------------------+ 4. 1-->2-->3-->4-->5-->6-->7 | | | | | | | V V V V V V V A-->B-->C-->D-->E-->F-->G Hier sollen jeweils durch Verweise verknüpfte Objekte im Heap (!) angelegt werden. Benutzen Sie nach Möglichkeit Schleifen (oder Rekursion)! Aufgabe 7 Schreiben Sie ein Programm, das eine Folge von int–Werten bis zur ersten Eingabe von einliest, in einer verketteten Liste speichert, die größte sowie die kleinste Zahl sucht und beide jeweils zusammen mit der Angabe ausgibt, als wievielte Zahl sie eingegeben wurde. (Z.B: “7 ist die kleinste, sie wurde als 3. eingegeben; 1251 ist die größte Zahl, sie wurde als 132. eingegeben.”) Aufgabe 8 1. Welche Ausgabe erzeugt folgendes Programm: #include <iostream.h> #include <string> using namespace std; void assign_1 (int *x, int *y) { x = y; } void assign_2 (int * &x, int * &y) { x = y; } int main () { int a[10] = {90,90,90,90,90,90,90,90,90,90}; int * p = new int[10]; for (int i=0; i<10; i++) p[i] = i; assign_1 (a, p); for (int i=0; i<10; i++) cout << a[i] << " , " << p[i] << endl; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 97 assign_2 (a, p); for (int i=0; i<10; i++) cout << a[i] << " , " << p[i] << endl; p = a; for (int i=0; i<10; i++) cout << a[i] << " , " << p[i] << endl; } Erläutern Sie die Aktionen der beiden Funktionen! Was genau wird bei der Parameterübergabe übergeben (l–Wert, r–Wert)? Ist das Programm überhaupt korrekt? 2. Wäre die Anweisung a = p; in obigem Programm erlaubt? 3. Was passiert, wenn ein mit new [] allokiertes Feld mit delete statt mit delete[] freigeben wird. 4. Welche Wirkung hat delete 0;? 5. Kann man Objekte auf dem Stack freigeben, wenn nicht, kann man es wenigstens versuchen? 6. Was ist der Unterschied zwischen int *p = new int [10]; und int *p = new int (10); 7. Wodurch unterscheiden sich die beiden Variablen p und q mit der Definition: int * const &p und const int * &q Zeichen Sie zur Illustration jeweils ein Diagramm. 8. Sie schreiben eine Funktion void f (??? pc), die einen C–String (Typ char *) als Parameter hat und die beispielsweise wie folgt aufgerufen werden soll: void f (...) { ... } int main () { char *p = new char [10]; const char *q = "Charlotte"; p[0] = ’X’; p[1] = 0; f (p); f (q); f ("Hugo"); } Welchen Typ sollte der Parameter von f haben? Aufgabe 9 Welche der folgenden Aussagen ist richtig: Der Destruktor einer Klasse C ist dafür verantwortlich ... 1. ... alle Objekte der Klasse C wegzuräumen. Programmierung II 98 2. ... Objekte der Klasse C im Heap wegzuräumen. 3. ... alle Komponenten von Objekten der Klasse C wegzuräumen. 4. ... Komponenten von Objekten der Klasse C, die im Heap liegen, wegzuräumen. 5. delete ist nichts anderes, als eine spezielle Art den Destruktor aufzurufen: p sei vom Typ S*, dann ist delete p; das Gleiche wie (*p). S() Aufgabe 10 Welche Ausgabe erzeugt folgendes Programm: #include <iostream> using namespace std; struct S { S() : a(0), l(0) {} S (int p_a, S * p_l) : a(p_a), l(p_l) {} int a; S * l; }; int main () { S * p = new S(1,0); for (int i = 0; i < 3; i++) p = new S (2*(p->a), p); cout << "In main:\n"; for (S * q = p; q != 0; q = q->l) cout << q->a << " "; cout << endl; } Aufgabe 11 Was ist alles falsch an folgender Klassendefinition: class S { public: S () : v(0), l(0) {} S (int i, S *pl) : v(i), l(pl) {} S (const S &ps) { delete l; l = 0; v = ps.v; for ( S *p = ps.l; p != 0; p=p->l) l = new S(p->v, l); } S & operator= (const S &ps) : l(0), v(ps->v) { for ( S *p = ps.l; p != 0; p=p->l) l = new S(p->v, l); return *this; } private: int v; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg S *l; }; Korrigieren Sie! 99 Programmierung II 100 4 Programmorganisation 4.1 4.1.1 Konsolenanwendungen Programmstart: Laden und main–Funktion Aktivieren Ein Programm wird nicht von selbst, aus eigenem Entschluss heraus, aktiv. Es wird vom Betriebssystem aus einer Datei in den Hauptspeicher geladen und dann gestartet. C– und C++–Programme müssen eine main–Funktion enthalten, die beim Start dann aufgerufen wird. Damit das Betriebssystem ein Programm startet, muss die Benutzerin ihm einen entsprechenden Wunsch mitteilen. Typischerweise macht sie das entweder über eine graphische Benutzeroberfläche, auf der ein Symbol angeklickt wird, das dem Programm zugeordnet ist, oder indem ein entsprechendes Kommando eingetippt wird. Das Kommando besteht meist einfach aus dem Namen der Datei, die das ausführbare Programm enthält. Auf welche Art auch immer, das Programm wird von einem anderen System aktiviert und und signalisiert diesem nach einer gewissen Zeit sein Ende. Normalerweise aktiviert das Betriebssystem des Rechners, auf dem es abläuft, das Program, im Prinzip sind aber auch andere Szenarien der Aktivierung denkbar. In jedem Fall wird ein Programm aber von einem irgendwie gearteten “externen System” aktiviert. 4.1.2 Das Programmergebnis Das einfachste C++–Programm ist: int main () {} Es wird aktiviert, tut nichts und teilt dann dem System mit, dass es dabei erfolgreich war. System und Programm kommunizieren miteinander. Das Programm kann Daten vom System übernehmen und umgekehrt erwartet das System vom Programm eine Statusmeldung vom Typ int: das Programm–Ergebnis. Das ist der Grund für den Ergebnistyp int, den jede main–Funktion haben sollte. Eine main–Funktion die nichts explizit zurück gibt, liefert implizit den Wert . als Statusmeldung bedeutet “Alles OK, kein Fehler!”. Trotz int–Ergebnis in main lassen wir darum dort (aber nur dort) in der Regel die return–Anweisung weg. 8 Die weitere Verwendung des Programm–Status’ hängt natürlich vom (Betriebs–) System ab, das die Stausmeldung in Empfang nimmt. Bei einer Unix–Shell kann man beispielsweise den Status zur Kontrolle weiterer Aktionen verwenden. Beispiel (Kommandozeile): if ./a.out; then echo "OK"; else echo "FEHLER"; fi; 4.1.3 Programmargumente Ein Programm kann an seine Umgebung nicht nur Werte zurückliefern, es kann auch Eingaben – Programmargumente genannt – annehmen. Die Eingabe an das Programm besteht aus der Kommandozeile mit der das Programm aktiviert wurde. Innerhalb des Programms kann mit den formalen Parametern von main darauf zugegriffen werden. Beispiel: int main (int argc, char * argv[]) { cout << "Zahl der Argumente: " << argc << endl; for (int i=0; i< argc; i++) cout << argv[i] << endl; } 8 Achtung C–Programmierer: dies gilt nur für C++ und nach aktuellem Sprachstandard! In C sollte main entweder vom Typ void sein, oder vom Typ int und dann tatsächlich einen Wert zurückgeben. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 101 Nach dem Programmaufruf enthält argc die Anzahl der Worte in der Kommandozeile und argv ist ein Feld von Zeigern auf die einzelnen Worte. argv ist also ein Feld von C–Strings. Wird das Programm etwa mit der Kommandozeile a.out paula hugo emilie aktiviert, dann erzeugt es folgende Ausgabe: Zahl der Argumente: 4 a.out paula hugo emilie Die Kommandozeile hat 4 Elemente: a.out, den Namen der Datei in der der ausführbare Programmcode liegt, sowie paula, hugo und emilie. Man beachte, dass der Name des Programms, hier a.out, selbst zur Argumentliste gehört. Ein weiteres Beispiel ist ein Programm, welches das Minimum seiner beiden Argumente bestimmt: #include <iostream> #include <stdlib.h> int main (int argc, char * argv[]) { if (argc != 3) // Programm-Name + 2 Argumente cout << "Falscher Aufruf!" << endl; else if (atoi (argv[1]) < atoi (argv[2])) cout << argv[1] << endl; else cout << argv[2] << endl; } atoi ist eine Bibliotheksfunktion die Zeichen–Felder (C–Strings) in ints konvertiert. 4.1.4 Zugriff auf die Programmargumente Der Zugriff auf die Programmargumente ist eine ausgezeichnete Übung zum Umgang mit Feldern und Zeigern. argv (argument vector) ist ein Feld von C–Strings, dessen Länge durch argc (argument count) festgelegt ist. Die C–Strings sind Felder von chars, deren Länge durch die Endemarkierung 0 (Null als binärer Zahlwert, nicht das Zeichen) bestimmt ist. Am besten macht man sich die Situation an einer kleinen Graphik klar (siehe Abbildung 37). argc Kommandozeile: Prog arg-1 arg-2 3 0 1 2 argv P r o g0 a r g -1 0 a r g -2 0 Abbildung 37: Argumentvektor Programmierung II 102 4.1.5 Konversion der Eingabe in Strings C–Strings bieten zwar gutes Übungsmaterial für den Umgang mit Zeigern. In einem ernsthaften Programm sollte man aber besser mit “richtigen” Strings arbeiten. Die Eingabeargumente können leicht in Strings umgewandelt werden: #include <string> #include <iostream> int main (int argc, char * argv[]) { if (argc != 3) // Programm-Name + 2 Argumente cout << "Falscher Aufruf!" << endl; else { string a1 (argv[1]); // C-String -> C++-String string a2 (argv[2]); // mit String-Konstruktor ... } } 4.1.6 Konversion der Eingabe mit String–Streams Zur Konversion der Eingabe in int–Werte oder in einen anderen numerischen Typ kann man auch String–Streams verwenden: #include <string> #include <iostream> #include <sstream> int main (int argc, char * argv[]) { if (argc != 3) // Programm-Name + 2 Argumente cout << "Falscher Aufruf!" << endl; else { istringstream iStrS1 (argv[1]); // C-String -> String -> String-Stream istringstream iStrS2 (argv[2]); int i1, i2; iStrS1 >> i1; // Konversion mit Operator >> iStrS2 >> i2; if (i1 > i2) else cout << i1 << " > " << i2 << endl; cout << i2 << " > " << i1 << endl; } } Der Konstruktor von istringstream akzeptiert einen String und füllt mit ihn einen internen Eingabestrom, aus dem mit allen Möglichkeiten des >>–Operators gelesen werden kann. Hier im Beispiel wird ein C–String übergeben, der zuerst noch in einen “richtigen” Sting konvertiert wird. 4.2 4.2.1 Speicherbereiche und Speicherschutz Speicherbereiche Ein laufendes Programm besteht aus mehreren Komponenten, die jeweils bestimmte Bereiche im Hauptspeicher in Anspruch nehmen: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 103 Programmcode Der Programmcode besteht aus den Maschinenanweisungen in die das Programm übersetzt wurde. Aus diesem Bereich liest der Prozessor sukzessive die auszuführenden Anweisungen. statische Daten Statische Daten sind alle Objekte mit einer Lebensdauer, die der des gesamten Programms entspricht. Beispielsweise gehören die globalen Variablen sowie die statischen Datenkomponenten einer Klasse dazu. Stapel (Stack) Der Stapel besteht aus den Instanzen aller gerade aktiven Funktionen und Methoden. In einer solchen Instanz finden sich die lokalen (dynamischen) Variablen der Funktion, temporäre Zwischenspeicher (für die Auswertung von Ausdrücken) und Verwaltungsinformationen (z.B. Rücksprungadressen). Heap Der Heap besteht aus den dynamisch (d.h. zur Laufzeit) mit new angeforderten Objekten. Umgebungsinformation In einem letzten kleinen Speicherbereich sind die Umgebungsinformationen gespeichert. Das sind in erster Linie die Programmargumente. Das genaue Layout hängt ab vom verwendeten Compiler und dem System auf dem der Code läuft. Die Grundstruktur ist aber überall gleich und kann graphisch dargestellt werden. (Siehe Abbildung 38). Umgebung: argc, argv, ... Instanz von main ... Parameter lokale Variablen Programmzähler Funktionsergebnis temporärer Bereich Instanz von f Rückkehr aus einer Funktion Stack Aufruf einer Funktion Aufruf von new Heap statische Daten Code von main Code von f ... Programmcode Abbildung 38: Speicherbereiche eines aktiven Programms Programmierung II 104 4.2.2 Speicherschutz Der Speicher ist den Aktivitäten des Programms nicht hilflos ausgeliefert. Das System, in Form der Software des Betriebssystems, eventuell auch mit Hilfe von Hardwaremechanismen, schützt zunächst einmal den Speicherbereich eines Programms vor Zugriffen eines anderen. Aber auch innerhalb des Bereichs, der einem Programm zugeteilt wurde, sind Schutzmechanismen aktiv. Beispielsweise ist es aus offensichtlichen Gründen nicht erlaubt in den Bereich des Programmcodes zu schreiben. Den eigenen Code darf ein Programm also nicht verändern. Manche Compiler betrachten dabei String–Literale als Bestandteile des Programmcodes, für andere gehören sie zu den statischen Daten. Das Programm char *p = "HALLO WELT"; int main () { p[0] = ’h’; } lässt sich darum problemlos übersetzen (und binden), stürzt aber eventuell mit der Fehlermeldung “Segmentation fault” (oder etwas ähnlichem) ab. Der Kontextbereich des Programms ist dagegen in der Regel ungeschützt. So ist zwar int main (int argc, char *argv []) { argc = 15; argv [16] = "Hallo"; } kompletter Unsinn, aber leider auch völlig korrekter C++–Code. Ebenso bedauerlich ist die Tatsache, dass die Wahrscheinlichkeit eines Absturzes recht gering ist. Das Programm treibt zwar Unsinn, bleibt aber dabei in seinem Speicherbereich und niemand klopft ihm darum auf die Finger. Auf Programmargumente sollte natürlich nur lesend zugegriffen werden und auch erst nachdem man sich vergewissert hat, wieviele Argumente überhaupt vorliegen. 4.2.3 const und Speicherschutz Der “harte” Schutz von Speicherbereichen durch das System ist etwas völlig anderes als der Schutz, den der Einsatz von const bietet. Der Speicherschutz des Systems schützt dieses und andere Programme in erster Linie vor Programmierfehlern anderer. Dieser Schutz ist zur Laufzeit aktiv. Mit const kann man sich dagegen vor den eigenen Fehlern schützen. const wird vom Compiler und nur zur Übersetzungszeit beachtet. Der Compiler vergleicht dabei die in Deklarationen/Definitionen dargelegten Absichten mit den Aktionen. Mit einem guten und typischen Einsatz von const kann man “Fehlgriffen” im Speicher vorbeugen. Beispielsweise bringt man zum Ausdruck, dass C–String Literale nicht beschrieben werden können und Programmargumente nicht beschrieben werden sollen: int main (const int argc, const const char * p = "HALLO"; p [0] = ... argc = ... argv[0] = ... argv[0][0] = ... } char * const argv []) { //FEHLER //FEHLER //FEHLER //FEHLER (const (const (const (const char) int) argv) char *) Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 4.3 105 Übersetzen und Binden 4.3.1 Die drei Phasen der Programmbearbeitung Nützliche Programme tendieren dazu recht groß zu werden. Bei zunehmender Größe der Programme spielt die Organsisation eine immer wichtigere Rolle. Wie verteilt man den Quelltext auf diverse Dateien und wie wird daraus ein ausführbares Programm erzeugt, ohne dass man komplett die Übersicht verliert? Eine vernünftige Programmorganisation setzt voraus, dass man sich über die Programmverarbeitung im Klaren ist, also den Weg vom Text zum ausführbaren Maschinencode kennt. Ein Programm ist zunächst ein Stück Text in einer Datei. Es muss übersetzt und gebunden werden und kann dann ausgeführt werden (siehe Abbildung 39). Quellprogramm Compiler Präprozessor Übersetzer Assembler Objektprogramm Binder Maschinenprogramm Abbildung 39: Phasen der Programmbearbeitung Beim Übersetzen wird der Quelltext in Objektcode umgewandelt. Der Objektcode ist der Maschinencode, der dem Quellprogramm direkt entspricht. Er ist in der Regel unvollständig und darum nicht lauffähig. Der Binder (engl. Linker) macht aus dem Objektcode ein vollständiges Programm, das dann vom Betriebssystem geladen und ausgeführt werden kann. 4.3.2 Übersetzen = Präprozessor + Compiler + Assembler In der Übersetzungsphase verarbeitet der Compiler den Quellcode und erzeugt Objektcode. Auch das läuft wieder in mehreren Unterphasen ab: Präprozessor: Zusammenstellung des vollständigen Quellcodes, Compiler: Erzeugung von Assemblercode (symbolischem Objektcode), Assembler: Umwandlung von Assemblercode in Objektcode. Der Präprozessor verarbeitet vor allem die Inklusions–Direktiven und erzeugt damit den vollständigen Quellcode. Der Compiler erzeugt den Objektcode nicht sofort aus dem Quellcode. Als Zwischenstufe wird zunächst oft symbolischer Objektcode erzeugt – Assembler (–code) genannt. 9 Der Assemblercode wird dann von einem Assembler genannten Programm in den Objektcode umgesetzt. Der Zwischenschritt über den Assembler hat den Vorteil, dass man in halbwegs verständlicher Form das Ergebnis der Übersetzung betrachten kann. 9 Das machen nicht alle Compiler so, oft wird der Objektcode sofort erzeugt. Programmierung II 106 4.3.3 Der Präprozessor: Text verarbeiten Der Präprozessor ist eine Textverarbeitungsmaschinerie. Er verarbeitet die Präprozessor–Direktiven. Deren wichtigste ist die Inklusions–Direktive, mit der der Präprozessor angewiesen wird den Inhalt einer anderen Datei in den Quelltext einzufügen. 4.3.4 Übersetzer: Datentypen und ihre Operationen umsetzen Bei der Übersetzung wird der Quelltext geprüft und in (symbolischen) Maschinencode umgewandelt. Im Maschinencode gibt es nur Bits und Bytes. All die schönen Datentypen die in C++ vorgegeben sind oder im Programm definiert wurden, müssen darum zu Bits und Bytes und in entsprechende Operationen umgesetzt werden. Textdatei Präptozessor vollständiger Compiler Quelltext Assembler Textdatei Objektdatei Binder vollständiges Maschinenprogramm Objektdatei Abbildung 40: Präprozessor und Binder 4.3.5 Binder: Referenzen auflösen, Programme vervollständigen Praktisch jedes Programm benutzt Konstrukte, die nicht von ihm selbst definiert wurden. Man denke nur an die Ein– und Ausgabe–Operationen. Der Binder hat die Aufgabe die fehlenden Teile hinzuzufügen. Man beachte den Unterschied zum Präprozessor: Der Präprozessor vervollständigt das Programm auf textueller Ebene. Alles was er macht könnte man auch mit einem Texteditor selbst erledigen. Der Binder vervollständigt Programme auf der Ebene des Objektcodes (siehe Abbildung 40). 4.3.6 Übersetzungseinheit Die Arbeitseinheit des Compilers – das was er übersetzt – nennt man Übersetzungseinheit. Dateien und Übersetzungseinheiten sind nur sehr selten identisch. Normalerweiser verarbeitet der Compiler in einem Lauf mehrere Eingabe–Dateien und erzeugt daraus eine Objektdatei. Weiter sind meist mehrere Objektdateien – die in mehreren Compilerläufen erzeugt wurden – notwendig, um ein Maschinen–Programm zu erzeugen (siehe Abbildung 41) . 4.4 4.4.1 Präprozessor und Inklusionen Die Inklusions–Direktive: Eine Übersetzungseinheit wird auf mehrere Dateien verteilt Die Arbeitseinheit des Compilers – das was er übersetzt – ist zunächst einmal eine Datei. Mit Hilfe der Inklusions– Direktive kann man den Compiler jedoch dazu bringen, seine Eingabe aus mehreren Dateien zu lesen. Das was in einem Lauf übersetzt wird – die Übersetzungseinheit – ist dann auf mehrere Dateien verteilt. Die Inklusions–Direktive kennen wir schon lange. Fast jedes Programm beginnt mit Zeilen wie: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 107 Text−Datei Text−Datei Compiler Text−Datei Text−Datei Übersetzungseinheit Compiler Objekt−Datei Objekt−Datei Objekt−Datei Binder Objekt−Datei Compiler Programm (Maschinen−) Programm Compiler Abbildung 41: Datei, Übersetzungseinheit, Programm #include <iostream> #include <string> ... Die Inklusionsdirektive ist kein richtiger Ausdruck der Programmiersprache C++, sondern schlicht eine Anweisung an den Präprozessor eine andere Datei zu lesen und den gelesenen Text so zu behandeln, als sei er hier an dieser Stelle in der Datei vorhanden. Die mit <iostream> etc. bezeichneten Dateien enthalten Deklarationen und Definitionen von Ein– und Ausgabe–Routinen und andere allgemein nützliche Dinge. Ohne den include–Mechanismus müssten sie in jeden Quelltext kopiert werden. 4.4.2 Inklusion eigener Dateien Eine Inklusions–Direktive muss sich nicht unbedingt auf eine Systemdatei mit wichtigen allgemeinen Definitionen und Deklarationen beziehen. Man kann auch seinen eigenen Programmtext auf mehrere Dateien verteilen. Beispiel: dat-1.cc #include "dat-2.cc" dat-2.cc int f (int x) return x; int main () int x = 5; f (x); Beide Dateien zusammen bilden ein Programm und eine (!) Übersetzungseinheit. Eine Übersetzungseinheit bedeutet einen Compileraufruf, z.B.: g++ dat-1.cc Damit wird das gesamte Programm mit zwei Quelldateien übersetzt. Der Compiler liest zuerst dat-1.cc und mit #include "dat-2.cc" auch dat-2.cc. Eine inkludierte Datei kann selbst wieder Inklusionsdirektiven enthalten. Auf diese Art kann die Inklusion beliebig tief verschachtelt werden. Programmierung II 108 4.4.3 Inklusion: spitze Klammern oder Hochkommas Bei der Inklusion der eigenen Datei im Beispiel oben wurden doppelte Hochkommas (“Gänsefüßchen”) verwendet #include "dat-2.cc" Bei der Inklusion von globalen Systemdateien verwendet man dagegen spitze Klammern. Z.B: #include <iostream> In der “Gänsefüßchen–Form” wird die Datei direkt angegeben. Hier im Beispiel die Datei dat-2.cc. Die Gänsefüßchen–Datei wird in dem Verzeichnis gesucht, in dem sich die Datei mit der include–Anweisung selbst befindet. Man kann durch Angabe des vollständigen Pfades beliebige Dateien (auf die man Lesezugriff hat) inkludieren: #include "/irgend/wo/anders/datei.cc" 4.4.4 Inklusion von Systemdateien Bei Verwendung der spitzen Klammen in der include–Anweisung sucht der Compiler in ihm bekannten Standard–Verzeichnissen nach einer Datei mit dem angegebenen Namen. Die Standard–Inklusionsdateien sind in der Regel überall die gleichen. Ihre Position im Dateisystem hängt aber vom konkreten System ab. Mit diesem Mechanismus können Programme erstellt werden, deren Quellcode von dem übersetzenden System unabhängig ist. Mit der Compileroption -I Verzeichnispfad kann die Liste der nach Systemdateien zu durchsuchenden Verzeichnisse erweitert werden. Beispiel: > g++ -I/ein/inklusions/verzeichnis dat.cc Bei der Übersetzung von dat.cc sucht der Compiler jetzt auch im Verzeichnis /ein/inklusions/verzeichnis nach Inklusionsdateien in spitzen Klammern. 4.4.5 Konventionen: C– und H–Dateien C++–Programme können im Prinzip völlig willkürlich auf beliebig viele Dateien verstreut werden. Natürlich ist das weder üblich noch empfehlenswert. Nach einer weit verbreiteten Konvention schreibt man alle ausführbaren Anweisungen einer Übersetzungseinheit in eine Datei mit der Endung .cc (oder .c, .C, o.ä.): in die sogenannte C–Datei. Inkludierte Definitionen und Deklarationen gehören in H–Dateien, Dateien mit der Endung .h. Anders ausgedrückt: Bei jedem Compileraufruf wird genau eine C–Datei verarbeitet. Jede von einer anderen inkludierte Datei muss eine H–Datei sein. Eine H–Datei enthält keine ausführbaren Konstrukte. Unter “ausführbaren Konstrukten” versteht man solche, die zu einer Aktion zur Laufzeit führen: Anweisungen, ebenso wie die Speicherallokationen durch die Definition einer globalen Variablen oder einer statischen Datenkomponente einer Klasse. In eine H–Datei gehören also speziell: keine Funktions– oder Methoden–Definitionen, keine globalen oder statischen Variablen– oder Felddefinitionen. In eine H–Datei kommen solche Konstrukte, die nur während der Übersetzung relevant sind, wie etwa: Funktionsdeklarationen, Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 109 Klassendefinitionen, typdefs etc. 4.4.6 Präprozessor–Konstanten Die Inklusionsdirektive ist ein wichtiger Mechanismus des Präprozessors, aber nicht der einzige. Mit der Definitions–Direktive können Präprozessor–Konstanten definiert werden. Beispiel: #define X 1.0 #define WAHR true ... if ( (X > 0) == WAHR ) ... Hier werden X und WAHR als textuelle Symbole definiert. Der Präprozessor ersetzt jedes Vorkommen von X und WAHR durch den definierten Text. Der eigentliche Compiler bemerkt davon genauso wenig wie von Inklusionen. Es ist üblich Präprozessor–Konstanten mit Großbuchstaben zu bezeichnen. Eine Präprozessor–Konstante kann als Wert auch einen leeren Text haben: #define Z Z hat hier als Wert den leeren Text. 4.4.7 Bedingte Direktive Mit einer bedingten Direktive kann getestet werden, ob eine Präprozessorkonstante definiert ist. Beispiel: #ifdef INT_GROSS int a; // Programmtext fuer den Fall INT_GROSS wurde definiert short b; #else long a; // Programmtext fuer den Fall INT_GROSS wurde nicht definiert int b; #endif Hier testet der Präprozessor, ob die Konstante INT GROSS definiert wurde. Wenn ja reicht er den Text int a; short b; // Programmtext fuer den Fall INT_GROSS wurde definiert an den Compiler weiter. Andernfalls wird der alternative Text übersetzt. Mit diesem Mechanismus kann der Übersetzungsvorgang gesteuert werden. Dabei ist es hilfreich, dass Präprozessor–Konstanten auch durch eine Compiler–Direktive gesteuert werden können. Mit dem Aufruf: g++ -DINT GROSS prog.cc agiert der Präprozessor so, als sei INT GROSS im Quelltext von prog.cc (als leerer Text) definiert. In C spielen Präprozessor–Konstanten eine wichtige Rolle. In C++ sollte sich ihr Einsatz auf die Steuerung des Übersetzungsvorgangs beschränken. 4.4.8 Inklusionswächter Bereits bei Programmen mäßiger Größe lässt es sich kaum verhindern, dass die Datei mehrfach von einer anderen inkludiert wird. Dies führt praktisch immer zu Problemen. Definitionen müssen ja eindeutig sein und dürfen Programmierung II 110 nicht wiederholt werden. Um diesen Schwierigkeiten aus dem Weg zu gehen, werden alle Inklusionsdirektiven prinzipiell mit Inklusionswächtern versehen. Im Beispiel: Datei XY.h: #ifndef XY_H_ #define XY_H_ ... Text der Datei XY.h #endif Dies bedeutet: Falls XY H nicht definiert ist, dann definiere es und verarbeite den Quelltext bis zum #endif, andernfalls ignoriere alles bis zum #endif. Im Endeffekt wird der Compiler den eingeschlossenen Quelltext nur einmal verarbeiten, egal wie oft er auf #include "XY.h" stoßen wird. Alle Dateien, die potentiell mehrfach inkludiert werden – also eigentlich alle H–Dateien –, sollten in dieser Art gestaltet werden. 4.5 4.5.1 Getrennte Übersetzung Dateien, Übersetzungseinheiten und Programme Nur in den einfachsten Fällen sind Quell–Dateien, Übersetzungseinheiten und Programme mehr oder weniger identisch. In realistischen Situationen müssen die Konzepte “Quelldatei”, “übersetzter Code” und “ausführbares Programm” genauer unterschieden werden: Der Quellcode eines Programms findet sich oft in vielen Dateien. Der Compiler verarbeitet in einem Lauf mehrere Dateien, und um ein Programm zu erzeugen sind mehrere Übersetzungsläufe nötig: Mehrere Quelldateien bilden eine Übersetzungseinheit. Sie werden vom Compiler zu einer Objektdatei übersetzt. Mehrere Übersetzungseinheiten werden vom Binder zu einem Programm zusammengefügt. Textdatei−11 Textdatei−21 Textdatei−12 Textdatei−22 Textdatei−13 Textdatei−23 Textdatei−1n Textdatei−2m Übersetzungseinheit−1 Übersetzungseinheit−2 Compiler Compiler Objektdatei−1 Objektdatei−2 Binder Maschinen−Programm Abbildung 42: Ein Programm aus zwei Übersetzungseinheiten Übersetzungseinheiten sind die Textstücke, die der Compiler in einem Lauf verarbeitet. Sie müssen sowohl korrekte als auch – in gewissem Sinn – vollständige Teile eines Gesamtprogramms sein. Der einfachste Fall liegt dann Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 111 vor, wenn das Gesamtprogramm auf eine einzige Übersetzungseinheit “verteilt” wird. Beschäftigen wir uns also zunächst mit dem Begriff “Programm”. 4.5.2 Programm: vollständige Übersetzungseinheit mit Funktion main Ein Programm ist eine Kollektion von korrekten und eindeutigen Definitionen (von Funktionen, Typen, Variablen, ... etc.) die genau eine Definition einer Funktion mit Namen main, sowie die Definiton aller direkt oder indirekt von main benutzten Namen enthält. Ein Programm ist also einfach eine Funktion main mit allem, was von ihr benutzt wird. 4.5.3 Getrennte Übersetzung Von getrennter Übersetzung spricht man, wenn ein Programm durch mehrere Übersetzungsläufe erzeugt wird. Getrennte Übersetzung darf nicht mit dem Inklusions–Mechanismus verwechselt werden. Bei der Datei–Inklusion werden in einem Compilerlauf mehrere Dateien verarbeitet. Bei der getrennten Übersetzung wird ein Programm aus mehreren Übersetzungseinheiten erzeugt. Getrennte Übersetzung und Datei–Inklusion sind völlig unabhängig. Selbst wenn der Compiler in einem Lauf den Inhalt von hunderten von Dateien übersetzt und daraus ein Programm erzeugt, handelt es sich immer noch nicht um getrennte Übersetzung. Erst wenn zur Erzeugung eines Programms der Compiler mindestens zweimal aktiv wird, haben wir es mit getrennter Übersetzung zu tun. 4.5.4 Übersetzen, Binden, Laden Ausführbare Programme werden in mehreren Schritten aus dem Quellcode erzeugt. Zuerst erzeugt der Compiler aus dem Quellcode den Objektcode, der Objektcode wird gebunden und schliesslich geladen und ausgeführt. Quellcode Compiler Objektcode Binder (Linker) ausführbarer Maschinencode Angenommen eine Quelldatei hallo-Welt.cc enthält ein vollständiges C++–Programm: hallo-welt.cc #include <iostream> using namespace std; void f (); int main () { f (); } void f () { cout << "Hallo Welt" << endl; } hallo-welt.cc Die drei Schritte – übersetzen, binden, laden und ausführen – sind dann konkret: 10 1. > g++ -c hallo-welt.cc Mit der Option -c wird der Compiler aufgefordert, nur den Objektcode und nicht gleich ein ausführbares Programm zu erzeugen. 10 Mit g++ hallo-welt.cc wird Übersetzen und Binden auf einmal erledigt. Die Aufteilung in einzelne Schritte soll Abfolge der Aktionen klar machen. Programmierung II 112 2. > g++ hallo-welt.o An der Endung .o erkennt der Compiler, dass es sich nicht um Quell– sondern bereits um Objektcode handelt. Er bindet ihn zum ausführbaren Programm in der Datei a.out. (Genauer gesagt wird g++ nicht als Compiler sondern als Binder aktiv.) 3. > a.out Das Programm wird geladen und gestartet. 4.5.5 Aufteilung eines Programms in zwei Übersetzungseinheiten Das Beispielprogramm von oben teilen wir jetzt in zwei Übersetzungseinheiten (ÜE) auf – also in zwei Dateien, die getrennt voneinander übersetzbar sind: ÜE-1, main.cc: ÜE-2, f.cc: void f (); #include <iostream> int main () f (); void f () cout << "Hallo Welt < endl; Die erste Datei – main.cc – enthält die Funktion main die zweite – f.cc – enthält f. Beide Dateien können übersetzt werden: > g++ -c main.cc > g++ -c f.cc Das Ergebnis der beiden Übersetzungen sind zwei Objektdateien main.o und f.o. Man beachte, dass keine der beiden Dateien für sich allein zu einem ausführbaren Programm gebunden werden kann. Die Aufrufe: > g++ main.cc > g++ f.cc führen zu Fehlermeldungen, in denen sich der Compiler über das fehlende main bzw. f beklagt. Beide zusammen machen erst ein ausführbares Programm aus. Die Option -c oben weist g++ an, nur als Compiler tätig zu werden und nicht zu versuchen ein ausführbares Programm zu erzeugen. Mit g++ main.o f.o werden die erzeugten Objektdateien main.o und f.o zu einem ausführbaren Programm a.out gebunden. Auch hier erkennt g++ wieder an den Endungen .o dass er als Binder (engl. Linker) und nicht als Compiler agieren soll. Der Binder nimmt den auf zwei Dateien verteilten Objekt–Code und fügt ihn zu einem Programm zusammen. Objekte mit externer Bindung werden dabei über die Grenzen von Übersetzungseinheiten identifiziert. In diesem Beispiel ist f ein Objekt mit externer Bindung. Der Aufruf von f in der einen Datei wird darum mit der Definition von f in der anderen Datei zusammengebracht. Wenn das Auftreten des gleichen Bezeichners in unterschiedlichen Übersetzungseinheiten nicht in Bezug gesetzt wird, dann handlet es sich um eine interne Bindung. 4.6 4.6.1 Konventionen zur Gestaltung von Übersetzungseinheiten Vorteile der getrennten Übersetzung Realistische Programme sind groß und werden von mehreren, eventuell sogar von sehr vielen Autoren erstellt. Es ist darum unbedingt notwendig sie in getrennt zu bearbeitende Teile aufzuspalten. Diese Teile sind die einzelnen Übersetzungseinheiten. Jeder Entwickler kann seinen Quellcode erstellen und jederzeit unabhängig von allen anderen übersetzen. Versorgt man ihn dazu noch mit dem Objektcode stabiler Versionen anderer Übersetzungseinheiten, dann kann er sogar seinen Teil in einer stabilen Umgebung und unabhängig von den anderen testen. Am Ende der Entwicklung brauchen die einzelnen Teile nur noch gebunden zu werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 4.6.2 113 Nachteil der getrennten Übersetzung: Inkonsistenzen Die getrennte Übersetzung hat auch Nachteile. Da der Compiler immer nur Teile und nie das gesamte Programm zu Gesicht bekommt und kontrollieren kann, kommt es leicht zu Inkonsistenzen zwischen den verschiedenen Quelltexten. Im Beispiel oben wird in einer Übersetzungseinheit (ÜE) die Funktion f definiert und in der anderen benutzt. Jede ÜE muss für sich eine vollständige Eingabe für den Compiler darstellen. Darum enthält auch die ÜE, die f nur benutzt und nicht selbst definiert, eine Deklaration von f. Deklaration und Definition von f stehen nun in verschiedenen ÜEs. Die Deklaration wird benutzt, um den Code für den Aufruf zu erzeugen. Die Definition wird benutzt um den Code für die Funktion selbst zu erzeugen. Das Problem liegt darin, dass der Compiler beide – Definition und Deklaration – nicht mehr gleichzeitig zu sehen bekommt und infolgedessen nicht mehr prüfen kann, ob beide zusammen passen (konsistent sind). Würde beispielsweise main.cc geändert werden zu: main.cc: int & f (int &); // FALSCHE Deklaration: // f ist vom Typ void f(); int main () { int x, y= 123; x = f(y); } dann würde das weder bei der Übersetzung dieser Datei noch bei der Übersetzung von f.cc auffallen. Mit etwas Glück erzeugt der Binder bei der Konstruktion des Gesamtprogramms einen Fehler. Im ungünstigsten Fall aber kommt es zu unerklärlichen Laufzeitfehlern und Programmstürzen. 4.6.3 Erzwungene Konsistenz von Export und Import von Funktionsdefinitionen In unserem Beispiel definiert ÜE f.cc die Funktion f und ÜE main.cc benutzt sie. Man kann auch sagen, dass die eine ÜE f exportiert und die andere es importiert. Die Konsistenz der Definition beim Exporteur und der Deklaration beim Importeur kann glücklicherweise recht einfach erzwungen werden: Die Deklaration wird in eine eigene Datei f.h geschrieben, die dann von beiden C–Dateien inkludiert wird. Die beiden Übersetzungseinheiten sind: UE-1, besteht aus einer Datei main.cc: #include f.h" int main () int x, y = 123; x = f(y); UE-1 UE-2, besteht aus zwei Dateien f.h: void f (); f.cc : #include f.h" #include <iostream> void f () cout << "Hallo Welt < endl; Programmierung II 114 UE-2 Bei dieser Konstruktion fällt die mangelnde Übereinstimmung von Deklaration und Definition bei der Übersetzung sofort auf: Die Konsistenz wird durch die Gestaltung des Quellcodes und seine Verteilung auf Dateien erzwungen. 4.6.4 Export und Import von Funktionen Diese Konvention formulieren wir als Grundregel zur Gestaltung von ÜEen die Funktionen exportieren und importieren: Export–Regel 1: Jede ÜE, die Funktionen an andere exportiert, hat deren Deklarationen in einer H– Datei zu sammeln. Diese H–Datei muss vom Exporteur und allen Importeuren inkludiert werden. Funktionen werden zu Codesequenzen im Objektcode übersetzt. Funktionsaufrufe werden zu Sprüngen zum Anfang dieser Codesequenzen. Liegen Definition und Verwendung einer Funktion in unterschiedlichen ÜEs, dann stellt der Binder (engl. Linker) die Verbindung zwischen beiden her (Funktionen haben externe Bindung). Die Deklaration sorgt dafür, dass der richtige Code erzeugt wird und die Codierkonvention sorgt dafür, dass die Korrektheit der Deklaration vom Compiler geprüft werden kann. (Siehe Abbildung 43). f.H void f (); f.cc main.cc #include "f.H" #include "f.H" ... f (); ... void f () { ... } g++ −c main.o g++ −c Compiler verfolgt include−Referenzen f.o JUMP f START f g++ −o prog main.o f.o unaufgelöste Referenz im Objektcode von main Binder löst Referenzen im Objektcode auf prog Abbildung 43: Getrennte Übersetzung einer Funktion 4.6.5 Export und Import von Typdefinitionen Im Gegensatz zu Funktionen existieren Typdefinitionen nur zur Übersetzungszeit. Es sind Informationen an den Compiler über durchzuführende Prüfungen und darüber, wie der Code für andere Konstrukte – Zuweisungen, Funktionsaufrufe, etc. – zu erzeugen ist. Zur Laufzeit und auch innerhalb eines Objektprogramms gibt es nichts, was einer Typdefinition direkt entsprechen würde. Aus diesem Grund kann – anders als bei Funktionen – auch nicht der Binder eine Verbindung zwischen der Definition eines Typs in einer ÜE und seiner Verwendung in einer anderen ÜE herstellen. Export und Import von Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 115 Typdefinitionen muss auf rein textueller Ebene erfolgen. Beispielsweise indem in allen ÜEen die Typdefinitionen exakt gleich wiederholt werden. Beispiel: ue-1.cc typedef int Gramm; typedef float Kilo; // Gemeinsame Definitionen Kilo f (Gramm); int main () { Kilo x; Gramm y; x = f(y); } ue-1.cc Die zweite ÜE: ue-2.cc typedef int Gramm; typedef float Kilo; // Gemeinsame Definitionen, // Exakte Kopie Kilo f (Gramm g) { return (float)g/1000; } ue-2.cc In der praktischen Arbeit ist es natürlich nur schwer möglich über verschiedene Dateien verstreute Typ– Definitionen stets auf dem gleichen Stand zu halten. Es ist auch nicht notwendig, denn mit dem include– Mechanismus können die Definitionen in einer Datei konzentriert werden. Export–Regel 2: Wird eine Typdefinition von mehreren ÜEen benutzt, dann schreibt man sie in eine H–Datei, die von allen Importeuren inkludiert wird. Am besten ordnet man die Typdefinition einer ÜE zu und platziert die Definition dann in deren H–Datei. Unser Beispiel wird damit zu (siehe auch Abbildung 44): Die erste ÜE: UE-1 ue-1.cc: #include "ue-2.h" //inkludiere H-Datei von UE-2 int main () { Kilo x; Gramm y; x = f(y); } UE-1 Die zweite ÜE, besteht aus zwei Dateien: UE-2 Programmierung II 116 ue-2.h: typedef int Gramm; // Gemeinsame Typdefinitionen typedef float Kilo; Kilo f (Gramm); // Deklaration der exportierten Funktion ue-2.cc: #include "ue-2.h" Kilo f (Gramm g) { return (float)g/1000; } UE-2 ue-2.h typedef int Gramm ue-1.cc #include "ue-2.h" ue-2.cc #include "ue-2.h" ... Gramm ... ... Gramm ... Referenz auf Gramm wird g++ -c g++ -c ue-2.o main.o vom Compiler per include aufgelöst Keine unaufgelöste Referenz auf Gramm im Objektcode prog Abbildung 44: Getrennte Übersetzung mit gemeinsamen Definitionen 4.6.6 Typdefinitionen haben keine externe Bindung Typdefinitionen haben interne Bindung.11 Im Gegensatz etwa zu Funktionen, werden Definition und Anwendung nicht vom Binder zusammengeführt. Eine Übersetzungseinheit kann nicht übersetzt werden, wenn ihr nicht alle benutzten Typdefintionen vorliegen.12 Sie kann aber – wie oben demonstriert – sehr wohl mit fehlenden Funktionsdefinitionen übersetzt werden. 11 Sie existieren nicht im Objektcode und haben damit genau gesagt gar keine Bindung, weil es nichts zu binden gibt. gewissen Umständen sind Vorausdeklarationen von Typen möglich. In diesen Fällen benötigt der Compiler die Typdefinition aber nicht wirklich. 12 Unter Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 4.6.7 117 Export und Import von Klassendefinitionen Eine Klasse besteht aus der Klassendefinition und der Definition der Methoden. Export und Import von Klassendefinitionen sind darum eine Kombination von Export/Import eines Typs mit der von Funktionsdefinitionen. Export–Regel 3: Wird eine Klasse exportiert, dann enthält die exportierende ÜE die Klassendefinition in ihrer H–Datei und die Definition der Methoden in ihrer C–Datei. Die C–Dateien der exportierenden und die C–Dateien der importierenden ÜEen inkludieren diese H– Datei. Eine Klasse bildet in der Regel eine eigene ÜE. Beispiel: H–Datei: Buch.h #ifndef BUCH_H_ #define BUCH_H_ #include <string> class Buch { public: Buch (); Buch (string, string); string holAutor () const; string holTitel () const; void setzeAutor void setzeTitel (string); (string); static bool neuer (const Buch &, const Buch &); private: string titel; string autor; const int regNr; static int naechsteNr; }; #endif Buch.h und C–Datei: Buch.cc #include "Buch.h" Buch::Buch(string p_autor, string p_titel) : titel (p_titel), autor (p_autor), regNr (naechsteNr) {naechsteNr++;} Buch::Buch() : titel (""), autor (""), regNr (naechsteNr) { naechsteNr++; } string string void Buch::holAutor () const { return autor; } Buch::holTitel () const { return titel; } Buch::setzeAutor (string p_autor) { autor = p_autor; } Programmierung II 118 void bool int Buch::setzeTitel (string p_titel) { titel = p_titel; } Buch::neuer (Buch &b1, const Buch &b2) { return b1.regNr > b2.regNr; } Buch::naechsteNr = 0; Buch.cc H–Datei und C–Datei bilden zusammen die ÜE Buch. Die folgenden Prinzipien wurden in diesem Beispiel beachtet: Eine Klasse bildet eine ÜE. Die Klassendefinition kommt in die H–Datei. Alle Methoden – statisch oder nicht – kommen in die C–Datei. Alle statischen Datenkomponenten werden in der C–Datei definiert und initialisiert. Die H–Datei inkludiert die Defintionen die von der Klassendefinition benutzt werden (im Beispiel #include <string>), Die C–Datei inkludiert als erstes die H–Datei, dann eventuell weitere Dateien, deren Definitionen in der Implementierung der Methoden benötigt werden. Von der Regel, dass jede Klasse eine eigene ÜE bildet, sollte nur bei kleinen oder sehr eng verwandten oder befreundeten Klassen und Funktionen abgewichen werden. Siehe Abbildung 45. Buch.h class Buch { ... }; main.cc Buch.cc #include "Buch.h" #include "Buch.h" Buch::Buch () { ... } Buch bibel; g++ −c g++ −c main.o Buch.o Referenz auf gemeinsame Deklarationen Compiler löst Referenz auf Deklarationen per include auf unaufgelöste Referenzen auf JUMP Buch_Konstr START Buch_Konstr Methodenimplementierungen im Objektcode Binder löst Referenzen im Objektcode auf (für alles mit externer Bindung) prog Abbildung 45: Getrennte Übersetzung einer Klasse Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 4.6.8 119 Import und Export von globalen Variablen: externe Bindung Globale Variablen13 haben externe Bindung (engl. external linkage): Eine globale Variabale, die in einer Übersetzungseinheit definiert wurde, kann in dieser und allen anderen benutzt werden. Dazu muss der Importeur die fremden Variablen nur durch extern dem Compiler lokal bekannt machen. 14 Beispiel: ÜE–1, Importeur (Benutzer) von i, Exporteur (Definierer) von f: extern int i; // int i ist in einer anderen UE // es wird hier benutzt void f () { i++; } // Funktionsdefinition // Benutzung des fremden i ÜE–2, Exporteur von i, Importeur von f: void f (); // Funktionsdeklaration: f ist in einer anderen UE // es wird hier benutzt int i = 0; // Definition des gemeinsam genutzten i int main () { f(); // Benutzung des fremden f } Die externe Bindung der globalen Variablen i und der Funktion f bedeutet, dass sie über alle ÜE–Grenzen hinweg im gesamten Programm genutzt werden. Beide – i und f – existieren im Objektcode einer ÜE und der Binder kann Definitions– und Verwendungsstellen zusammenführen. Externe Bindung bedeutet nicht nur, dass der Binder die Verbindung herstellen kann, sondern auch dass er es auch tatsächlich macht. Objekte mit externer Bindung müssen darum über alle ÜE–Grenzen hinweg eindeutig sein, d.h. sie dürfen nur einmal im gesamten Programm definiert werden. Beispiel: ÜE 1: int i = 0; //ein i void f () { i++; } und ÜE 2: void f (); int i = 0; //FEHLER, ein ANDERES globales i ist NICHT erlaubt int main () { i++; f(); } Werden ÜE–1 und ÜE–2 zu einem Programm gebunden, dann wird der Binder einen Fehler melden: i wurde zweimal definiert; zwar in unterschiedlichen Dateien und unterschiedlichen ÜEen, aber in einem Programm – das darf nicht sein! Siehe Abbildung 46. 13 Es wird generell nicht empfohlen mit globalen Variablen zu arbeiten, es sei denn es liegen wirklich stichhaltige Gründe dafür vor. C–Programmierer: Hier weicht C++ von C und auch von älteren C++–Versionen ab. static als Kennzeichnung der Lokalität globaler Variablen (interne Bindung, internal linkage) gibt es nicht mehr. Alle globalen Variablen haben externe Bindung (external linkage.) 14 Achtung Programmierung II 120 ÜE−1.cc ÜE−2.cc int i; ÜE−1.cc ÜE−2.cc int i; int i; extern int i; .. i ... .. i ... g++ −c g++ −c g++ −c ÜE−1.o .. i ... .. i ... g++ −c ÜE−2.o externe i externe i Bindung ... i ... ... i ... prog ... i ... Bindung i ... i ... g++ ÜE−1.o ÜE−2.o KONFLIKT i ist doppelt deklariert Abbildung 46: Globale Variablen über ÜE–Grenzen hinweg Eine Doppeldefinition einer Funktion wäre ebenfalls auch dann ein Fehler, wenn die beiden Definitionen in unterschiedlichen ÜEs zu finden sind. Natürlich kann nicht der Compiler, sondern erst der Binder solche Fehler finden. Export–Regel 4: Der Export von Variablen ist in der Regel zu vermeiden. Sollte er doch notwendig sein, dann schreibt der Exporteur die Variablendefinition in seine C–Datei und die extern–Deklaration dieser Variablen in seine H–Datei. Der Importeur inkludiert diese H–Datei. Statische Datenkomponenten einer Klasse, die Klassenvariablen, werden in Bezug auf getrennte Übersetzung wie globale Variablen behandelt. Sie haben externe Bindung. Ihre Deklaration gehört mit der Klassendefinition in die H–Datei und ihre Definition in die C–Datei. 4.6.9 Import und Export von Konstanten, interne Bindung Konstanten haben im Gegensatz zu globalen Variablen und Funktionen eine interne Bindung (engl. internal linkage), d.h sie werden nicht vom Binder automatisch über ÜE–Grenzen im gesamten Programm bekannt gemacht. Aus diesem Grund können die folgenden beiden ÜEen problemlos zu einem Programm gebunden werden: ÜE 1: const int i = 0; //eine int-Konstante void f () { int x = i; } ÜE 2: void f (); const int i = 1; //eine andere int-Konstante int main () { int y = i; f(); } Interessanterweise kann Konstanten aber auch explizit eine externe Bindung verschafft werden. Beide – Exporteur Th Letschert, Fachbereich MNI, FH Giessen–Friedberg ÜE-1.cc extern const int i = 0; 121 ÜE-2.cc extern const int i; .. i ... .. i ... g++ -c g++ -c externe Bindung keine externe i ... i... ... i ... ... i ... g++ ÜE-1.o ÜE-2.o prog i i Bindung ... i ... g++ ÜE-1.o ÜE-2.o prog g++ -c ÜE-2.o i ... i ... ... i ... .. i ... ÜE-1.o ÜE-2.o i const int i = 1; .. i ... g++ -c ÜE-1.o ÜE-2.cc ÜE-1.cc const int i = 0; i ... i... ... i ... Abbildung 47: Konstanten über ÜE–Grenzen hinweg und Importeur – müssen dazu die Konstante mit extern kennzeichnen; der Exporteur muss sie initialisieren, der Importeur darf sie nicht initialisieren (siehe auch Abbildung 47): ÜE 1: extern const int i = 0; //eine int-Konstante, ueber UE-Grenzen hinweg //verwendbar (externe Bindung) void f () { int x = i; } ÜE 2: void f (); // Info an den Compiler uber fremdes Objekt f extern const int i; // Info an den Compiler uber fremdes Objekt i int main () { int y = i; f(); } // i durch den Binder mit dieser Verwendung verknupft // f durch den Binder mit dieser Verwendung verknupft Am besten macht man von dieser Möglichkeit keinen Gebrauch. Besser man verbreitet Konstanten, die in mehreren ÜEen bekannt sein sollen, nicht über die mögliche externe Bindung (also durch den Binder), sondern dadurch, dass man ihre Definition (ohne extern!) in eine von allen inkludierte H–Datei schreibt (also mit dem include– Mechanismus auf Textebene). Im Beispiel: UE 1 ue-1.h: const int i = 0; void f (); ue-1.c: #include üe-1.h" void f () int x = i; Programmierung II 122 UE 1 UE 2 ue-2.c: #include "ue-1.h" int main () { int y = i; // i ist wird nur "textuell" verbreitet f(); // f wird durch den Binder mit dieser Verwendung verkn"upft } UE 2 Die Konstanten mit externer Bindung weiter oben werden vom Binder im Programm verbreitet. Im Gegensatz dazu werden hier zwei verschiedene Konstanten i definiert. Mit der Platzierung der Definition in der H–Datei, die von beiden ÜEen inkludiert wird, erreicht man aber, dass sie den exakt gleichen Wert haben. In jedem Fall ist es sicher ganz schlechter Stil innerhalb eines Programms sowohl Konstanten mit interner als auch solche mit externer Bindung willkürlich zu mischen15. Am besten vermeidet man globale Konstanten vollständig. Statische Klassenmitglieder sind die bessere Alternative! Export–Regel 5: Globale Konstanten sind in der Regel zu vermeiden. Sind sie dennoch notwendig, dann werden sie wie Typdefinitionen behandelt: Man schreibt sie in eine H–Datei, die von allen Importeuren inkludiert wird. Am besten ordnet man die Konstantendefinition einer ÜE zu und platziert die Definition dann in deren H–Datei. 4.7 4.7.1 Make Programmerzeugung Programme, die aus mehreren ÜEs bestehen, können nur in mehreren Schritten erzeugt werden. Die Komponenten müssen einzeln übersetzt und dann gebunden werden. Für derartige Aufgaben wird allgemein ein Make–System verwendet. Nehmen wir an, dass ein Programm aus zwei ÜEen besteht: ÜE 1 mit den Dateien: ue-1.h und ue-1.cc; ÜE 2 mit den Dateien: ue-2.h und ue-2.cc. wobei sowohl ue-1.cc als auch ue-2.cc beide H–Dateien inkludieren. Das Gesamtprogramm, als ausführbare Datei prog, wird mit folgenden Anweisungen erzeugt: g++ -c ue-1.cc g++ -c ue-2.cc g++ -o prog ue-1.o ue-2.o 4.7.2 Makedatei Diese Anweisungen wird man nicht nach jeder Änderung einer Quelldatei aufs neue tippen wollen. Man könnte sie in eine “Batch–Datei” schreiben, aber der übliche Weg ist die Erstellung einer Make–Datei mit dem Namen Makefile: 15 Die vielen Möglichkeiten von C++ wurden zur Konstruktion klarer und effizienter Programme eingeführt, nicht, wie man gelegentlich versucht ist zu glauben, dazu möglichst unleserliche, verwirrende oder beeindruckende Programme zu schreiben! Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 123 prog : ue-1.o ue-2.o g++ -o prog ue-1.o ue-2.o ue-1.o : ue-1.cc ue-1.h ue-2.h g++ -c ue-1.cc ue-2.o : ue-2.cc ue-1.h ue-2.h g++ -c ue-2.cc Achtung: Makedateien haben strenge Formatkonventionen! Die eingerückten Zeilen müssen mit einem eingerückt werden. Die nicht eingerückten Zeilen müssen in der ersten Spalte beginnen! Den Inhalt der Make–Datei kann man abschnittweise wie folgt deuten: prog : ue-1.o ue-2.o g++ -o prog ue-1.o ue-2.o Erste Zeile: Die Datei prog wird aus den Dateien ue-1.o und ue-1.o erzeugt (prog hängt von ue-1.o und ue-2.o ab.) Zweite Zeile: prog wird mit dem Kommando g++ -o prog ue-1.o ue-2.o erzeugt. ue-1.o : ue-1.cc ue-1.h ue-2.h g++ -c ue-1.cc Die Erzeugung der Datei ue-1.o hängt von den Dateien ue-1.cc, ue-1.h und ue-2.h ab. ue-1.o wird mit dem Kommando g++ -c ue-1.cc erzeugt. ue-2.o : ue-2.cc ue-1.h ue-2.h g++ -c ue-2.cc ue-2.o hängt von ue-1.cc, ue-1.h und ue-2.h ab und wird mit g++ -c ue-2.cc erzeugt. Mit dem Aufruf make prog oder einfach make kann jetzt das Programm erzeugt werden. make liest standardmäßig eine Datei mit Namen Makefile und interpretiert die dort zu findenden Anweisungen. 4.7.3 Make–Dateien bestehen aus rekursiven Regeln zur Dateierzeugung Eine Make–Datei besteht aus einer Kollektion von Make–Regeln (make rules). Jede Regel hat die Form: Ziel–Datei : Abhängigkeitsliste Kommando Die Ziel–Datei ist eine Datei die mit Hilfe von Kommando generiert wird. Die Abhängigkeitsliste ist die Liste der Dateien, die zur Erzeugung der Ziel–Datei benötigt werden. ( ist das Tabulatorzeichen!) Ein Vorteil von make gegenüber einer Liste von Kommandos besteht darin, dass make die richtige Reihenfolge der Erzeugung mit Hilfe der Abhängigkeitsliste selbst bestimmt. Das Kommando einer Regel wird ausgeführt, wenn die Ziel–Datei nicht existiert, aber alle Dateien in der Abhängigkeitsliste existieren. Die Dateien in der Abhängigkeitsliste werden vorher ihrer Regel entsprechend erzeugt – auch wenn die Regel weiter unten im Text zu finden ist. make realisiert also einen rekursiven Algorithmus zur Dateierzeugung. Es werden also nicht einfach Kommandos abgearbeitet, sondern nur das Notwendige und das in der richtigen Reihenfolge getan. Programmierung II 124 4.7.4 Make beachtet die Erzeugungszeiten der Dateien Eine Make–Regel wird immer angewendet, wenn die Ziel–Datei nicht existiert. make beachtet aber auch die Erzeugungszeiten der Dateien und erzeugt die Ziel–Datei auch dann neu, wenn sie älter ist als eine in ihrer Abhängigkeitsliste. Auch dieser Mechanismus wird selbstverständlich rekursiv angewendet. Nehmen wir an im Beispiel oben wird ue-2.cc geändert und dann make aufgrufen. Damit wird ue-2.cc neu übersetzt (ue-2.cc ist neuer als ue-2.o) und prog neu gebunden (ue-2.o ist neuer als prog). Man sieht, dass die Welt von make aus Dateien besteht. Dateien werden mit Hilfe von Kommandos erzeugt und jede Datei hängt von einer Liste von anderen Dateien ab. Entsprechend den im Makefile dargelegten Regeln untersucht make alle Ziel–Dateien und generiert alle die neu, die älter sind als eine der Dateien, von denen ihre Produktion abhängt. Dabei sorgt make auch dafür, dass zum einen die richtige Reihenfolge eingehalten wird und zum anderen keine überflüssigen Generierungsprozesse ablaufen. 4.7.5 Make hält Systeme aktuell Jeder Aufruf von make aktiviert alle notwendigen und keine überflüssigen Generierungsprozesse. Damit können komplexe Systeme nicht nur produziert werden, man kann sie auch mit minimalem Aufwand aktuell halten. Systeme sind sehr oft Programme. Die Anwendung von make ist aber nicht nur auf die Erzeugung von Programmen beschränkt, man kann es benutzen um den Ablauf beliebiger Aktionen zu steuern, soweit diese Aktionen sich mit der Generierung von Dateien aus anderen Dateien beschäftigen. Make ist ein mächtiges und flexibles Werkzeug. Die hier diskutierten Dinge kratzen gerade mal an der Oberfläche. Jedem, der ernsthaft an SW–Entwicklung interessiert ist, wird eine eingehendere Beschäftigung mit diesem Werkzeug dringend empfohlen. 4.8 4.8.1 Bibliotheken Bibliotheken sind Dateiarchive Eine Bibliothek – zu Recht auch oft Archiv genannt – ist eine Datei, die eine Kollektion anderer Dateien enthält. 4.8.2 Objektbibliotheken werden vom Binder verarbeitet Die wichtigste Anwendung der Dateiarchive sind die Objektbibliotheken: Kollektionen von Objektdateien in einer Archivdatei. Eine Objektbibliothek kann vom Binder ebenso wie eine Objektdatei verarbeitet werden. Nehmen wir an unser Programmsystem besteht aus den ÜEen ue-m.cc, (Hauptprogramm) ue-1.cc (1–te Hilfsfunktion) und ue-2.cc (2–te Hilfsfunktion). Dabei enthält ue-m.cc das Hauptprogramm und die beiden anderen irgendwelche Hilfsroutinen und/oder Klassendefinitionen. Wir können ue-1.cc und ue-2.cc übersetzen und in einer Bibliothek mit beliebigem Namen, z.B. libmm.a, einsortieren: > g++ -c ue-1.cc > ar -r libmm.a ue-1.o Das ar–Kommando sortiert (archiviert) die Datei ue-1.o in das Archiv libmm.a ein. Sollte dieses Archiv noch nicht existieren, dann wird es erzeugt. Die Option -r steht für replace und bedeutet, dass das Argument eingeführt und ein eventuell vorhandenes Element gleichen Namens gelöscht wird (d.h. replace = ersetzendes Einfügen). Das gleiche kann mit ue-2.cc gemacht werden: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 125 > g++ -c ue-2.cc > ar -r libmm.a ue-2.o In libmm.a sind jetzt zwei Objektdateien vorhanden. Es ist eine Objektbibliothek die vom Binder benutzt werden kann: > g++ -o prog ue-m.cc libmm.a Mit diesem Kommando wird die ÜE ue-m übersetzt, mit der Bibliothek libmm.a gebunden und das Ergebnis als ausführbare Datei in prog erzeugt. Der Bindeprozess wird oft beschleunigt, wenn innerhalb des Archivs mit ranlib libmm.a ein interner Index aller definierten Symbole erzeugt wird. ranlib kann man beispielsweise nach jedem Einfügen aufrufen: > g++ -c ue-2.cc > ar -r libmm.a ue-2.o > ranlib libmm.a 4.8.3 Objektbibliotheken können programmübergreifend verwendet werden Es ist nicht notwendig, dass alle Bestandteile einer Objektbibliothek beim Binden verarbeitet werden. Man kann darum auch Bibliotheken erzeugen, die von vielen Programmen benutzt werden. Der Objektcode, der zu den Standardroutinen wie etwa der Ein–/Ausgabe oder zu den mathematischen Funktionen gehört, ist in einer solchen Bibliothek zu finden – der sogenannten Standardbibliothek. Sie wird stets automatisch zu jedem Programm gebunden. 4.8.4 Bibliotheks–Suchpfade Die Verwendung von Objektbibliotheken ist so wichtig und weitverbreitet, dass der Binder weitere Kommandozeilen–Optionen zur Unterstützung bietet. Wichtig sind: -LVerzeichnispfad Mit dieser Option wird der Binder veranlasst (auch) im Verzeichnis Verzeichnispfad nach Objektbibliotheken zu suchen. -lBibliothekskürzel Mit dieser Option wird die Bibliothek libBibliothekskürzel.a zum Programm gebunden. Beispiel: > g++ -o prog ue-m.o -L/mein/pfad -lmm Hier wird die Objektbibliothek libmm.a aus dem Verzeichnis /mein/pfad zum Programm gebunden. Objektbibliotheken sollten wegen dieser Konvention grundsätzlich einen Namen tragen, der mit lib beginnt und mit .a endet. 4.8.5 Objektbibliotheken und Make Selbstverständlich können Objektbibliotheken auch in Zusammenhang mit Make verwendet werden. Der Quellcode wird übersetzt und die Objektdatei in das Archiv eingefügt. Eine entsprechende Regel ist: ue-1.o : ue-1.cc ue-1.h ue-2.h g++ -c ue-1.cc ar -r libmm.a ue-1.o ranlib libmm.a Man sieht, dass make mehrere Kommandos für ein Ziel aktivieren kann und dass die Kommandos sich nicht unbedingt wirklich mit der Ezeugung der Zieldatei beschäftigen müssen. Programmierung II 126 Zum Binden des Gesamtprogramms wird die Bibliothek benötigt. Dies schlägt sich in einer entsprechenden Regel nieder: program : program.o libmm.a g++ -o program program.o libmm.a 4.8.6 Make–Makros Zur Aktualisierung der Einträge in einer Bibliothek benutzt man am besten Makros: # im aktuellen Ziel ($@) neue Voraussetzung ($?) ersetzen: libmm.a : ue-1.o ue-2.o ue-3.o ue-4.o ar -r $@ $? Im Kommando dieser Regel werden zwei Makros eingesetzt, um im aktuellen Ziel (erstes Makro, die Bibliothek) die neue Voraussetzung (zweite Makro, die neue O–Datei) zu ersetzen. Eine Makedatei für ein ganzes Programmsystem, das mit einer Objektbibliothek arbeitet ist: # ausfuehrbares Programm prog erzeugen prog : main.o Buch.o libmm.a g++ -o prog main.o libmm.a # UE Buch uebersetzen Buch.o : Buch.h Buch.cc g++ -c Buch.cc # UE Buchmappe uebersetzen Buchmappe.o : Buch.h Buchmappe.h Buchmappe.cc g++ -c Buchmappe.cc # UE main uebersetzen main.o: Buch.h main.cc g++ -c main.cc # Objektbibliothek erzeugen libmm.a : Buch.o Buchmappe.o ar -r $@ $? Ein Teilsystem eines größeren Gesamtsystems kann mit folgender Makedatei übersetzt und in einer Bibliothek platziert werden: CC=g++ AR=ar CFLAGS=-g ARFLAGS=r SHARE_LIB=/Pfad/zu/lib.a OBJS=lib-datei-1.o lib-datei-2.o SRCS=$(OBJS:.o=.c) ${SHARE_LIB}: ${OBJS} ${AR} ${ARFLAGS} $@ $? -ranlib $@ Hiermit werden lib-datei-1.c und lib-datei-1.c übersetzt und dann in der Bibliothek /Pfad/zu/lib.a platziert. Die Makedatei für ein Programm, das Definitionen aus einer der beiden, oder beiden benutzt, kann folgendes Aussehen haben: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 127 CC=g++ CFLAGS=-g LDFLAGS= SHARE_LIB=/Pfad/zu/lib.a OBJS=dat-1.o dat-2.o dat-3.o dat-4.o SRCS=$(OBJS:.o=.c) program: ${OBJS} ${CC} ${CFLAGS} -o program ${OBJS} ${SHARE_LIB} -lpthread Das Programm besteht hier aus vier Übersetzungseinheiten dat-1.c .. dat-4.c. Zu den Objektdateien dat-1.o .. dat-4.o wird die Bibliothek /Pfad/zu/lib.a gebunden, in der lib-datei-1.o und lib-datei-2.o von oben abgelegt sind. Dazu wird noch die Systembibliothek pthread gebunden. 4.8.7 Dynamisches Binden In Systemen mit dynamischer Bindung enthalten ausführbare Programme nicht den gesamten Code. Das unvollständige Programm wird gestartet. Wenn dann bei der Ausführung ein fehlender Teil angetroffen wird, dann wird er dynamsich nachgeladen und gebunden. Der Begriff “dynamisch” bedeutet hier wieder “zur Laufzeit”. Der Vorteil des dynamischen Bindens sind wesentlich kleinere ausführbare Programme. Dem steht ein komplexerer Lade/Binde–Prozess gegenüber. Eine Bibliothek, die dynamisch bindbaren Objektcode enthält, wird dynamische Bibliothek oder auch dynamic link library (DLL) genannt. 4.9 4.9.1 Beispiel: Geometrische Objekte Übersetzungseinheiten Als etwas umfangreicheres Beispiel wollen wir hier die geometrischen Objekte aus dem letzten Kapitel auf mehrere Übersetzungseinheiten verteilen. Üblicherweise macht man jede Klasse zu einer Übersetzungseinheit, die jeweils aus einer H–Datei und einer C–Datei besteht. Unser Beispiel wird damit zu: ÜE–1: Vektor.h, Vektor.cc ÜE–2: Punkt.h, Punkt.cc ÜE–3: Gerade.h, Gerade.cc Hauptprogramm als eigene ÜE: main.cc 4.9.2 H–Dateien Jede H–Datei wird mit einem Inklusionswächter ausgestattet. Sie umfasst die Klassendefinition und die Deklarationen der freien Funktionen für die entsprechende Klasse. Die benutzten Defintionen werden in Form ihrer H–Dateien inkludiert. Z.B. Gerade.h: #ifndef GERADE_H_ #define GERADE_H_ // Inklusionswaechter #include <iostream> #include <Punkt.h> #include <Vektor.h> // benutzte Definitionen class Gerade { ... }; // Klassendefinition ostream & operator<< (ostream &, const Gerade &); // freie Funktionen istream & operator>> (istream &, Gerade &); Programmierung II 128 #endif 4.9.3 C–Dateien Die C–Dateien enthalten die Methodendefinitionen, die Definitionen der freien Funktionen und die Definition der Klassenvariablen: #include <Gerade.h> // H-Datei inkludieren Gerade::Gerade () : p_(Vektor(0,0)), a_(Vektor(1,1)) {} ... // Methodendefinition ostream & operator<< (ostream & os, const Gerade &g) { // freie Funktion return os << "[" << g.p_ << " + " << g.a_ << "]"; } ... const Gerade Gerade::x_Achse(Vektor(0,0), Vektor (1,0)); const Gerade Gerade::y_Achse(Vektor(0,0), Vektor (0,1)); 4.9.4 // statische // Komponenten Dateiorganisation Vektor, Punkt und Gerade und das Hauptprogramm werden in vier Verzeichnissen organisiert: Ein Inklusionsverzeichnis mit allen H–Dateien Ein Quellverzeichnis mit allen C–Dateien Ein Bibliotheksverzeichnis mit der Bibliothek, die den Objektcode enthält. Ein Verzeichnis mit dem Hauptprogramm, der Anwendung der Klassen. include Inkludeverzeichnis Vektor.h Punkt.h Gerade.h src Quellverzeichnis Vektor.cc Punkt.cc Gerade.cc Bibliotheksverzeichnis lib libgeo.a Verzeichnis mit der Anwendung TestGeo main.cc Abbildung 48: Programmorganisation Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 4.9.5 129 Alle Geometrie–ÜEs in einer Bibliothek versammeln Das Quellverzeichnis wird jetzt mit einer Make–Datei ausgestattet, welche die Bibliothek erzeugt bzw. aktuell hält: LIBDIR=..Pfad zum Bibliotheksverzeichnis.. INCDIR=..Inklusionsverzeichnis.. CC=g++ CFLAGS=-Wall -pedantic AR=ar ARFLAGS=r ${LIBDIR}/libgeo.a : Gerade.o Punkt.o ${AR} ${ARFLAGS} $@ $? -ranlib $@ Vektor.o Gerade.o: Gerade.cc Gerade.h Punkt.h Vektor.h ${CC} -I${INCDIR} -c Gerade.cc Punkt.o: Punkt.cc Punkt.h Vektor.h ${CC} -I${INCDIR} -c Punkt.cc Vektor.o: Vektor.cc Vektor.h ${CC} -I${INCDIR} -c Vektor.cc 4.9.6 Anwendung Das Hauptprogramm #include <Vektor.h> #include <Punkt.h> #include <Gerade.h> int main (){ Gerade g1, g2; ... } kann dann mit dem Kommando: g++ -I..Pfad-zum-Inc-Verz.. -L..Pfad-zum-Bib-Verz.. main.cc -lgeo übersetzt und gebunden werden. Programmierung II 130 4.10 Übungen Aufgabe 1 1. Erläutern Sie mit welchem Mechanismus Zugriffsfehler durch “const” entdeckt werden. 2. Das Betriebssystem schützt sich und andere vor unberechtigten Speicherzugriffen der Programme. Warum gibt es in C++ dann noch ein const? 3. Wenn alle Programmiersprachen das const–Konstrukt von C++ hätten, müsste das Betriebssystem noch unberechtigte Speicherzugriffe der Programme abfangen? 4. Schreiben Sie ein Programm das die Summe seiner Kommandozeilenargumente berechnet. 5. Schreiben Sie ein Programm das, wenn es mit dem Namen min aufgerufen wird, das Miniumum seiner beiden int–Argumente berechnet und, wenn es den Namen max trägt, das Maximun berechnet: > > > > > > g++ min 3 g++ max 4 -o min minmax.cc 3 4 -o max minmax.cc 3 4 6. Welchem Zweck dienen Inklusionswächter? 7. Was versteht man unter “externe Bindung”? 8. Funktionen haben externe Bindung. Was bedeutet das konkret? Was wäre beispielsweise anders, wenn Funktionen interne Bindung hätten? Geben Sie jeweils ein Beispiel an, für etwas das bei externer Bindung möglich und bei interner Bindung unmöglich ist und umgekehrt. 9. Schreibt man Inline–Definitionen von freien Funktionen und Methoden in H– oder in C–Dateien? 10. Was ist falsch an: class C { public: C (); private: static int x; }; C::C() : x(5) {} Teilen Sie Ihre korrigierte Version auf eine C– und eine H–Datei auf. 11. Zwei Quelldateien a.cc und b.cc werden übersetzt und gebunden: a.cc: #include <iostream> float f(); typedef char X; const X a = ’a’; int main () { std::cout<<f()<<std::endl; } b.cc: #include <iostream> typedef float X; const X a = 0.0; float f () { std::cout<<a<<std::endl; return a; } Ist das Programm korrekt? Wenn ja: Welche Ausgabe erzeugt es? Wenn nein: was ist falsch? Was ändert sich wenn const in beiden Dateien weggelassen wird? Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 131 Aufgabe 2 1. Teilen Sie folgendes Programm in zwei Übersetzungseinheiten auf (Klasse B, main–Funktion). Beachten Sie dabei die Konventionen zur Gestaltung von Übersetzungseinheiten. #include <iostream> using namespace std; class B { friend int public: B(); ˜B(); int f(); static int private: int static int const int }; main(); g (); x; y; z; int B::y = 0; B::B() : x(0), z(y) { ++y; } B::˜B() { --y; } int B::f() { ++x; return x+z; } int B::g() { return y; } int main () { cout << B::g() << endl; B b1; cout << B::y << endl; B b2; if ( b1.f() + b2.f() > 2) { B b3; cout << B::g() << endl; } cout << B::g() << endl; } 2. Schreiben Sie eine Make–Datei zur Erzeugung eines ausführbaren Programms. Zur Übersetzung jeder Einheit und zum Binden soll diese jeweils eine Regel enthalten. Aufgabe 3 1. Realsieren Sie das Beispiel der geometrischen Objekte mit Vektoren, Punkten und Geraden mit getrennter Übersetzung, einer Objektbibliothek und einer Make–Datei. Jede Klasse soll dabei zu einer Übersetzungseinheit mit jeweils einer H–Datei und einer C–Datei werden. Die Objektdateien der Übersetzungseinheiten werden in der Bibliothek abgelegt. 2. Schreiben Sie ein Hauptprogramm – als weitere Übersetzungseinheit – zum Test Ihrer Bibliothek. Programmierung II 132 5 5.1 5.1.1 Datenstrukturen Verkettete Liste Listen Listen sind Kollektionen von sequenziell angeordneten Elementen. Eine Liste kann ihren Benutzern eine Vielzahl unterschiedlicher Operationen anbieten. Typisch für Listen ist jedoch stets, dass die Liste nicht auf eine bestimmte Größe festgelegt ist und Elemente nach Belieben eingefügt und herausgenommen werden können, sowie dass zum Zugriff auf ein Listenelement die Liste vom Anfang an durchlaufen werden muss. (Im Gegensatz zu Feldern gibt es keinen “Listen–Index”) Das sind einige typische Kriterien. Sie treffen meist, aber nicht immer zu. Der Begriff “Liste” ist so weit gefasst, dass eine Vielzahl an Varianten möglich ist. 5.1.2 Verkettete Listen Das Konzept “Verkettete Liste” bezieht sich auf die Implementierung einer Liste in Form von einzelnen Knoten, die über Zeiger miteinander verbunden sind. Listen werden sehr oft als verkettete Listen realisiert, man sollte aber trotzdem das Konzept der Liste von der Art seiner Implementierung trennen. Andere Implementierungen sind möglich und werden gelegentlich auch angetroffen. Beispielsweise werden Zeichenketten, also Listen von Zeichen, meist in Feldern gespeichert. Verkettete Listen können in unterschiedlicher Art realisiert werden: Einfach verkettete Listen: Bei ihnen enthält jeder Knoten neben dem Listenelement einen Verweis auf den Nachfolgerknoten. Doppelt verkettete Listen: Jeder Knoten beinhaltet einen Verweis auf den Nachfolger und den Vorgänger. Eine weitere Variationsmöglichkeit besteht darin, für die Liste insgesamt entweder nur einen Verweis auf das erste Element zu führen, oder sowohl auf das erste als auch auch das letzte Element zu verweisen. (Machen Sie sich die Unterschiede an Hand kleiner Skizzen klar !) 5.1.3 Listenvarianten Neben den unterschiedlichen Arten der Listenimplementierung gibt es auch Unterschiede in der prinzipiellen Arbeitsweise von Listen. Einige wichtige Funktions–Varianten sind: Sortierte Liste, Stapel (engl. Stack) Warteschlange (engl. Queue) Sortierte Listen speichern ihre Elemente in sortierter Form. Jede Einfügeoperation platziert ihr Argument an der richtigen Stelle. Stapel sind Listen, mit einem sehr eingeschränkten Satz an Operationen: Elemente können nur an einem Ende angefügt bzw. entnommen werden. Warteschlangen sind Listen, bei denen Elemente an einem Ende angefügt am anderen entnommen werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 5.1.4 133 Beispiel: verkettete Liste Eine Klassendefinition für eine einfach verkettete, unsortierte Liste mit einem Verweis nur auf das Startelement ist: class List { public: List (); ˜List (); void insert (int i); void erase (int i); void eraseAll (int i); int isIn (int i); private: class Node; // Deklaration geschachtelte Klasse Node *head; void erase_r (int i, Node * &); }; class List::Node { // geschachtelte Klasse public: Node (); Node (int, Node *); ˜Node (); int v; Node * next; }; Die hier definierte Liste speichert Elemente vom Typ int. Die Methode insert fügt ein Element ein, erase (i) löscht das erste Element das gleich i ist, eraseAll (i) löscht alle Elemente gleich i und isIn (i) stellt fest wie oft i in der Liste vorkommt. Die Liste wird mit Hilfe von zwei Klassen definiert. List ist der Typ des Listenanfangs. Die Listenelemente werden in Objekten der Klasse Node gespeichert. (Siehe Abbildung 49) List Node Node Node * int Node* int Node* head v next v next ... Stack Heap Abbildung 49: Struktur einer Liste mit zwei Klassen Objekte der Klasse List stellen die “Listenvariablen” dar. Sie liegen in der Regel im Stack. Objekte der Klasse Node liegen im Heap. 5.1.5 Geschachtelte Klassen Die Klasse List besteht neben den Methodendefinitionen aus einem Verweis head auf die verketteten Listenelemente vom Typ Node. (erase r ist eine Hilfsroutine.) Die Klasse Node ist eine reine Hilfsklasse. Die Benutzer der Liste brauchen diesen Typ nicht zu kennen. Node wurde darum innerhalb des privaten Teils von List definiert. Es ist eine geschachtelte Klasse (engl.: nested class). Programmierung II 134 Die Deklaration class Node; innerhalb von List kann auch durch die gesamte Klassendefinition ersetzt werden. Das macht die Schachtelung noch deutlicher: class List { public: List (); ˜List (); void insert (int i); void erase (int i); void eraseAll (int i); int isIn (int i); private: class Node { // geschachtelte Klasse public: Node (); Node (int, Node *); ˜Node (); int v; Node * next; }; Node *head; void erase_r (int i, Node * &); }; 5.1.6 Implementierung der verketteten Liste Die Implementierung der Methoden ist: List::Node::Node () : v(0), next(0){} List::Node::Node (int i, Node * n) : v(i), next(n){} List::Node::˜Node () { delete next; } List::List () : head (0) {} List::˜List () { delete head; } void List::insert (int i) { head = new Node (i, head); } int List::isIn (int i) { int res = 0; for(Node * p = head; p != 0; p = p->next) if (p->v == i) res++; return res; } void List::eraseAll (int i) { Node *p, *prev, *dead = 0; p = head, prev = 0; while (p != 0) { if (p->v == i) { dead = p; if (p == head) head = head->next; else prev->next = p->next; p = p->next; dead->next = 0; delete dead; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 135 } else { prev = p; p = p->next; } } } void List::erase_r (int i, Node * &l) { if (l != 0) if (l->v == i) { Node * dead = l; l = l->next; dead->next = 0; delete dead; } else erase_r (i, l->next); } void List::erase (int i) { erase_r (i, head); } An der Hilfsmethode erase r sieht man, dass ein einzelnes Element sehr elegant mit einer rekursiven Funktion gelöscht werden kann. Man beachte dass der zweite Parameter von erase r ein Referenzparameter ist. 5.1.7 Flache Listenkopie Eine Liste l 1 kann nicht einfach in eine Liste l 2 kopiert werden, indem man die Datenfelder von l 1 in die entsprechenden von l 2 kopiert. In unserem Beispiel würde so nur l 1.head nach l 1.head kopiert. Beide Listen würden auf die gleichen Knoten zeigen, mit dem Effekt, dass jede Manipulation der einen sich auf die andere auswirkt. Diesen Effekt einer Kopieroperation wird man sich wohl kaum wünschen. Nach der Zuweisung l 1 = l 2 teilen sich beide Listen die gleichen Knoten. Die alten Knoten von l 2 bleiben als Speichermüll zurück und jede Veränderung der einen wirkt auch auf die andere Liste.(Siehe Abbildung 50): l_1 1 2 3 11 12 0 gemeinsame Knoten l_2 10 13 0 Speichermüll Abbildung 50: Zwei Listen nach Zuweisung mit flacher Kopie 5.1.8 Tiefe Listenkopie Es reicht also nicht die Liste nur in dieser Art flach zu kopieren. Die Kopie muss tief sein: Alle Knoten der Liste müssen kopiert werden und nicht nur das Listenobjekt mit dem Zeiger auf den Anfang. Der vordefinierte Kopierkonstruktor kopiert stets nur flach. Sind Zeiger im Spiel, muss überlegt werden, was genau unter einer “Kopie” zu verstehen ist. Wenn das flache Kopieren nicht die der Anwendung angemessene Definition von “Kopieren” ist, dann muss ein eigener Kopierkonstruktor definiert werden. Mit dem eigenen Kopierkonstruktor wird stets auch ein eigener Zuweisungsoperator fällig. (Siehe Abbildung 51). Programmierung II 136 class List { public: ... List (const List &); List & operator= (const List &); ... }; // Kopierkonstruktor // Zuweisungsoperator // Kopierkonstruktor: initialisiere mich // mit einer tiefen Kopie von p_l List::List (const List &p_l) { head = 0; //kein delete, es gibt nichts altes ! Node **l = &head, //hier muss der Verweis auf den Neuen eingesetzt werden *n; //Verweis auf den Neuen for(Node * p = p_l.head; p != 0; p = p->next){ n = new Node (p->v, 0); *l = n; l = &(n->next); } } // Zuweisungsoperator: entsprechend dem Kopierkonstruktor // List & List::operator= (const List &p_l) { if (head != p_l.head) { // keine Zuweisung an sich selbst delete head; // hier delete: weg mit dem alten ! head = 0; Node **l = &head, *n; // Kopieren wie oben for(Node * p = p_l.head; p != 0; p = p->next){ n = new Node (p->v, 0); *l = n; l = &(n->next); } } return *this; // liefert Referenz auf sich selbst } l_1 1 2 3 0 1 2 3 0 delete l_2 10 delete 11 12 delete 13 delete 0 Abbildung 51: Zwei Listen nach Zuweisung mit tiefer Kopie Kopier–Konstruktor und Zuweisungsoperator sind für Kopieraktionen zuständig. Sie sind sich darum auch sehr ähnlich. Die geringen Unterschiede rühren daher, dass zum einen der Zuweisungsoperator einen alten Wert vorfindet, der weggeräumt werden muss, und dass er zum anderen einen Wert liefern muss. Die Zuweisung einer Variable an sich selbst (l=l;) funktioniert mit dem Kopieralgorithmus nicht. Sie muss darum abgefangen werden. Glücklicherweise ist Nichtstun eine korrekte Implementierung einer Zuweisung an sich selbst. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 5.2 5.2.1 137 Menge als konkreter Datentyp Mengen implementiert als verkettete Listen Verkettete Strukturen im Allgemeinen und Listen im Besonderen werden verwendet, wenn eine unbekannte oder variable Zahl von Dingen zu verwalten ist. Mengen von ganzen Zahlen können beispielsweise in Form einer verketteten Liste geführt werden. Man muss dabei lediglich darauf achten, dass kein Element zweimal in der Liste vorkommt. Die Menge stellt damit eine weitere Variante der Liste dar. 5.2.2 Eine Menge nicht objektorientiert In der klassischen – Objekt–freien – Programmierung hat eine Menge in etwa folgendes Aussehen: class Knoten { public: int wert; Knoten *naechster; }; ... void fuegEin (Knoten *m, int w) { .. einen neuen Knoten mit Inhalt w .. in die Knotenliste einfuegen, falls .. w noch nicht enthalten ist } void vereinigeMit (Knoten *m1, Knoten *m2) { .. jedes Element von m2 in die .. Knoten-Liste m1 einfuegen, falls .. es dort noch nicht enthalten ist } ... Knoten *menge1, *menge2, *menge3; menge1 = menge2 = menge3 = 0; ... fuegeEin (menge1, 2); ... vereingeMit (menge1, menge2); ... 5.2.3 Eine Menge einfach objektorientiert Die Klassen der objekt–orientierten Programmierung bieten zunächst einmal das Konzept der Kapselung mit der die Schnittstelle der Menge von der Implementierung getrennt wird: class Menge { public: void leere; void fuegEin (int w); void vereinigeMit (const Menge &m2); ... private class Knoten {...}; Knoten *anfang; }; ... void Menge::fuegEin (int w) {...} Programmierung II 138 void Menge::vereinigeMit (const Menge &m2) {...} ... Menge menge1, menge2, menge3; menge1.leere(); menge2.leere(); menge3.leere(); ... menge1.fuegeEin (2); ... menge1.vereinigeMit (menge2); ... Hier wird in erster Linie der Code des Programms so umorganisiert, dass alles, was zur Menge gehört, zusammengefasst und über eine definierte Schnittstelle der Anwendung zugänglich gemacht wird. Es entsteht eine variablenorientierte Klasse. 5.2.4 Die Menge als konkreter Datentyp Sollen Mengen genau so einfach und flexibel wie vordefinierte Datentypen verwendet werden, dann sind weitere Sprachkonstrukte und etwas Arbeit nötig. Als erstes stellen wir fest, dass Mengen vom intuitiven Konzept her wertartig zu verstehen sind. Das legt im Gegensatz zu oben eine Verwendung nach folgendem Muster nahe: class Menge { .. }; ... Menge menge1, menge2, menge3; //gleich richtig initialisiert ... menge1 = menge1 + Menge(2); // statt menge1.fuegeEin (2); ... menge1 = menge1 + menge2; // statt menge1.vereinigeMit (menge2); ... Die Vereinigung wird mit dem Operator “+” bezeichnet und sollte natürlich seiteneffektfrei sein. Seiteneffektfrei bedeutet, dass die Vereinigung ihre Operanden nicht verändert. Eine offensichtliche Forderung: Wie in der Mathematik soll die Vereinigung zweier Mengen eine dritte erzeugen und dabei die beiden Argumente in Frieden lassen. Mengen sind Werte durch eine wertartige Klasse zu implementieren. Damit eine wertartige Klasse nach dem Muster vordefinierter Datentypen verwendet werden kann, muss sie als konkreter Datentyp implementiert werden, d.h. sie muss mindestens einen Defaultkonstruktor, einen Kopierkonstruktor und den Zuweisungsoperator zur Verfügung stellen. Insgesamt benötigen wir bei der Menge als konkretem Datentyp: Defaultkonstruktor Mit ihm wird eine korrekt initialisierte leere Menge erzeugt. Kopierkonstruktor Er wird bei der Wertübergabe gebraucht und muss selbst definiert werden, wenn die Klasse Zeiger auf Hilfsobjekte (in unserem Fall die Knoten) enthält. Zuweisungsoperator Wenn der Kopierkonstruktor selbst definiert werden muss, dann gilt das auch für den Zuweisungsoperator. Hier passiert ja praktisch das Gleiche. Weitere von der speziellen Klasse abhängige Konstruktoren und Operatoren Operatoren wie “+” (Vereinigung) und “*” (Schnitt), sowie ein Konstruktor, der eine Menge aus einem Element erzeugen, sind sicher für jede Anwendung der mengen nützlich.. Destruktor Ein Destruktor ist immer dann notwendig, wenn die Objekte Zeiger auf Hilfsobjekte enthalten, die sich im Heap befinden und damit explizit weggeräumt werden müssen. Das ist bei den Mengen der Fall. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 139 Mit diesen Komponenten kommt man dann etwa zu folgender Klassendefinition für die Menge: class Menge { public: Menge (); // Menge (int); // Menge (const Menge &); // ˜Menge (); // Menge & operator= Menge operator+ Menge operator* bool istEnthalten leere Menge einelementige Menge Kopierkonstruktor Destruktor (const Menge &); // Zuweisung (const Menge &) const; // Vereinigung (const Menge &) const; // Schnitt (int i) const; private: class Knoten { public: Knoten (); Knoten (int, Knoten *); ˜Knoten (); int wert; Knoten * n; }; Knoten *anfang; // Start der verketteten Knoten ... eventuelle Hilfsroutinen ... }; 5.3 5.3.1 Wiederverwendung: Die Liste als allgemeine Behälterklasse Menge als Variante oder Benutzer einer Liste Im Beispiel oben ist die Menge eine Variante der Liste. Die Mengenoperationen werden als spezielle Listenoperationen realisiert. Einfügen vermeidet Duplikate, Vereinigung und Schnitt sind Varianten der Verknüpfung von Listen. Die Mengenimplementierung benutzt zwar auf konzeptioneller Ebene die Idee der “Liste”, aber es wird keine bestimmte konkrete Liste benutzt (siehe Abb. 52). einfuegen Liste loeschen Modifizieren, Anpassen vereinige Menge schneide Abbildung 52: Wiederverwendung der Konzepte: Menge als Variante der Liste So einfach und naheliegend sie ist, die Wiederverwendung der Konzepte hat auch Nachteile: Zunächst einmal muss der Quellcode verdoppelt werden. Neben der Liste gibt es eine modifizierte Liste als Menge. Alle Erweiterungen Programmierung II 140 und Modifikationen in der einen Variante müssen in der anderen nachgezogen werden. Die Gefahr ist groß, dass dabei Fehler passieren und beide im Laufe der Zeit auseinanderlaufen. Besser wäre es eine einzige Liste zu pflegen, die von allen “listenartigen Anwendungen” benutzt wird. Bei der echten Benutzung einer realen Liste durch die Menge enthält jedes Mengenobjekt eine komplette Liste. Eine Liste, jetzt nicht als Idee, sondern eine “reale” vollständige Liste, komplett mit Schnittstelle und Implementierung, taucht in der Menge auf (siehe Abb. 53 und als UML–Diagramm 54): class Liste {...}; class Menge { public: Menge (); ... etc. Mengenschnittstelle wie oben ... private: Liste l; ... }; // Die Liste als Komponente einer Menge Liste einfuegen loeschen Als Komponente benutzen ! vereinige schneide Menge Liste einfuegen loeschen Abbildung 53: Wiederverwendung durch Benutzung: Menge als Benutzer der Liste Menge vereinige schneide Liste einfuegen loeschen Abbildung 54: Menge als Benutzer der Liste in UML Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 5.3.2 141 Klassenbenutzung als Wiederverwendung Der Übergang vor der Menge als Listenvariante zur Menge, die eine Liste benutzt, sieht zunächst recht unschuldig aus. Tatsächlich repräsentiert er aber einen weiteren wesentlichen Schritt in der Art Software zu erstellen. Ein Schritt der mindestens dem Übergang von der einfach gekapselten Menge zum konkreten Datentyp Menge vergleichbar ist. Zunächst einmal ist es wohl sehr vernünftig, eine bereits vorliegende Listenklasse bei der Implementierung der Menge zu verwenden. Die Unterschiede sind gering, der Nutzen der Wiederverwendung ist hoch und kann scheinbar mit wenig Aufwand erreicht werden. Versucht man aber tatsächlich eine bestehende Listenklasse in der Mengenklasse zu verwenden, dann stellt man schnell fest, dass die Schnittstelle der Liste kaum bis gar nicht geeignet ist, die für die Menge notwendigen Dinge zu erledigen. Das Problem verschärft sich, wenn die Liste nicht nur für eine bestimmte Klasse wie die Menge, sondern für viele, jetzt eventuell noch unbekannte Anwendungen, ohne Modifikationen verwendet werden soll. Die Konstruktion einer wiederverwendbaren Liste lohnt sich aber nur, wenn sie oft und vielseitig wiederverwendet wird. Die Objektorientierung ist ursprünglich auch mit dem Anspruch aufgetreten, dass sie die Erstellung wiederverwendbarer Software–Komponenten möglich und lohnend macht. Heute nach ca. 15 Jahren Objektorientierung geht man allgemein davon aus, dass dieser Anspruch – zumindest in ersten Ansätzen – eingelöst werden kann 16. 5.3.3 Behälterklassen Behälterklassen (engl. Container Classes) sind universell einsetzbare Klassen, deren Objekte, die Behälter (engl. Container), dazu dienen andere Objekte aufzunehmen und zu verwalten. Stapel, Listen, Warteschlangen etc. sind Behälter. Behälter sind dadurch charakterisiert, dass ihre Methoden gar nicht oder nur unwesentlich davon abhängen, welche Objekte in ihnen gespeichert werden. Behälterklassen sind das erste und einfachste Beispiel für Wiederverwendbarkeit. Auch wenn für die gewünschte Flexibilität die Klassen als Schablonen (Templates) formuliert werden sollten, führen wir hier die Liste zunächst einmal als normale (wiederverwendbare Behälter–) Klasse ein. Später werden wir uns mit Templates beschäftigen. Listen und Behälterklassen ganz allgemein sind Bestandteil der Standard Template Library (kurz STL), die heute zu jeder vollständigen C++–Implementierung gehören muss. Einige einfache Varianten einer Behälterklasse implementiert zu haben, hilft beim Verständnis der Bibliothekskomponenten. 5.3.4 Eine allgemein verwendbare Liste Eine allgemein verwendbare Liste muss sicher die Möglichkeit bieten, Elemente am Anfang und am Ende anzufügen. Ebenso wird wohl die eine oder andere Listenanwendung nach einem bestimmten Element suchen, oder auch Elemente mitten in die Liste einfügen wollen. Bei der Implementierung der Menge wird eine Einfügoperation benötigt, die Duplikate vermeidet. Die Vereinigung ist eine Verkettung, bei der Duplikate eliminiert und der Schnitt eine Verkettung, bei der nur Duplikate berücksichtigt werden. Insgesamt wird eine wiederverwendbare Liste sicher wesentlich reichhaltiger sein als eine auf einen speziellen Einsatz zugeschnittene Version. Es ist aber völlig unmöglich, alle Sonderfälle und Varianten an notwendigen Listenoperationen vorherzusehen. Darum wird eine allgemeine und mächtige Operation benötigt, mit der sich im Zweifel alles realisieren lässt. Die allgemeinste Operation auf einer Liste besteht darin, sie zu durchlaufen und an jeder beliebigen Stelle nach Belieben zu manipulieren. 5.3.5 Liste mit aktueller Position Um eine Liste sytematisch zu durchlaufen, muss man entweder Zugriff auf ihre “Innereien” haben (was aber softwaretechnisch nicht empfehlenswert ist, vergl. Abb. 55), oder sie muss eine Schnittstelle bieten, über die ihre 16 Hier wird sich in den kommenden Jahren noch einiges tun. Das Komponentengeschäft ist zwar seit vielen Jahren Thema und beginnt jetzt zur Realität zu werden. An der Software–Entwicklung ernsthaft Interessierte tun gut daran, es im Auge zu behalten. Programmierung II 142 Benutzer auf ein Element nach dem anderen zugreifen können. Finger Weg von meinen Listenelementen ! Listenbenutzer Liste Abbildung 55: Privatsphäre der Liste wird nicht beachtet. Den dazu notwendigen Mechanismus gibt es in drei Varianten: Als Argument des Zugriffs: “Gib mir das zweite Element!” Als Liste mit verschiebbarer aktueller Position: “Gehe an den Anfang, gehe zum Nächsten!” Als “Iterator”, bei dem die eine aktuelle Position von der Liste losgelöst und in ein eigenes Objekt gepackt wird. 5.4 5.4.1 Cursorkonzept: Liste mit aktueller Position Position als Argument des Listenzugriffs Wenn der Listenbenutzer auf alle Listenelemente zugreifen will, dann kann er die Liste einfach bitten ihm das –te Element zu liefern, bzw. es zu verändern. Eine entsprechende Liste ist schnell skizziert: class Liste { public: ... int anzahl () int gibElement (int nr) void setzeElement (int nr, ... private: ... }; ... Liste l; ... for (int i; i <= l.anzahl(); if (l.gibElement(i) > 10) l.setzeElement(i, 10); const; const; int wert); ++i) Die Methode anzahl liefert die Zahl der Listenelemente, gibElement(nr) liefert den Wert des Listenelements mit der Nummer und setze(nr,wert) setzt es auf einen bestimmten Wert. Mit Referenzergebnissen kann das noch etwas eleganter formuliert werden. Statt einer Lese– und einer Schreibmethode: int gibElement (int nr) const; void setzeElement (int nr, int wert); nehmen wir eine mit der beides – Lesen und Schreiben – möglich ist: int & element (int nr); Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 143 class Liste { public: ... int anzahl () const; int & element (int nr); ... private: ... }; ... Liste l; ... // Liste durchlaufen: for (int i; i <= l.anzahl(); ++i) if (l.element(i) > 10) l.element(i) = 10; Die Implementierung dieses Elementzugriffs ist einfach und wird – ohne die Berücksichtigung von Fehlbedienungen – etwa wie folgt aussehen: int & Liste::element(int x) { Knoten p = anfang; int i = 1; while (i != x) { p = p->n; ++i; } return p->v; } 5.4.2 Position als Zustand der Liste Bei dieser Art von positionsweisem Zugriff muss die Liste bei jedem Zugriff neu von vorne durchlaufen werden. Der Aufruf element (100) durchläuft die Liste vom ersten bis zum hundertsten Element. element (101) ist nur einen Schritt weiter, aber die Liste muss wieder vom Anfang bis zum hundertersten Element durchlaufen werden. Elemente aktElement Behälterklasse geheWeiter aktPos aktElement geheWeiter Abbildung 56: Behälterklasse mit Position als Zustand Man müsste an der aktuellen Position stehen bleiben und später dort weitermachen können. Dazu muss die Liste sich die aktuelle Position merken: class Liste { public: ... void geheAnfang (); // erstes Element ist das aktuelle Programmierung II 144 void geheWeiter (); // int & aktElement (); // bool amEnde (); // ... private: Knoten * anfang; Knoten * aktPos; }; ... Liste l; ... // Liste durchlaufen: l.geheAnfang (); while ( !l.amEnde() ) { if (l.aktElement() > 10) l.geheWeiter(); } Position verschieben Zugriff auf das aktuelle Element Listenende erreicht ? l.aktElement() = 10; Diese Liste hat eine aktuelle Position als “Zustand”. Zwei Listen sind jetzt nicht mehr unbedingt gleich, wenn sie die gleichen Elemente in der gleichen Reihenfolge enthalten. Sie können mit der aktuellen Position – ihrem “Cursor” – in unterschiedlichen Zuständen sein. Diese Art von Liste ist nicht mehr wertorientiert sondern variablenorientiert (vergl. Abb. 56). 5.5 5.5.1 Iteratoren Problem der Position als Zustand Wenn die aktuelle Position ein Bestandteil der Liste ist, dann kann man keine zwei Positionen gleichzeitig in der Liste haben. Damit ist bereits schon ein einfaches Sortieren unmöglich: Zwei geschachtelte Schleifen und damit zwei Positionen gleichzeitig müssen dabei ja über die Liste laufen. void sort (Liste l]) { l.geheAnfang (); while ( !l.amEnde() ) { ..?? zweite innere Schleife ..?? auf der Liste ist nicht moeglich ..?? da es keine zwei aktuellen Positionen gibt while ..?? ..?? if (l.aktPos-1() > l.aktPos-2()) ..?? swap (l.aktPos-1(), l.aktPos-2()); } Als Ausweg könnte man die Liste mit zwei, drei oder auch 10 Positionen versehen. Ein besseres Konzept besteht darin, die aktuelle Position mitsamt den Zugriffs– und Verschiebeoperationen aus der Liste herauszulösen und in eine eigene Klasse zu packen. Der Listenbenutzer kann dann soviele aktuelle Positionen erzeugen, wie er braucht. Die aus der Liste – oder allgemein aus der Behälterklasse – herausgelöste aktuelle Position ist ein Iterator (vergl. Abb. 57). class Liste { friend class Iterator; public: ... Iterator anfang (); Iterator ende (); ... private: ... // Iterator, der auf den Anfang zeigt // Iterator, der hinter das Ende zeigt Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 145 Elemente Liste mit aktueller Position aktElement geheWeiter aktPos Elemente Liste ohne aktuelle Position aktElement geheWeiter aktPos Iterator Abbildung 57: Von der Liste mit Position zum Iterator: aktuelle Position in einem eigenen Objekt }; class Iterator { friend class Liste; public: void geheWeiter (); // Position verschieben int & aktElement (); // Zugriff auf das aktuelle Element private: Knoten * anfang; Knoten * aktPos; } ... Liste l; ... // Liste durchlaufen: Iterator i=l.anfang (); while ( i != l.ende() ) { if (i.aktElement() > 10) i.aktElement() = 10; i.geheWeiter(); } Die Methode anfang und ende der Liste erzeugen Iteratoren. Mit anfang wird der Iterator erzeugt, der auf das erste Listenelement zeigt: Iterator Liste::anfang() { Iterator res; res.aktPos = anfang; return res; } Iterator Liste::ende() { Iterator res; res.aktPos = 0; return res; } Das geht etwas kompakter, wenn die Klasse Iterator mit geeigneten Konstruktoren versehen wird: Iterator Liste::anfang() { return Iterator (anfang); } Iterator Liste::ende() { return Iterator (0); } Programmierung II 146 Jetzt kann jeder Benutzer der Liste soviele Positionen erzeugen wie er benötigt. Z.B.: Liste l; ... Iterator i_1 = l.anfang (); while ( i_1 != l.ende() ) { Iterator i_2 = i_1; while ( i_2 != l.ende() ) { if (i_1.aktElement() > i_2.aktElement()) swap (i_1.aktElement(), i_2.aktElement()) i_1.geheWeiter(); } i_2.geheWeiter(); } 5.5.2 Iteratoren mit operator*() und operator++() Ein Iterator ist ein “abstrakter Zeiger” der in eine Behälterklasse, wie beispielsweise eine Liste, zeigt. Mit einem Iterator können die Elemente des Behälters systematisch durchlaufen werden, ohne dass die Anwendung die Implementierung des Behälters kennen muss. Der Iterator einer Liste beispielsweise versteckt die konkrete Implementierung der Liste: einfach, oder mehrfach verkettet, Zahl, Namen, Typ der Komponenten eines Knotens, etc. Er bietet aber gleichzeitig wie ein Zeiger den vollen Zugriff auf die Listenelemente (vergl. Abb. 58). Ersetzt man die Methode aktElement() durch operator*() und geheWeiter() durch operator++() dann wird das Wesen des Iterators als abstrakten Zeiger noch deutlicher: Liste l; ... // Alle Listenenlemente von l inkrementieren, // ohne die Listenimlementierung zu kennen // for (ListenIterator i = l.anfang(); i != l.ende(); ++i) ++(*i); // <==> *i = *i+1; Elemente Behälterklasse *i ... ++i ... Iterator i Abbildung 58: Iteratorkonzept Hier wird die Liste l mit dem Iterator i durchlaufen und dabei jedes Listenelement um eins erhöht. Der Ausdruck ++(*i) inkrementiert ein Listenelement mit dem vordefinierten (normalen) ++–Operator auf Int–werten. ++i im Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 147 Kopf der Schleife dagegen ist das selbstdefinierte “Weitergehen in der Liste” als Inkrementieren des Iterators. i ist der Iterator, *i (= i.aktElement()) ist ein Listenelement, ++(*i) erhöht ein Listenelement um eins, und ++i (= i.geheWeiter()) geht zum nächsten Listenelement über. Alle Zugriffe sind nabhängig davon, wie die Liste implementiert ist. 5.5.3 Iteratordefinition Da i ein Iterator und nicht einfach ein Zeiger ist, müssen die Dereferenzierung (operator*) und der Inkrement– Operator (operator++) umdefiniert werden. Dabei wird natürlich die reale Struktur der Listen–Knoten berücksichtigt (vergl. Abb. 59): Liste erster ... l l Iterator ++i pos i *i Abbildung 59: Iteratordefinition class ListenIterator { friend class Liste; public: ... int & operator* (); ListenIterator & operator++ (); ... private: Liste &l; // Die Liste Liste::Knoten *pos; // aktueller Knoten (aktuelle Position) }; // in dieser Liste class Liste { friend class ListenIterator; ... ListenIterator anfang (); ListenIterator ende (); ... private: class Knorten { ... }; Knoten * erster; ... }; Programmierung II 148 Der Iterator wurde hier erweitert. Neben der aktuellen Position enthält er noch einen Verweis auf die Liste selbst. Damit kann der Bezug zur Liste überwacht werden. Eine besondere inhaltliche Bedeutung hat dieser Zeiger nicht. Die überladenen Operatoren “*” und “++” sind: int & ListenIterator::operator* () { return pos->wert; } ListenIterator & ListenIterator::operator++ () { if (pos != 0) pos = pos->n; return *this; } Und die Erweiterungen der Liste: ListenIterator Liste::anfang () { return ListenIterator (*this, erster); } ListenIterator Liste::ende () { return ListenIterator (*this, 0); } this ist der Zeiger der in jeder Methode auf das aktuelle Objekt zeigt. Mit *this übergeben wir in beiden Methoden die Liste selbst an den Konstruktor des Iterators. erster ist der Zeiger auf den ersten Knoten der Liste. Ein Iterator enthält einen Verweis auf den “aktuellen” Knoten der Liste. Mit dem operator* kann lesend und schreibend (Referenz als Ergebnis!) auf die wert–Komponente des aktuellen Knotens zugegriffen werden. Der operator++ geht in der Liste weiter zum nächsten Knoten. Wir benutzen den Prefix–Operator ++i weil er i zuerst inkrementiert und dann den neuen Wert liefert. Das ist einfacher als zuerst den Wert feststellen, i inkrementieren und dann den alten Wert liefern, wie es die Semantik des Postfix–Operators i++ verlangt. Wenn wie hier der gelieferte Wert ignoriert wird, sollte man immer die einfachere Variante des Inkrementierens, die Prefix–Operation, wählen. Der Sinn des Iterators gegenüber dem Hantieren mit “rohen” Zeigern besteht darin, dass Struktur und Verkettung der Knoten vollständig vor der jeweiligen Anwendung verborgen bleiben, trotzdem aber ein Zugriff auf die Werte aller Listenelemente möglich ist. Beim lesenden Durchlaufen einer Liste mag man den Vorteil der versteckten Innereien eventuell noch nicht sofort einsehen. Bei modifizierenden Listenoperationen sieht das aber schon anders aus. 5.5.4 Listenveränderung Jeder der schon einmal Elemente in eine doppelt verkettete Liste eingefügt oder aus ihr entfernt hat, weiß dass die Entwicklung des entsprechenden korrekten Quellcodes nicht ohne einen gewissen Aufwand möglich ist. Statt für zahllose Anwendung jeweils die gleiche Funktionalität neu zu entwickeln, lohnt sich hier der erhöhte Aufwand für eine allgemeine “abstrakte” Lösung schon. Sehen wir uns die Einfügoperation an. Zunächst eine Anwendung: for (ListenIterator i = l.anfang(); i != l.ende (); ++i) if (...) l.einHier (i, 10); // hier 10 in die Liste einfuegen l.einHier fügt in der Liste l vor der – durch den Iterator i angegebenen – aktuellen Position einen neuen Knoten mit dem Wert 10 ein. Die Implementierung: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 149 class Liste { ... void einHier (ListenIterator &, int); // Vor dem Iterator einfuegen ... private: Knoten *erster; Knoten *letzter; }; class ListenIterator { ... private: Liste &l; // Liste Liste::Knoten *pos; // aktuelle Position in der Liste }; void Liste::einHier (ListenIterator &iter, int p_wert) { if (iter.pos == 0) { // am Ende einEnde (p_wert); // also am Ende einfuegen } else { Neuer Knoten mit: Knoten * neu = new Knoten (p_wert, // Wert iter.pos, // Nachfolger iter.pos->v); // Vorgaenger if (iter.pos->v != 0) iter.pos->v->n = neu; // naechster-Verweis im Vorgaenger iter.pos->v = neu; // der Neue ist Vorgaenger des aktuellen if (iter.pos == erster) erster = neu; // am Anfang ist der neue der Anfang } } Graphisch dargestellt ist die Situation vor dem Einfügen der 10 (Siehe Abbildung 60): Liste Iterator erster Knoten 0 pos letzter 1 2 3 0 Abbildung 60: Liste und Iterator vor dem Einfügen von 10 und danach (Siehe Abbildung 61): 5.5.5 Die Liste und ihr Iterator sind Freunde Die Klasse Liste und ihr Iterator sind eng gekoppelt. Ein Iterator muss direkt in die Innereien der Liste greifen, beispielsweise um den Wert des aktuellen Knotens verändern. So etwas ist aber auch sinnvollerweise privat und somit vor dem Zugriff fremder Klassen geschützt. Die Liste muss den Iterator darum zu ihrem Freund erklären: class Liste { friend class ListenIterator; public: ... private: // zugreifbar fuer ListenIterator: class Knoten { Programmierung II 150 Liste Iterator erster Knoten 0 pos letzter 2 1 3 0 10 v wert n Abbildung 61: Liste und Iterator nach dem Einfügen von 10 public: Knoten (); Knoten (int, Knoten *, Knoten *); ˜Knoten (); Knoten * n; // naechster Knoten Knoten * v; // vorheriger Knoten int wert; }; Knoten *erster; Knoten *letzter; }; In unserem Beispiel muss die Freundschaft gegenseitig sein. Die Einfügoperation der Liste muss auf die (private) aktuelle Position des Iterators zugreifen. Die Liste ist darum auch ein Freund des Iterators: class ListenIterator { friend class Liste; public: ... private: // zugreifbar fuer die Liste Liste &l; // Liste in die der Iterator zeigt Liste::Knoten *pos; // aktuelle Position in dieser Liste }; Mit dem Konzept der Freundschaft sollte man möglichst sparsam umgehen. In diesem Fall ist sie gerechtfertigt: die Liste und ihr Iterator bilden eine konzeptionelle Einheit. Der Iterator existiert nicht, um ein Konzept mit eigener Schnittstelle und eigener Implementierung zu realisieren. Er ist eine Erweiterung und Ergänzung der Liste. Beide zusammen bieten eine bestimmte Funktionalität und hüten gemeinsame Implementierungsgeheimnisse. 5.6 5.6.1 Konstante Iteratoren Konstante Iteratoren Die Konstantheit von Objekten spielt in C++ eine große Rolle. Um mit Iteratoren auf konstante Listen zugreifen zu können, definieren wir noch die Klasse der konstanten Iteratoren ListenConstIterator. Mit einem konstanten Iterator kann man natürlich nur lesend auf die Listenelemente zugreifen: // lesen und schreiben: int & ListenIterator::operator* () { return pos->wert; } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg // nur lesen, darum keine Referenz: int ListenConstIterator::operator* () { return pos->wert; } 5.6.2 Die allgemeine Behälterklasse Liste Jetzt ist es an der Zeit die Dinge im Zusammenhang zu betrachten. Beginnen wir mit der Liste: class Liste { friend class ListenIterator; friend class ListenConstIterator; public: Liste (); ˜Liste (); Liste (const Liste &); Liste & operator= (const Liste &); Liste operator+ (const Liste &); void void void void einAnfang einEnde einHier loescheHier ListenIterator ListenIterator // Default-Konstruktor (leere Liste) // Kopier-Konstruktor // Zuweisung // Verkettung (int); // am Anfang einfuegen (int); // am Ende einfuegen (ListenIterator &, int); // Vor Iterator einfuegen (ListenIterator &); // An Iteratorposition loeschen anfang (); ende (); ListenConstIterator ListenConstIterator constAnfang () const; constEnde () const; private: class Knoten { public: Knoten (); Knoten (int, Knoten *, Knoten *); ˜Knoten (); Knoten * n; // naechster Knoten Knoten * v; // vorheriger Knoten int wert; }; Knoten *erster; Knoten *letzter; }; Die beiden Iteratoren sind: class ListenIterator { friend class Liste; public: ListenIterator (Liste &, Liste::Knoten *); int & operator* (); ListenIterator & operator++ (); bool operator== (const ListenIterator &) const; bool operator!= (const ListenIterator &) const; private: Liste &l; Liste::Knoten *pos; }; // Liste // aktuelle Position in der Liste 151 Programmierung II 152 class ListenConstIterator { friend class Liste; public: ListenConstIterator (const Liste int operator* ListenConstIterator & operator++ bool operator== bool operator!= private: const Liste &l; const Liste::Knoten *pos; }; 5.6.3 &, const Liste::Knoten *); (); (); (const ListenConstIterator &) const; (const ListenConstIterator &) const; // Liste // aktuelle Position in der Liste Die auf der Liste basierende Menge Die auf dieser Liste basierende Menge ist einfach: class Menge { public: Menge (); Menge (int); Menge (const Menge &); Menge & operator= // leere Menge, Default-Konstruktor // ein-elementige Menge // Kopierkonstruktor (const Menge &); // Zuweisungsoperator Menge operator+ (const Menge &) const; Menge operator* (const Menge &) const; bool istEnthalten (int i) const; // Vereinigung // Schnitt // Element enthalten private: Liste l; }; Die Implementierung der Klassen überlassen wir dem Leser als Übung. 5.7 Beispiel Syntaxbäume 5.7.1 Eine Grammatik definiert eine Menge von Zeichenketten Eine Grammatik definert eine Menge von Zeichenketten als zulässige “Ausdrücke” einer “Sprache”. Das Definitionsprinzip einer Grammatik ist die Induktion (Rekursion). Ausgehend von einer Basis elementarer Ausdrücke werden weitere mögliche Ausdrücke definiert. Beispielsweise können einfache arithmetische Ausdrücke mit folgender Grammatik mit drei Ableitungsregeln definiert werden: <Ausdruck> <WertAusdruck> <OpAusdruck> <Op> ::= ::= ::= ::= <WertAusdruck> | <OpAusdruck> <Zahl> ’(’ <Ausdruck> <Op> <Ausdruck> ’)’ ’+’ | ’-’ | ’*’ | ’/’ (Regel (Regel (Regel (Regel 0) 1) 2) 3) Die Basis der Ausdrücke sind die Zahlen. Jede Zahl ist ein Ausdruck (Regel 1). Aus zwei beliebigen Ausdrücken kann ein neuer mit Hilfe von zwei Klammern und einem Operator gebildet werden (Regel 2). Ein Operator ist ein Plus–, Minus–, Multiplikations– oder Divisionszeichen (Regel 3). Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 5.7.2 153 Ableitungsbäume zeigen die Struktur der Ausdrücke Eine Grammatik definiert nicht nur die Menge der zulässigen Zeichenketten, der “Ausdrücke”, sie gibt ihnen auch eine Struktur. Die Struktur eines Ausdrucks ist die Art in der er aus anderen Ausdrücken zusammengesetzt ist. Mit einem Ableitungsbaum kann diese Struktur dargestellt werden. Der Ableitungsbaum des Ausdrucks ((2+3)-4) wird beispielsweise in Abb. 62 dargestellt. <Ausdruck> ( ( <Ausdruck> <Ausdruck> <Op> <Op> <Ausdruck> <Zahl> <Zahl> 2 3 ) <Ausdruck> ) <Zahl> 4 Abbildung 62: Ableitungsbaum von ((2+3)-4) 5.7.3 Syntaxbäume: Gestraffte Ableitungsbäume zur Darstellung der Textstruktur Die Information in einem Ableitungsbaum kann ohne wesentlichen Verlust verdichtet werden. Die dabei entstehnende gestraffte Version nennt man abstrakten Syntaxbaum, weil von irrelevanten Details abstrahiert wird, oder kurz Syntaxbaum. In Abbildung 63 wird der Syntaxbaum zum Ausdruck ((2+3)-4) dargestellt. Man sieht, dass beispielsweise die Klammern wegfallen. In der Baumform sind sie zur Verdeutlichung der Struktur nicht notwendig und können wegfallen. Ausdruck Ausdruck 2 4 3 Abbildung 63: Syntaxbaum von ((2+3)-4) In vielen Fällen ist es vorteilhaft einen Text nicht direkt zu verarbeiten, sondern zuerst den Syntaxbaum zu bestimmen und an diesem dann erst die notwendigen Berechnungen auszuführen (siehe Abb. 64). In der ersten Phase wird die innere Struktur des Textes entsprechend der Grammatik bestimmt und in der zweiten Phase wird die Strukturdarstellung dann weiterverarbeitet. Als interne Darstellung der Textstruktur ist ein Syntaxbaum besser geeignet als ein Ableitungsbaum, da er die gleichen Informationen in übersichtlicherer Art enthält. 5.7.4 Syntaxbaum als Datenstruktur Syntaxbäume werden intern mit Hilfe von Zeigern gespeichert. Zur Darstellung unserer einfachen Ausdrücke könnte man beispielsweise den Typ Ausdruck definieren. Objekte vom Typ Ausdruck enthalten die Information über die Struktur eines arithmetischen Ausdrucks in Form von Zeigern: class Ausdruck { friend ostream & operator<< (ostream &, const Ausdruck &); friend istream & operator>> (istream &, Ausdruck &); public: Ausdruck (); Programmierung II 154 ((2+3)−4) einlesen und analysieren Ausdruck Ausdruck 2 4 3 verarbeiten (auswerten) 1 Abbildung 64: Zweiphasige Verarbeitung eines Textes Ausdruck (int); Ausdruck (char, const Ausdruck &, const Ausdruck &); Ausdruck (const Ausdruck &); Ausdruck & operator=(const Ausdruck &); ˜Ausdruck (); private: class Knoten; Knoten * ak; }; Der Eingabeoperator operator>> soll einen Ausdruck als Text einlesen und die entsprechende interne Darstellung aufbauen. Der Ausgabeoperator operator<< hat umgekeht die Aufgabe die interne Darstellung wieder in Text umzuwandeln. Konstruktoren, Zuweisungsoperator und Destruktor müssen definiert werden, da bei Klassen mit Zeigern die automatisch generierten Varianten in der Regel nicht korrekt sind. Die Klasse Ausdruck ist, wie weiter oben die Klasse Liste, nur die äussere Hülle der Datenstruktur. Die Daten selbst werden in internen Knoten gespeichert, die hier vom Typ Knoten sind: class Ausdruck::Knoten { public: Knoten (int); Knoten (char, Knoten *, Knoten *); ˜Knoten (); Knoten (const Knoten &); Knoten & operator= (const Knoten &); ostream & write (ostream &) const; private: enum Art {wertAusdr, opAusdr}; Art art; int wert; char op; Knoten *l; Knoten *r; }; Ausdrucksknoten haben keinen Defaultkonstruktor, da es keine sinnvolle Default–Belegung gibt. Die beiden Kon- Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 155 struktoren erlauben die Initialisierung eines Knotens entweder als Wert-Ausdruck (Blatt eines Ausdrucksbaums) oder als Operator-Ausdruck (innerer Knoten eines Ausdrucksbaums). Ausdrucksknoten erfüllen die gleiche Funktion wie Listenknoten. Sie haben darum einen äquivalenten Aufbau, allerdings mit zwei statt einem Nachfolger, geeignete Konstruktoren, einem Zuweisungsoperator und einem Destruktor. All dies kann wie die entsprechende Funktionalität der Liste definiert werden. Der Leseoperator wird problemlos rekursiv definiert. Wir verweisen dazu auf Teil 1 des Skripts. 5.7.5 Typinforamtion in den Knoten Ein Knoten des Syntaxbaums (vom Typ Knoten) enthält entweder einen Operator und die Verweise auf zwei Unterausdrücke oder eine Zahl als Wert. Bei einem Objekt der Klasse Knoten sind also entweder op, l und r mit sinnvollen Werten belegt oder aber wert. Damit stets klar ist, um welche Variante es sich handelt, wird art mit der entsprechenden Typinformation belegt. Hat art den Wert wertAusdr dann handelt es sich um einen Wertausdruck und wert ist (sinnvoll) belegt. Hat art den Wert opAusdr dann handelt es sich um einen Operatorausdruck und op, l und r haben (sinnvolle) Werte. Es ist17 nicht möglich zwischen zwei Arten von Knoten – z.B. Wert– und Operatorknoten – zu unterscheiden. Die Zeiger l und r in Knoten sowie ak in Ausdruck können nur auf Objekte mit einem ganz bestimmten festen Typ zeigen. Dieser muss dann die Informationen aller Varianten aufnehmen können. Das führt natürlich zu etwas Platzverschwendung. 5.7.6 Implemetierung der Methoden und Funktionen Zu den Methoden der beiden Klassen gibt es nichts weiter zu sagen, als dass mit Sorgfalt auf eine korrekte Implementierung speziell der Kopieroperationen (Zuweisungsoperator und Kopierkonstruktor) zu achten ist. Alle Kopien müssen tief sein, also den gesamten Ausdrucksbaum duplizieren. Wir definieren dazu einen rekursiven Kopierkonstruktor für Knoten und nutzen ihn an allen Stellen, an denen eine tiefe Kopie notwendig ist: // Undefinierter Ausdruck // Ausdruck::Ausdruck () : ak(0) {} // Wert-Ausdruck // Ausdruck::Ausdruck (int i) : ak(new Knoten(i)) {} // Operator-Ausdruck // tiefe Kopie der Komponenten von a1, a2 // mit Hilfe des Kopierkonstruktors von Knoten // Ausdruck::Ausdruck (char op, const Ausdruck & a1, const Ausdruck & a2) : ak(new Knoten(op, new Knoten(*(a1.ak)), new Knoten(*(a2.ak)) )) {} // Kopierkonstruktor // tiefe Kopie von a mit Hilfe des Kopierkonstruktors von Knoten // Ausdruck::Ausdruck (const Ausdruck &a) : ak( new Knoten(*(a.ak)) ) {} // new mit Kopierkonsruktor // Destruktor 17 ohne das Konzept der Vererbung 156 Ausdruck::˜Ausdruck () { delete ak; } // Zuweisung von Ausdruecken // tiefe Kopie von ak mit Hilfe des Kopierkonstruktors von Knoten // Ausdruck & Ausdruck::operator=(const Ausdruck &a) { if ( &a != this ) { delete (ak); ak = new Knoten(*(a.ak)); // new mit Kopierkonsruktor } return *this; } // Knoten eines Wert-Ausdrucks erzeugen // Ausdruck::Knoten::Knoten (int p_i) : art(wertAusdr), wert (p_i), // sinnvolle Werte op(’?’), l(0), r(0) // Fuell-Werte {} // Knoten eines Operator-Ausdrucks erzeugen (kopiert flach) // Ausdruck::Knoten::Knoten (char p_op, Knoten *p_l, Knoten *p_r) : art(opAusdr), wert(-999), // Fuell-Wert op(p_op), l(p_l), r(p_r) {} // Destruktor von Knoten // Ausdruck::Knoten::˜Knoten () { delete(l); delete(r); } // Kopierkonstruktor von Knoten // tiefe Kopie durch rekursiven Aufruf von sich selbst // Ausdruck::Knoten::Knoten (const Knoten &a) { art = a.art; op = a.op; wert = a.wert; if ( a.art == opAusdr) { l = new Knoten(*(a.l)); // rekursiver Aufruf r = new Knoten(*(a.r)); // rekursiver Aufruf } else { l = 0; r = 0; } } // Zuweisungsoperator von Knoten // tiefe Kopie mit Hilfe des Kopierkonstruktors // Ausdruck::Knoten & Ausdruck::Knoten::operator=(const Knoten &a) { if (&a != this) { // keine Zuweisung an sich selbst delete(l); l = 0; delete(r); r = 0; art = a.art; Programmierung II Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 157 op = a.op; if ( a.art == opAusdr) { l = new Knoten(*(a.l)); r = new Knoten(*(a.r)); } } return *this; } ostream & Ausdruck::Knoten::write(ostream & os) const { switch (art) { case wertAusdr: os << wert; break; case opAusdr: if ( (l==0 ) || (r==0) ){ cerr << "FEHLERHAFTE STRUKTUR\n"; exit (1); } os << ’(’; l->write(os); os << op; r->write(os); os << ’)’; break; default: cerr << "FEHLERHAFTE STRUKTUR art= " << art << "\n"; exit (1); } return os; } Der Ausgabeoperator für Ausdrücke ist trivial und der Eingabeoperator kann bei deren einfacher Struktur und bei Verzicht auf Fehlerprüfungen recht einfach rekursiv definiert werden: std::ostream & operator<< (std::ostream &os, const Ausdruck &a) { if ( a.ak == 0 ) return os<< "UNDEFINIERTER-AUSDRUCK"; else return a.ak->write(os); } std::istream & operator>> (std::istream & is, Ausdruck & a) { char z; Ausdruck a1, a2; char op; if (!(is >> z)) { cerr << "Eingabefehler\n"; exit (1); } switch (z){ case ’(’ : is >> a1; is >> op; is >> a2; a = Ausdruck (op, a1, a2); is >> z; if ( z == ’)’ ) return is; else cerr << "Ausdrucksfehler\n"; exit (1); case ’0’: case ’1’: case ’2’: case ’3’: case ’4’: case ’5’: case ’6’: case ’7’: case ’8’: case ’9’: a = Ausdruck (z-’0’); return is; Programmierung II 158 default: cerr << "Ausdrucksfehler\n"; exit (1); } } Diese Version des Eingabeoperators muss nicht einmal ein Freund sein. Er benutzt keinerlei Information über die innere Struktur der Ausdrücke. Das macht ihn sehr einfach, es hat aber auch seinen Preis. Dieser Eingabeoperator ist sehr ineffizient. Bei der Analyse eines Operatorausdrucks werden die Unterausdrücke a1 und a2 eingelesen und aus ihnen wird dann ein neuer Operatorausdruck gebildet. a1 und a2 werden erst aufgebaut, dann komplett (tief) kopiert und anschließend vernichtet (siehe Abbildung 65). a1 ak a2 a ak ak neuer Operator−Knoten Löschen durch Destruktor Kopieren Kopieren Abbildung 65: Neuen Ausdruck durch Kopieren erzeugen Da die Knotenstruktur von a1 und a2 nur dazu benutzt wird, um a aufzubauen, würde man besser flach kopieren und auf das Vernichten verzichten. Keinesfalls darf dazu aber das allgemeine Verhalten der Klassen Ausdruck und Knoten verändert werden, etwa indem generell aus einer tiefen eine flache Kopie gemacht wird. Der besonderen Situation im Leseoperator muss speziell angepasster Code gerecht werden: // effizienterer Eingabeoperator // std::istream & operator>> (std::istream & is, Ausdruck & a) { ... // STATT // a = Ausdruck (op, a1, a2); // JETZT // a zu Fuss belegen: a.ak = new Ausdruck::Knoten(op, a1.ak, a2.ak); a1.ak = 0; // <-- Destruktor "abschalten" a2.ak = 0; // <-- Destruktor "abschalten" ... } Der hier aufgerufene Konstruktor kopiert flach. (Er kann nicht anders, seine Argumente haben ja keinen Klassen– Typ.) Dadurch wird das sinnlose Duplizieren vermieden. Allerdings zeigen jetzt jeweils zwei Zeiger auf die selbe Struktur: der neue erzeugte Operator–Knoten mit seinem l– und seinem r–Zeiger und a1.ak bzw. a2.ak (Siehe Abbildung 66). Da a1 und a2 lokale Variablen sind, wird ihr Destruktor bald aktiv werden und die Knoten von a1 Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 159 und a2 und damit auch die des neu erzugten Ausdrucks wegräumen. Um das zu vermeiden setzen wir die Zeiger in a1 und a2 auf Null. a1 ak a2 ak a ak Verweise löschen neuer Operator−Knoten Abbildung 66: Neuen Ausdruck ohne Kopie erzeugen Programmierung II 160 5.8 Übungen Aufgabe 1 Erweitern Sie folgende Definition von S um einem korrekten Kopierkonstruktor, Zuweisungsoperator und Destruktor. Alle Kopien sollen tief sein. class S { public: S() : a(0), l(0) {} S(int p_a, S * p_l) : a(p_a), l(p_l) {} ˜S() { ????? } S (const S &) { ????? } S & operator= (const S &) { ????? } private: int a; S * l; }; Aufgabe 2 Eine verkettete Datenstruktur, wie etwa ein Stapel, kann auf verschiedene Arten dargestellt werden. Üblicherweise definiert man eine “Mutterklasse” Stapel und eine Hilfsklasse Knoten. Objekte der Klasse Stapel stellen Stapel dar und benutzen intern Objekte der Klasse Knoten zur Speicherung der einzelen Elemente. Objekte der Klasse Stapel können im Stack und im Heap angelegt werden. Knoten sind immer nur im Heap. class Stapel { public: Stapel(); ˜Stapel(); Stapel (const Stapel &); Stapel & operator= (const Stapel &); void push (int); void pop (); int top () const; bool leer () const; private: struct Knoten {...}; Knoten * k; }; Anfänger sind gelegentlich versucht alles in eine Klasse zu packen. Etwa wie folgt: class Stapel { public: Stapel(); ˜Stapel(); Stapel (const Stapel &); Stapel & operator= (const Stapel &); void push (int); void pop (); int top () const; bool leer () const; private: int a; Stapel * s; // <<---- !!! }; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 161 Definieren Sie die Methoden in den beiden Varianten. Erläutern Sie die Nachteile der zweiten Variante am Beispiel des leeren Stapels. Wie wird ein leerer Stapel dargestellt. Was passiert, wenn auf einem leeren Stapel etwas abgelegt wird, etc. Aufgabe 3 Ein sehr verbreiteter Bestandteil von Programmen beschäftigt sich mit der Verwaltung von Daten mit folgenden Operationen: Einfügen, Entfernen, Suchen: Nachsehen ob ein Datum vorhanden ist. Das zu speichernde Datum wird oft noch in ein Paar aus Schlüssel und Wert aufgeteilt. Der Schlüssel ist der Anteil, nach dem gesucht wird und der Wert ist das, an dem man interessiert ist. Ein Telefonbuch stellt beispielsweise eine solche Anwendung dar: Die Schlüssel sind die Namen und die Telefonnummern die Werte. Beide zusammen bilden ein Datum das gespeichert, gesucht und gelöscht wird. Man nennt eine solche Datenstruktur Abbildung (engl. Map): Sie stellt mathematisch eine partielle Abbildung der Schlüsselmenge auf die Wertemenge dar. Die Abbildung ist partiell, weil typischerweise nur ein (geringer) Teil der möglichen Schlüssel einen zugeordneten Wert hat. Ein Wörterbuch (engl. Dictionary) ist eine Sonderform der Abbildung, bei der das gesamte zu speichernde Datum Schlüssel ist. 1. Implementieren Sie einen konkreten Datentyp (unsortierte) Liste von Strings mit zugehörigem Iterator. Die Liste soll das Einfügen und Entfernen von Listenelementen an beliebiger Position ermöglichen. 2. Nutzen Sie Ihre Liste zur Implementierung eines Wörterbuchs mit zugehörigem Iterator. Das Wörterbuch ist sortiert, d.h. der Iterator durchläuft die Elemente in lexikographischer Ordnung. Die Liste wird vom Wörterbuch benutzt: class Dictionary { public: .... private: Liste l; ... }; 3. Zeichnen Sie ein Klassendiagramm zur Darstellung der Beziehung Ihrer vier Klassen (Liste und Wörterbuch jeweils mit ihren Iteratoren). Aufgabe 4 Warum erzeugt der Konstruktor (vergleiche Beispiel im Skript) Ausdruck::Ausdruck (char op, const Ausdruck & a1, const Ausdruck & a2) : ak(new Knoten(op, new Knoten(*(a1.ak)), new Knoten(*(a2.ak)) )) {} für Operator-Ausdrücke eine tiefe Kopie seiner Argumente, der Konstrutor Ausdruck::Knoten::Knoten (char p_op, Knoten *p_l, Knoten *p_r) : art(opAusdr), wert(-999), op(p_op), l(p_l), r(p_r) {} für Operator-Knoten jedoch nicht? Warum wird ein Unterschied gemacht? Programmierung II 162 Aufgabe 5 Schreiben Sie ein Programm das vollständig geklammerte Ausdrücke mit rationalen Zahlen (Brüchen) einliest und einen entsprechenden Syntaxbaum als interne Datenstruktur vom Typ Ausdruck aufbaut. Der Typ Ausdruck soll dabei wie folgt definiert werden: class Ausdruck { friend ostream & operator<< (ostream &, const Ausdruck &); friend istream & operator>> (istream &, Ausdruck &); public: Ausdruck (); Ausdruck (const Ausdruck &); Ausdruck & operator=(const Ausdruck &); ˜Ausdruck (); private: class AusdrKnoten; AusdrKnoten * ak; }; ostream & operator<< (ostream &, const Ausdruck &); istream & operator>> (istream &, Ausdruck &); operator>> liest Ausdrücke ein und baut die interne Datenstruktur auf. operator<< gibt die interne Darstellung in textueller Form wieder aus. Die Grammatik Ausdrücke ist: <Ausdruck> <WertAusdruck> <OpAusdruck> <Operator> ::= ::= ::= ::= <WertAusdruck> | <OpAusdruck> <RationaleZahl> ’(’ <Ausdruck> <Operator> <Ausdruck> ’)’ ’+’ | ’-’ | ’*’ | ’/’ Ausdrücke basieren auf rationalen Zahlen, deren textuelle Darstellung durch folgende Grammatik definiert wird: <RationaleZahl> <Vz> <Zahl> <Ziffer> ::= ::= ::= ::= ’[’ <Vz> <Zahl> ’/’ <Zahl> ’]’ ’+’ | ’-’ <Ziffer> | <Ziffer><Zahl> ’0’ | ’1’ | ’2’ | ’3’ | ’4’ | ’5’ | ’6’ | ’7’ | ’8’ | ’9’ Achten Sie darauf, dass Ausdruck als konkreter Datentyp definiert wird. Definieren Sie auch einen Datentyp Bruch zur Speicherung rationale Zahlen der die Verantwortung für Einlesen und Ausgeben der textuellen Darstellung von Brüchen übernimmt. Der Syntaxbaum soll nur die Struktur der Ausdrücke – nicht auch die der Brüche darlegen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 6 163 Schablonen (Templates) 6.1 6.1.1 Funktionsschablonen Funktionsschablone: Muster einer Funktion Manche Programmstücke sind sich sehr ähnlich. Beispielsweise unterscheidet sich eine Funktion, die das Maximum von zwei float–Zahlen berechnet, lediglich im Typ der Parameter von einer, die das Maximum von zwei ints berechnet. Eine Programmiererin, die beide Maximum–Funktionen realisieren will, wird oft erst die eine Variante schreiben und die zweite durch Kopieren und Modifizieren aus der ersten erzeugen. int max_i (int x, int y) { if (x > y) return x; else return y; } float max_f (float x, float y) { if (x > y) return x; else return y; } Diese Art der Programmerzeugung durch Kopieren und Modifizieren ist so elementar mechanisch, dass man sie dem C++–Compiler überlassen kann: Man gibt eine Schablone (ein Muster, engl. Template) der Funktion an, aus dieser kann der Compiler dann intern beliebig viele “sehr ähnliche” Programmstücke erzeugen. Nehmen wir als Beispiel eine Schablone, das alle Maximumfunktionen zusammenfasst: template <class T> // Maximun-Schablone mit Typ-Parameter T T max (T x, T y) { if (x > y) return x; else return y; } 6.1.2 Struktur einer Schablone Die Zeile template <class T> sagt, dass ein Template, also eine Schablone, folgt. Das Template hat einen Parameter T der hier ein Typ sein muss, erkennbar am Schlüsselwort class vor T. Der Rest des Beispiels ist das Funktionsmuster. Zusammen mit einem Typ kann es zu einer Funktion werden. Ersetzt man in der Definition der Schablone den Typparameter T durch einen wirklichen Typ, dann entsteht aus dem Funktionsmuster eine Funktion. So wie eine Funktion Werte (in runden Klammern) als Parameter hat, so hat dieses Template einen Typ (in spitzen Klammern) als Parameter. Das Template kann man als Kopier– / Editieranweisung an den Compiler verstehen: Kopiere es, ersetze T durch int, oder float oder einen anderen Typ und übersetze schließlich die sich ergebende Funktionsdefinition. In der Kopfzeile der Schablone steht zwar class T, aber der aktuelle Parameter für T muss nicht unbedingt eine Klasse sein, jeder Typ ist erlaubt. Statt class ist hier auch das im Prinzip passendere Schlüsselwort typename erlaubt. Manche Compiler verstehen das aber noch nicht und da außerdem class kürzer ist, bleiben wir bei ihm. template <typename T> // Schablone mit Typ-Parameter T T max (T x, T y) {...} // "typename" entspricht "class" 6.1.3 Schablone benutzen Eine Funktions–Schablone kann sehr einfach benutzt werden: template <class T> T max (T x, T y) { ... wie oben ... // Template Anfang Programmierung II 164 } // Template Ende int main () { int a = 1, b = 2; float m = 0.5, n = 0.4; // Benutzung der int--Variante von max: cout << "INT MAX: " << max (a, b) << endl; // Benutzung der float--Variante von max: cout << "FLOAT MAX: " << max (m, n) << endl; } An den Typen der aktuellen Parameter erkennt der Compiler, dass eine int– und eine float–Version von max benötigt werden. Er erzeugt sie und fügt den Aufruf der jeweiligen Variante ein. Der Vorteil liegt auf der Hand. Statt zwei nahezu identischen Funktionen muss nur noch eine einzige erzeugt und gepflegt werden. 6.1.4 Instanzierung: Schablone + Parameter = Funktion Man beachte, dass hier zwei völlig unterschiedliche Parameterübergaben stattfinden: Eine zur Übersetzungszeit und eine zur Laufzeit: Instanzierung der Schablone: Während der Übersetzung werden Typen an das Template übergeben und Funktionen erzeugt. Aufruf der Funktion: Zur Laufzeit werden Werte an die Funktionen übergeben und andere Werte erzeugt. Im Detail: Während der Übersetzung von max (a, b) erkennt der Compiler dass eine int–Version von max benötigt wird. Er übergibt darum den Typ int an einen internen Mechanismus zur Generierung von Funktionen aus Templates. Diesen Mechnismus hat der Programmierer mit dem Template beschrieben. Der Compiler führt ihn jetzt mit dem aktuellen Parameter int aus und erzeugt so etwas wie (eine Übersetzung von): int max (int x, int y) { if (x > y) return x; else return y; } Diese int–Version von max ist eine Instanz des Templates. Der Prozess ihrer Erzeugung wird Instanzierung (engl. instantiation) genannt. Zur Laufzeit werden dann in bekannter Art die Werte 1 und 2 an die Instanz (eine normale Funktion) übergeben. ... max (a, b) ... | +<-- int | | Compiler: Instanzierung V int-Variante (Instanz) von max | | Compiler: Codegenerierung V Maschinen-Code der int-Variante | +<-- 1, 2 | | Prozessor: Code ausfuehren Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 165 V Maximum von 1, 2 6.1.5 Parameter einer Schablone Eine Schablone kann zwei Arten von Parametern haben: Typ–Parameter werden durch das Schlüsselwort class bzw. typename gekennzeichnet; Nicht–Typ–Parameter sehen wie die formalen Parameter einer Funktion aus. Eine Schablone kann beliebig viele Parameter haben. Ein Beispiel mit beiden Arten von Parametern ist: template<class T, int size> void sort (T (&a)[size]) { for (int i=0; i<size; ++i) for (int j=i+1; j<size; ++j) if (a[j] > a[i]) swap (a[i], a[j]); } Die Schablone hat zwei Parameter: einen Typ und einen int–Wert. Die Funktion sort hat einen Parameter, ein Feld mit der Größe size. Die aktuellen Parameter für T und size werden zur Übersetzungszeit für jeden Aufruf einzeln festgestellt: template<class T> void swap (T &x, T &y) { T t = x; x = y; y = t; } template<class T, int size> void sort (T (&a)[size]) { .. wie oben .. } int main () { int a1[10] float a2[5] ... sort (a1); // sort (a2); // ... } 6.1.6 = {6,5,5,6,2,4,9,5,3,1}; = {0.1, 6.2, 9.5, 9.1, 0.9}; Uebersetzungszeit: T <- int, size <- 10, Laufzeit: a <- a1 Uebersetzungszeit: T <- float, size <- 5, Laufzeit: a <- a2 Explizit angegebene Schablonenparameter Die Parameter, die der Compiler bei der Erzeugung einer Instanz einsetzt, leitet er aus den Funktionsargumenten ab. Er analysiert dazu die Aufrufstelle und versucht nach Möglichkeit einen passenden aktuellen Templateparameter zu identifizieren. Will oder kann man sich auf diese eingebaute Intelligenz des Compilers nicht verlassen, dann können Templateparameter auch explizit angegeben werden: template <class T> T max (T x, T y) { ... wie oben ... } int main () { Programmierung II 166 int a = 1, b = 2; float m = 0.5, n = 0.4; cout << max<int> (a, b) << endl; //expliziter Typparameter int an max cout << max<float> (m, n) << endl; //expliziter Typparameter float an max } max<int> bezeichnet hier die int–Spezialisierung der Schablone max, die durch Angabe des Parameters int in spitzen Klammern hinter max definiert wird. Wenn die Funktionsparameter eine eindeutige Ableitung der Schablonenparameter nicht erlauben, dann müssen sie explizit angegeben werden. Beispiel: template<class T> T max(T x, T y) { if (x>y) return x; else return y; } int main () { int i = 1; float f = 1.0; cout << max (i, f) << endl; // FEHLER: nicht uebersetzbar } Hier ist der Compiler nicht in der Lage selbständig die richtige Spezialisierung dee Schablone zu finden, sie muss explizit angegeben werden: template<class T> T max(T x, T y) { if (x>y) return x; else return y; } int main () { int i = 1; float f = 1.0; cout << max<float> (i, f) << endl; // OK } 6.1.7 Schablonen– und Funktionsparameter nicht verwechseln Typen (Klassen) können nur als Schablonenparameter übergeben werden. Alles andere kann sowohl als Schablonen–, als auch als Funktionsparameter übergeben werden. Vergleichen wir dazu die folgenden beiden Sortierschablonen: template<class T, int size> void sort_1 (T (&a)[size]) { ... } template<class T> void sort_2 (T a[], int size) { ... } Die Feldgröße ist bei sort 1 ein Schablonenparameter, bei sort 2 ist sie Funktionsparameter. Die unterschiedliche Art der Parameterübergabe erkennt man am Aufruf: int float ... sort_1 sort_1 ... sort_2 sort_2 a1[10] = ...; a2[5] = ...; (a1); (a2); // implizite Uebergabe der Feldgroesse zur Uebersetzungszeit (a1, 10); (a2, 5); // explizite Uebergabe der Feldgroesse zur Laufzeit Bei sort 1 stellt der Compiler die aktuellen Schablonenparameter für T und size fest (int und 10, bzw. float und 5), erzeugt die zwei Funktionen Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 167 sort 1<int, 10> sort 1<float, 5> die dann mit den Parametern a1 bzw. a2 aufgerufen werden. Bei sort 2 stellt der Compiler den aktuellen Schablonenparameter für T fest fest (int, bzw. float), erzeugt die zwei Funktionen sort 1<int> sort 1<float> die dann mit den Parametern a1,10 bzw. a2,5 aufgerufen werden. Die Parameterübergabe zur Laufzeit – d.h. Funktions– statt Schablonenparameter – ist stets etwas flexibler: ... int * p = new int [n]; ... sort_1 (p); // FEHLER: GEHT NICHT sort_2 (p, n); // OK In diesem Beispiel ist die Feldgröße erst zur Laufzeit bekannt, sie kann darum kein Schablonenparameter sein. 6.1.8 Schablonen und die Referenzübergabe von Feldern In template<class T, int size> void sort 1 (T (&a)[size]).. ist size ein Schablonenparameter. In template<class T> void sort 3 (T (&a)[size]).. ist size weder Schablonen– noch Funktionsparameter. Es ist entweder eine globale Konstante oder die Definition ist fehlerhaft: template<class T> void sort_3 (T (&a)[size]) { ... } // FEHLER: size nicht definiert! Wir können size natürlich definieren, erhalten aber dann eine sehr unflexible Funktionsschablone: const int size = 10; template<class T> void sort_3 (T (&a)[size]) { ... } int main () { int a1[10] = ... ; float a2[5] = ... ; sort_3 (a1); // OK sort_3 (a2); // FEHLER, size stimmt nicht } 6.1.9 Anwendung: Sortieren Aktionen wie Suchen und Sortieren sind generell unabhängig von der genauen Art dessen, was da gesucht oder sortiert wird. Elemente sehr vieler Typen kann man mit den gleichen Mechanismen suchen und sortieren. Sie müssen nur miteinander verglichen werden können. Wenn die Elemente eines Typs T verglichen werden können, dann können sie auch sortiert und gesucht werden. Das sind ideale Voraussetzungen zum Einsatz von Schablonen. Programmierung II 168 Betrachten wir eine einfache Sortier–Schablone: template<class T, int size> void sort (T (&a)[size]) { for (int i=0; i<size; ++i) for (int j=i+1; j<size; ++j) if (a[j] > a[i]) swap (a[i], a[j]); } Diese Schablone ist nur benutzbar, wenn Elemente des Typs T mit > verglichen werden können, und natürlich auch nur wenn wir aufsteigend sortieren wollen. 6.1.10 Die Sortierrichtung als Parameter Man kann die Vergleichsoperation zu einem weiteren Parameter machen, um so die Schablone sowohl zum auf– wie zu absteigenden Sortieren benutzen zu können. Etwa wie folgt: template<class T> void swap (T &x, T &y) { T t = x; x = y; y = t; } template<class T, int size, bool compare (T, T)> void sort (T (&a)[size]) { for (int i=0; i<size; ++i) for (int j=i+1; j<size; ++j) if (compare (a[j], a[i])) swap (a[i], a[j]); } template<class T> // Vergleich groesser bool gt (T x, T y) { return x > y; } template<class T> // Vergleich kleiner bool lt (T x, T y) { return x < y; } int main () { int a1[10] = {6,5,5,6,2,4,9,5,3,1}; float a2[5] = {0.1, 6.2, 9.5, 9.1, 0.9}; sort<int, 10, gt<int> > (a1); // a1 absteigend sortieren sort<float,5,lt<float> > (a2); // a2 aufsteigend sortieren // ˆ // Achtung, Leerzeichen beachten ! ... } sort muss hier explizit spezialisiert werden. Der Compiler kann die Sortierrichtung ja nicht erraten. Als Alternative bietet sich an, die Vergleichfunktion nicht an die Schablone sondern an deren Instanz – die erzeugte Funktion – als Funktionsparameter (Zeiger auf eine Funktion) zu übergeben: template<class T> void swap (T &x, T &y) { T t = x; x = y; y = t; } template<class T, int size> void sort (T (&a)[size], bool (*compare) (T, T)) { for (int i=0; i<size; ++i) for (int j=i+1; j<size; ++j) if (compare (a[j], a[i])) Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 169 swap (a[i], a[j]); } template<class T> bool gt (T x, T y) { return x > y; } template<class T> bool lt (T x, T y) { return x < y; } int main () { int a1[10] = {6,5,5,6,2,4,9,5,3,1}; float a2[5] = {0.1, 6.2, 9.5, 9.1, 0.9}; sort<int,10> (a1, &gt<int>); // a1 absteigend sortieren sort<float,5> (a2, &lt<float>); // a2 aufsteigend sortieren ... } In dieser Version werden die Vergleichsfunktionen wieder zur Übersetzungszeit als Instanzen der beiden Schablonen erzeugt, aber im Gegensatz zu oben erst zur Laufzeit an die Sortierfunktion übergeben. Auf die explizite Spezialisierung von sort mit aktuellen Schablonenparametern kann hier verzichtet werden. Das vereinfacht den Aufruf zu: ... sort(a1, &gt<int>); // a1 absteigend sortieren sort(a2, &lt<float>); // a2 aufsteigend sortieren ... 6.1.11 Klassen als Schabloneparameter Typparameter einer Schablone werden mit dem Schlüsselwort class gekennzeichnet. Trotz dieses Schlüsselworts können die übergebenen Typen Klassen sein, sie müssen es aber nicht sein. In den Beispielen bisher ist sogar nicht ein einziges Mal tatsächlich eine Klasse bei der Instanzierung einer Schablonen mit Typparameter eingesetzt worden. Es spricht aber nichts dagegen beispielsweise Strings zu sortieren: ... string a1[5] = {"abc","xyz","123","UVW","MAEH"}; sort(a1, &gt<string>); ... 6.1.12 Teilweise explizite Spezialisierung einer Schablone Der Compiler muss entweder in der Lage sein die Schablonen–Argumente aus der Verwendungsstelle zu schließen, oder die Programmiererin muss sie explizit angeben. In vielen Fällen kann ein Teil der Argumente vom Compiler selbständig festgestellt werden, ein anderer nicht. Die Sortierung bietet wieder ein Beispiel: ... template<int size, class T> void sort (T a[]) { ... } int main () { int *p = new int[10]; ... sort (p); // FEHLER Compiler kann den // Templateparameter nicht bestimmen sort<10, int> (p); // OK, vollstaendige Spezialisierung Programmierung II 170 sort<10> ... (p); // OK, teilweise Spezialisierung } Hier kann der Typ int vom Compiler abgeleitet werden, die Feldgröße des aktuellen Parameters dagegen nicht. In einer Spezialisierung können die Argumente der Schablone weggelassen werden, die der Compiler selbst feststellen kann. Sie müssen allerdings den Rest der Liste ausmachen: ... template<class T, int size> // falsche Reihenfolge void sort (T a[]) { ... } ... sort<int, 10> (p); // OK, vollstaendige Spezialisierung sort<10> (p); // FEHLER: teilweise Spezialisierung nicht moeglich ... T a[] ist genau wie T a[size] äquivalent zu T *a. Alle drei Typen beinhalten nicht die Feldgröße. Im Gegensatz dazu ist die Feldgröße Bestandteil des Parameters, wenn das Feld per Referenz übergeben wird. Der Compiler kann dann aus dem aktuellen Parameter den Schablonenparameter size ableiten. 6.2 6.2.1 Klassenschablonen Deklaration und Verwendung eines Klassenschablone Schablonen können auch benutzt werden, um Muster von Klassendefinitionen zu erzeugen. Ein einfaches Beispiel ist: template<int size, class Elem> class Stack { public: Stack(); void push (Elem e); void pop (Elem &e); private: Elem space [size]; int index; }; Diese Schablone definiert ein Muster für die Erzeugung von Stapeln mit unterschiedlichem Elementtyp und von unterschiedlicher maximaler Länge. Im Gegensatz zu Spezialisierungen von Funktionsschablonen, die der Compiler meist völlig selbständig erzeugen kann, müssen Spezialisierungen von Klassenschablone durch den Programmierer immer explizit angegeben werden: ... Stack<10,int> int_stack; // Stack mit maximal 10 ints Stack<20,float> float_stack; // Stack mit maximal 20 floats ... int_stack.push (1); float_stack.push (2.6); .. Hier bezeichnen Stack<10,int> und Stack<20,float> jeweils einen Typ. Es sind Instanzierungen der Schablone Stack mit den aktuellen Parametern 10 und int bzw. 20 und float. Die Zeile Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 171 Stack<10,int> int stack; ist eine Variablendefinition und int stack ist eine Variable mit einem anonymen (namenlosen) Typ. Man hätte ihm auch einen Namen geben können, z.B. IntStack: ... // IntStack = Typ der Stacks mit maximal 10 ints: typedef Stack<10,int> IntStack; // eine Variable vom Typ IntStack: IntStack int_stack; ... int_stack.push (1); .. 6.2.2 Definition der Methoden einer Klassenschablone Die Methodendefinitionen sehen – wenig überraschend – den Funktionsschablonen sehr ähnlich: template<int size, class Elem> // Stack<size, Elem>::Stack (): index(-1){} // // Konstruktor // "Stack<size, Elem>" bezeichnet die Klasse zu der der Konstruktor mit Namen "Stack" gehoert template<int size, class Elem> // Einfuegen void Stack<size, Elem>::push (Elem e){ if (index < size){ index += 1; space[index] = e; } } template<int size, class Elem> // Entnehmen void Stack<size, Elem>::pop (Elem &e){ if (index >= 0){ e = space[index]; index -= 1; } } 6.2.3 Eine Vektorschablone Als weiteres Beispiel für ein Klassentemplate definieren wir eine Vektorschablone mit der Vektoren mit bliebigen Elementtypen und beliebiger Größe (Dimension) erzeugt werden können: //-Deklaration------------------template <class T, int dim> class Vektor { public: Vektor (); Vektor (T (&x)[dim]); Vektor operator+ (Vektor); private: T a[dim]; }; //-Verwendung-------------------int main () { Programmierung II 172 int x[3] = {1, 2, 3}; int y[3] = {-1, -2, -3}; float u[2] = {1.5, 2.5}; float t[2] = {-1.2, -2.2}; Vektor<int,3> v1(x), // 3 int-Vektoren der Laenge 3 v2(y), // jweils durch ihren Konstruktor v3; // initialisiert Vektor<float,2> w1(u), w2(t), w3; v3 = v1 + v2; w3 = w1 + w2; } //-Definition-der-Methoden------template<class T, int dim> Vektor<T, dim>::Vektor () { for (int i=0; i<dim; i++) a[i] = 0; } template<class T, int dim> Vektor<T, dim>::Vektor (T (&x)[dim]) { for (int i=0; i<dim; i++) a[i] = x[i]; } template<class T, int dim> Vektor<T, dim> Vektor<T, dim>::operator+ (Vektor v) { Vektor res; for (int i=0; i<dim; i++) res.a[i] = a[i] + v.a[i]; return res; } 6.2.4 Eine einfache Listenschablone Ein Kennzeichen für Behälterklassen ist die weitgehende Unabhängigkeit ihrer Methoden von den enthaltenen Komponenten. Eine int–Liste unterscheidet sich kaum von einer float oder string–Liste. Schablonen sind darum bestens geeignet Behälterklassen, wie Listen, Mengen, Abbildungen etc. zu realisieren. Schablonen sollte man generell zuerst in einer Nicht–Schablonen–Version entwickeln und testen und sie danach dann in eine Schablone umsetzen. Im Wesentlichen besteht diese Umsetzung darin, den konkreten Typ der Beispielspezialisierung durch den Typparameter der Schablone zu ersetzen. Mit dieser Methode ist eine einfache Listenschablone schnell aus einem int–Beispiel erzeugt. Zuerst die Klassendefinition: template <class T> class List { public: List (); ˜List (); List (const List &); List & operator= void insert (T void erase (T void eraseAll (T int isIn (T private: class Node { public: Node (); (const List &); i); i); i); i); Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 173 Node (T, Node *); ˜Node (); T v; Node * next; }; Node *head; void erase_r (T i, Node * &); }; Die Definition der Methoden bietet ebenfalls keine besonderen Probleme oder Überraschungen: template<class T> List<T>::Node::Node () : next(0){} template<class T> List<T>::Node::Node (T i, Node * n) : v(i), next(n){} template<class T> List<T>::Node::˜Node () { delete next; } template<class T> List<T>::List () : head (0) {} template<class T> List<T>::˜List () { delete head; } template <class T> List<T>::List (const List &p_l) { Node **l = &head, *n; for(Node * p = p_l.head; p != 0; p = p->next){ n = new Node (p->v, 0); *l = n; l = &(n->next); } } template <class T> List<T> & List<T>::operator= (const List &p_l) { if (head != p_l.head) { delete head; head = 0; Node **l = &head, *n; for(Node * p = p_l.head; p != 0; p = p->next){ n = new Node (p->v, 0); *l = n; l = &(n->next); } } return *this; } ... etc. ... Man beachte, dass die Schablone hier eine geschachtelte Klasse Node enthält sowie die Syntax, mit der die Methoden der geschachtelten Klasse definiert werden. In template<class T> List<T>::Node::Node () : next(0) bezeichnet beispielsweise List<T>::Node den Typ Node innerhalb der Liste List<T>. 6.2.5 Initialisierung in Schablonen Die Initialisierung von Objekten mit einem Parametertyp ist mit Vorsicht zu behandeln. Wird beispielseweise eine Integer–Version der Liste mit folgendem Konstruktor List::Node::Node () : v(0), next(0) //OK erfolgreich getestet und man wandelt das ganze dann in eine Schablone um: template<class T> List<T>::Node::Node (): v(0), next(0) //SCHLECHT Programmierung II 174 werden sich unter Umständen schwer zu lokalisierende Fehler einstellen. Der Grund ist die explizite Initialisierung der v–Komponente mit 0. Bei einem Integer macht das Sinn. Für beliebige andere Typen ist dieser Konstruktoraufruf nicht unbedingt sinnvoll. Man überläßt die Initialisierung darum besser dem Default–Konstruktor: template<class T> List<T>::Node::Node (): next(0) 6.2.6 //OK Schablone in UML Eine Klassenschablone Stack mit Parameter T und size template>class T, int size> class Stack { ... }; wird in UML änhnlich wie eine Klasse dargestellt (siehe Abbildung 67) T size:int Stack Abbildung 67: Ein Template in UML Das Schablonen– (Template–) Symbol kann wie bei einer Klasse mit weiteren Informationen über Attribute und Methoden ausgestattet werden. 6.3 6.3.1 Schablone mit Freunden Arten von Schablone–Freunden Bei der Freundschaft in Zusammenhang mit Schablone gilt stets, dass Freundschaft immer eine Beziehung zwischen Klassen oder zwischen Klassen und Funktionen und nicht zwischen Schablonen ist. Wird in einer Klassenschablone eine Freundschaft zu einer Klasse oder einer anderen Schablone erklärt, dann bezieht sich dies letztlich immer auf Instanzen der Schablone. Schablonen können auf verschiedene Arten Freundschaften erklären: Freunde die selbst keine Schablone sind: Eine Klasse ist ist Freund jeder Instanz. Gebundene Schablonen–Freundschaft: Instanzen einer Klassenschablone sind befreundet, wenn sie mit den gleichen Parametern instanziiert wurden. Ungebundene Schablonen–Freundschaft: Instanzen einer Klassenschablone sind befreundet, egal mit welchen Parametern sie instanziiert wurden. Die in der Praxis wichtigste Art der Freundschaft ist die gebundene Freundschaft von Schablonen. Neben Klassen können auch Funktionen Freunde einer Klasse sein. Dabei gelten die gleichen Regeln. Wir erörtern darum nur die Klassenschablonen als Freunde ausführlich. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 6.3.2 175 Freunde die keine Schablonen sind Im einfachsten Fall erklärt die Schablone einer bestimmten Klasse oder Funktion die Freundschaft. Der Freund einer Schablone selbst keine Schablone. Betrachten wir dazu ein Beispiel: template <class T> class Vektor { friend class C; // ein Nicht-Template Freund, die Klasse C public: Vektor (); Vektor (T, T); private: T x, y; int z; // nicht vom Template-Parameter abhaengig }; typedef Vektor<int> VI; // Zwei Instanzen typedef Vektor<float> VF; // C ist ein Freund jeder Instanz VI vgi (0, 0); VF vgf; // Variablen vom Typ // der Instanzen struct C { static void spion (); }; // Der Freund // aller Vektoren void C::spion () { cout << vgi.z << endl; cout << vgf.z << endl; } // C hat Zugriff auf jede Instanz // kann aber mit x und y nichts anfangen // da deren Typ Templateparamenter ist. Die Klasse C ist ein Freund aller Instanzen von Vektor. Sie hat Zugriff auf alle Komponenten aller Instanzen. Das sieht auf den ersten Blick wie ein großer Vorteil aus, bringt aber tatsächlich nicht sehr viel. Die Klasse C kann nichts von Vektor benutzen, das irgendwie vom Parameter T abhängt. Die Zahl der sinnvollen Anwendungen dieser Art von Freundschaft ist darum auch eher begrenzt. Als Beispiel für den Einsatz der Nicht-Schablonen–Freundschaft könnte man eine Klasse definieren, die die Zahl der Objekte von verschiedenen Instanzen einer Klassenschablone zählt. Dies ist auch gleichzeitig ein Beispiel für statische Mitglieder einer Schablone: template<class T> class Vektor { friend class F; // F ist Freund all meiner Instanzen! public: Vektor (); T x,y; private: static int vZaehler; }; template<class T> Vektor<T>::Vektor () { vZaehler++; } template<class T> int Vektor<T>::vZaehler = 0; typedef Vektor<int> VI; typedef Vektor<float> VF; Programmierung II 176 class F { // Der Freund der Vektoren public: // stellt fest wie viele es gibt void vektoren (); }; void F::vektoren() { cout << "int Vektoren: " << VI::vZaehler << endl; cout << "float Vektoren: " << VF::vZaehler << endl; } int main () { VI vi_1, vi_2, vi_3, vi_4, vi_5; VF vf_1, vf_2, vf_3, vf_4; F f; f.vektoren(); } Jede Instanz der Klassenschablone Vektor hat ihr eigenes Exemplar der statischen Datenkomponente vZaehler. F ist ein Freund und kann darum jede dieser statischen Komponenten lesen. Das Programm erzeugt darum die Ausgabe: int Vektoren: 5 float Vektoren: 4 6.3.3 Gebundene Schablonenfreunde Freunde von Schablonen sind in der Regel selbst Schablonen. Im Fall eines gebundenen Schablonen–Freundes ist jeder Instanz der Schablone eine Instanz des Freundes zugeordnet. Betrachten wir ein Beispiel: #include <iostream> template<class TT>class C; // Deklaration von C, dem Freund template <class T> class Vektor { friend class C<T>; // C muss deklariert sein! public: // Jede Instanz von C mit dem gleichen T Vektor (); // ist mein Freund! Vektor (T, T); private: T x, y; }; //------------------------------template<class T> // Definition von C, struct C { // dem Freund-Template void spion (Vektor<T>); }; //------------------------------template<class T> void C<T>::spion (Vektor<T> v) { cout << v.x << endl; // Der Freund hat cout << v.y << endl; // Zugriff auf Komponenten vom Typ T } //------------------------------int main () { typedef Vektor<int> V_I; typedef C<int> C_I; // ein Freund von VI typedef C<float> C_F; // KEIN Freund von VI Th Letschert, Fachbereich MNI, FH Giessen–Friedberg V_I C_I ci.spion(vi); 177 vi; ci; } //------------------------------template<class T> Vektor<T>::Vektor () : x(0), y(0) {} template<class T> Vektor<T>::Vektor (T p1, T p2) : x(p1), y(p2) {} Hier sind nur “passende” Instanzen befreundet, d.h solche die mit den gleichen Argumenten erzeugt wurden. Man erkennt dies daran, dass innerhalb von template<class T> class Vektor friend class C<T>; ... ; mit: friend class C<T>; gesagt wird, dass (nur) die Spezialisierung von C mit dem aktuellen T ein Freund ist. Diese Art der Schablonen– Freundschaft ist sicherlich die im praktischen Einsatz wichtigste Variante. 6.3.4 Ungebundene Schablonenfreunde Bei der ungebundenen Schablonenfreundschaft werden alle Instanzen einer anderen Schablone zu Freunden aller Instanzen dieses Schablone erklärt. Beispiel: #include <iostream> template <class T> class Vektor { template<class TT> friend class C; public: Vektor (); Vektor (T, T); private: T x, y; }; // Alle Instanzen von C // sind Freunde all meiner Instanzen! //------------------------------typedef Vektor<int> V_I; // Zwei Instanzen typedef Vektor<char> V_C; V_I vi; V_C vc; //------------------------------template<class T> // Alle Instanzen von C sind Freunde struct C { // aller Instanzen von Vektor void spion (); }; template<class T> // Zugriff auf alle Sorten von Vektoren void C<T>::spion () { cout << vi.x << endl; cout << vi.y << endl; cout << vc.x << endl; cout << vc.y << endl; } typedef C<int> C_I; // C_I ist ein Freund von V_I und V_C typedef C<float> C_F; // C_F ist AUCH ein Freund von V_I und V_C ! 178 Programmierung II //------------------------------int main () { C_I ci; C_F cf; ci.spion(); cf.spion(); } //------------------------------template<class T> Vektor<T>::Vektor () : x(0), y(0) {} template<class T> Vektor<T>::Vektor (T p1, T p2) : x(p1), y(p2) {} Hier sind alle Instanzen von C Freunde aller Instanzen von Vektor. An der etwas bemühten Konstruktion sieht man, dass sich die praktische Bedeutung dieser Art der Freundschaft in Grenzen hält. Der Schablonen–Parameter in C wird gar nicht benutzt, das Beispiel wird damit zu einer Variante der Freundschaft mit einer Nicht–Template–Klasse. Im Endeffekt müssen die Schablonen–Argumente von Vektor und C entweder ohne Bezug oder gleich sein. Hier sind sie ohne Bezug. Wenn sie gleich sind, wie in folgendem Beispiel: #include <iostream> template <class T> class Vektor { template<class TT> friend class C; public: Vektor (); Vektor (T, T); private: T x, y; }; //------------------------------template<class T> // Definition von C, struct C { // dem Freund-Template void spion (Vektor<T>); // alle Instanzen sind Freunde }; // aller Instanzen von Vektor //------------------------------template<class T> void C<T>::spion (Vektor<T> v) { cout << v.x << endl; cout << v.y << endl; } //------------------------------int main () { typedef Vektor<int> V_I; typedef C<int> C_I; // ein Freund von VI typedef C<float> C_F; // AUCH ein Freund von VI V_I vi; C_I ci; C_F cf; ci.spion(vi); // OK cf.spion(vi); // NICHT OK: // cf ist zwar ein Exemplar einer befreundeten // Klasse, kann aber nicht aktiv werden, da // die Parameter-Typen NICHT PASSEN ! } //------------------------------template<class T> Vektor<T>::Vektor () : x(0), y(0) {} template<class T> Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 179 Vektor<T>::Vektor (T p1, T p2) : x(p1), y(p2) {} dann verwendet man besser die einfachere gebundene Version der Schablonen–Freundschaft. So wie wir es oben getan haben. 6.3.5 Funktionsschablone als Freunde einer Klassenschablone Klassenschablonen können auch Funktionsschablonen als Freunde haben. Hier gibt es die gleichen Varianten mit den gleichen Regeln wie bei der Freundschaft von Klassenschablonen. Der Standardfall ist auch hier die gebundene Freundschaft von Schablonen. Beispiel: template<class T> class Vektor { friend Vektor operator+ <T> (const Vektor &, const Vektor &); public: Vektor (); Vektor (T, T); ... private: T x, y; }; ... template<class T> Vektor<T> operator+ (const Vektor<T> &v1, const Vektor<T> &v2) { return Vektor<T> (v1.x+v2.x, v1.y+v2.y); } Oder, als weiters Beispiel, eine Listenschablone mit Ausgabefunktion: template <class T> class List { friend ostream & operator<< <T> (ostream &, const List<T> &); public: List (); ... private: class Node { public: ... T v; Node * next; }; Node *head; }; ... template <class T> ostream & operator<< (ostream &os, const List<T> &l) { for(List<T>::Node * p = l.head; p != 0; p = p->next) os << p->v << ", "; return os; } 6.4 6.4.1 Datenstrukturen und Algorithmen mit Schablonen Sortieren mit Quicksort Übliche Algorithmen können leicht in Funktionsschablonen umgewandelt werden. Beispielsweise Quicksort: Programmierung II 180 template<class T> void swap (T &x, T &y) { T t = x; x = y; y = t; } // Quicksort // sortiert Elemente vom Typ E mit Schluessel key // template<class E> void quicksort (E a[], int l, int r) { int i, j; E x; if (l>=r) return; i=l; j=r; x=a [(l+r)/2]; while (i<=j) { while ( a[i].key < x.key ) ++i; while ( a[j].key > x.key ) --j; if (i<=j) { swap (a[i], a[j]); ++i; --j; } } quicksort (a, l, j); quicksort (a, i, r); } struct Elem { // ein Beispiel zum Test int key; int val; }; int main () { Elem array[10] = {{6,6},{5,5},{4,4},{1,1},{2,2},{0,0},{6,6},{9,9},{7,7},{6,6}}; quicksort (array, 0, 9); ... } Der Algorithmus sortiert ein Feld von Elementen, die aus einem Schlüssel und einem Wertfeld bestehen. Der Typ des Schlüssels ist nur insofern relevant, als dass auf ihm die Vergleichsoperationen definiert sein müssen. Der Typ der Werte ist völlig irrelevant. 6.4.2 Klassendefinition der Listenschablone Die Listenschablone hat einen Parameter, den Typ der Elemente. Ihre Freunde sind die beiden Iteratoren. template<class E> class ListenIterator; // gebundene Templatefreunde template<class E> class ListenConstIterator; // muessen Voraus deklariert // werden template<class E> class Liste { friend class ListenIterator<E>; friend class ListenConstIterator<E>; public: Liste (); // Default-Konstruktor (leere Liste) ˜Liste (); Liste (const Liste &); // Kopier-Konstruktor Liste & operator= (const Liste &); Liste operator+ (const Liste &); // Zuweisung // Verkettung Th Letschert, Fachbereich MNI, FH Giessen–Friedberg void void void void einAnfang einEnde einHier loescheHier ListenIterator<E> ListenIterator<E> (E); // (E); // (ListenIterator<E> &, E); (ListenIterator<E> &); // 181 am am // An Anfang einfuegen Ende einfuegen Vor dem Iterator einfuegen Iteratorposition loeschen anfang (); ende (); ListenConstIterator<E> ListenConstIterator<E> constAnfang () const; constEnde () const; private: class Knoten { public: Knoten (); Knoten (E, Knoten *, Knoten *); ˜Knoten (); Knoten * n; // naechster Knoten Knoten * v; // vorheriger Knoten E wert; }; Knoten *erster; Knoten *letzter; }; Die beiden Iteratorklassen sind gebundene Freunde da nur solche Iteratoren sinnvoll mit der Liste zusammenarbeiten können, die mit dem gleichen Parameter instanziert wurden. 6.4.3 Die Iteratoren Die Klassendefinition der beiden Iteratoren ist: template<class E> class ListenIterator { friend class Liste<E>; public: ListenIterator (Liste<E> &, Liste<E>::Knoten *); E & operator* (); ListenIterator<E> & operator++ (); bool operator== (const ListenIterator &) const; bool operator!= (const ListenIterator &) const; private: Liste<E> &l; Liste<E>::Knoten *pos; }; // Liste // aktuelle Position in der Liste template<class E> class ListenConstIterator { friend class Liste<E>; public: ListenConstIterator (const Liste<E> &, const Liste<E>::Knoten *); E operator* (); ListenConstIterator & operator++ (); bool operator== (const ListenConstIterator &) const; bool operator!= (const ListenConstIterator &) const; Programmierung II 182 private: const Liste<E> &l; const Liste<E>::Knoten *pos; }; // Liste // aktuelle Position in der Liste Die Definitionen der Schablonen wurden durch einfaches Umformen aus den Klassendefinitionen erzeugt. 6.4.4 Liste Sortieren Eine Liste nach dieser Definition kann sortiert werden: template<class T> void swap (T &x, T &y) { T t = x; x = y; y = t; } // Sortiertemplate, // Die Faehigkeiten die von L und L_ITER gefordert sind, // erkennt man nur durch Lesen der Definition von sort. // template<class L, class L_ITER> void sort (L &l) { for (L_ITER i=l.anfang(); i!=l.ende(); ++i) for (L_ITER j=i; j!=l.ende(); ++j) if (*j > *i) swap (*i, *j); } int main () { Liste<int> l; .... // Liste sortieren: sort<Liste<int>, ListenIterator<int> > (l); //Liste ausgeben: for (ListenConstIterator<int> i = l.constAnfang(); i != l.constEnde(); ++i) { cout << *i<<","; } ... } Die Sortierschablone hat den Typ der Liste und den Listeniterator als Parameter. Die Liste und ihr Iterator, die als aktuelle Schablonenparameter verwendet werden, müssen natürlich zueinander und zum Sortieralgorithmus passen. Die Liste muss beispielsweise den Dereferenzierungsoperator unterstützen, eine Methode anfang bieten, etc. Der Sortieralgorithmus und die Liste sind also eng gekoppelt. Diese Kopplung kommt in den formalen Schablonenparametern class L und class L ITER nur informal in den Namen zum Ausdruck. 6.4.5 Liste und ihre Sortierung enger koppeln Der Sortieralgorithmus ist auf exakt die Fähigkeiten des Listentemplates und seines Iterators zugeschnitten. Es ist besser, wenn dies auch formal zum Ausdruck kommt. Etwa indem Liste und Listeniterator explizit im Sortieralgorithmus auftreten: template<class T> void swap (T &x, T &y) { T t = x; x = y; y = t; } // sort ist offensichtlich auf Liste und Listeniterator Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 183 // zugeschnitten, E ist ein beliebiger Typ von dem nur // die Unterstuetzung von > verlangt wird. // // E ist Parameter; Liste und ListenIterator // werden fuer alle sichtbar benutzt // template<class E> void sort (Liste<E> &l) { for (ListenIterator<E> i=l.anfang(); i!=l.ende(); ++i) for (ListenIterator<E> j=i; j!=l.ende(); ++j) if (*j > *i) swap (*i, *j); } int main () { Liste<int> l; ... sort<int> (l); ... } // Sortieren Der Parameter E in dieser Variante von sort kann zwar ebenfalls nicht durch jeden beliebigen Typ ersetzt werden, die Anforderungen an einen aktuellen Parameter sind aber im Vergleich zur vorherigen Variante sehr einfach: E muss lediglich die Vergleichsoperation > anbieten. 6.5 6.5.1 Standard Template Library STL Die C++ Standardbibliothek Zu C++ gehört nicht nur die reine Sprachdefinition sondern auch die sogenannte Standardbiblithek, eine Sammlung nützlicher Funktions— und Typdefinitionen, die mit jeder C++–Implemetierung ausgeliefert werden müssen. Ein– /Ausgabe–Typen, Strings und diverse andere nützliche Dinge sind Bestandteil der Standardbibliothek. Die Standardbibliothek ist eine wichtige Komponente der Sprache deren Kenntnis manche Programmierarbeit einsparen kann. 6.5.2 STL, die Standard Template Library Schablonen sind ideal geeignet zur Formulierung von Datenstrukturen und Algorithmen. Listen und andere Behälterklassen und die entsprechenden Algorithmen auf ihnen sind Bestandteil fast jeden Programms. Es ist darum nicht verwunderlich, dass die Standardbibliothek in Form der Standard Template Library – STL – eine Kollektion an Funktions– und Klassenschablonen enthält. Die STL besteht aus drei wesentlichen Komponenten: Behälter (engl. Container): Klassentemplates für Listen, Vektoren, und andere Behälterklassen Iteratoren mit denen Behälter und andere Kollektionen von Objekten durchwandert werden können. Algorithmen mit den Kollektionen von Objekten durchsucht, sortiert oder auf andere Art durchlaufen und manipuliert werden können. Alle Bestandteile der STL sind natürlich Schablonen. 6.5.3 Listen Als Beispiel formulieren wir einen Sortieralgorithmus auf STL–Listen: Programmierung II 184 #include <utility> #include <list> #include <iostream> // STL swap-Template // STL Listen-Template list using namespace std; int main () { list<int> l; // Liste einlesen int x; do { cin >> x; if ( x == 0 ) break; l.push_back(x); } while (true); // Liste definieren // an Liste anhaengen; // push_front setzt ein Element an den Anfang // Liste sortieren: list<int>::iterator i, // STL Listen-Iteratoren j; for ( i=l.begin(); i!=l.end(); ++i ) for ( j=i; j!=l.end(); ++j) if (*j > *i) swap (*i, *j); // swap ist ein Funktionstemplate aus utility // Liste ausgeben: for ( i=l.begin(); i!=l.end(); ++i ) cout << *i << endl; } STL–Listen sind intern als doppelt verkettete Listen implementiert. Sie sind für alle Anwendungen geeignet, bei denen Elemente wahllos eingefügt oder gelöscht werden. 6.5.4 Vektoren STL–Vektoren sind die bessere Alternative zu Listen, wenn die verwaltete Kollektion der Werte zwar beliebig wachsen kann, aber wahllose Einfüge– oder Lösch–Operationen nicht benötigt werden. Unser Sortierbeispiel ist von dieser Art: #include <vector> #include <algorithm> #include <iostream> // STL-vector // enthaelt sort using namespace std; int main () { vector<int> v; // Vektor einlesen int x; do { cin >> x; if ( x == 0 ) break; v.push_back(x); } while (true); // Vektor sortieren: // an Vektor anhaengen Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 185 sort (v.begin(), v.end()); // sort-Template // v.begin() und v.end() sind Iteratoren // Vektor ausgeben: for ( unsigned int i=0; i<l.size(); ++i ) cout << v[i] << endl; } Vektoren sind im Gegensatz zu Listen auf schnellen Zugriff optimiert. Sie erlauben den wahlfreien Zugriff mit einem Index und können mit dem Sortier–Template sort der STL bearbeitet werden. sort sortiert den Bereich, der mit zwei Iteratoren angegeben wird. 6.5.5 Weitere Behälterklassen Vektoren und Listen sind nur zwei der Klassentemplates aus der STL. Es gibt Mengen (set) und Multimengen (multiset), Warteschlangen (deque, double ended queue), sowie Abbildungen (map und multimap) in denen Schlüssel–Wert–Paare (Tabellen) gespeichert werden. In einer multimap kann einem Schlüssel mehr als ein Wert zugeordnet sein. Über die Implementierung der Behälter sagt der Standard nichts, das ist Sache des jeweiligen Produkts. Mengen und Abbildungen können beispielsweise intern als Binärbäume oder auch als Hash–Tabellen implementiert sein. In jedem Fall kann man aber davon ausgehen, dass es beträchtlicher Anstrengungen bedarf, um eine eigene Klasse von entsprechender Qualität zu entwickeln. Ein einfaches Beispiel für die Verwendung einer Abbildung zum Sortieren nach einem Schlüssel ist: #include <map> #include <string> #include <iostream> using namespace std; int main () { map<string, string> m; string k, v; for (int i=0; i<10; ++i) { cout << "K: "; cin >> k; cout << "V: "; cin >> v; m[k] = v; } // Paare aus Schluessel und Wert einlesen // und (sortiert) in m abspeichern // Nach dem Schluessel sortiert ausgeben: // i ist ein Iterator ueber m // for ( map<string, string>::iterator i = m.begin(); i != m.end(); ++i ) cout << i->first << ", " << i->second << endl; } 6.5.6 Algorithmen: Permutationen Mit Hilfe der STL ist es sehr einfach alle Permutationen der Elemente einer Liste zu berechnen: Programmierung II 186 #include <list> #include <algorithm> #include <iostream> list<int> l; using namespace std; int main() { for (int i=0; i<5; ++i) // Liste mit den ersten 5 ganzen Zahlen fuellen l.push_back(i); do { for ( list<int>::iterator j = l.begin(); // Eine Permutation ausdrucken j != l.end(); ++j) cout << *j << ","; cout << endl; } while ( next_permutation(l.begin(), l.end()) ); // Schleife ueber alle } // Permutationen der Liste Die Funktion next permutation erzeugt solange neue Permutation einer Kollektion, die durch zwei Iteratoren begrenzt wird, bis diese sortiert ist. Genauer gesagt liefert next permutation dann false, wenn das Ergebnis seiner Aktivität sortiert ist. Beginnt man mit einer sortierten Folge, dann zerstört der erste Aufruf die Sortierung und alle Permutationen werden solange durchlaufen, bis sie wieder hergestellt ist. 6.5.7 Algorithmen: Problem des Handlungsreisenden Um einen Eindruck von der Mächtigkeit der Algorithmen von STL zu bekommen, wollen wir möglichst einfach das Problem des Handlungsreisenden lösen. Ein Handlungsreisender hat eine Reihe von Städten zu besuchen und sucht die Rundreise, die durch jede Stadt führt und dabei möglichst kurz ist. Das Problem ist berühmt, weil es eine außerordentliche praktische Bedeutung18 hat, aber nur mit hohem rechnerischem Aufwand gelöst werden kann. Wir nehmen an, dass unser Handlungsreisender mit einem Flugzeug reist und die Länge des Wegs sich aus der Entfernung der Punkte ergibt. Wir lösen das Problem einfach dadurch, dass wir die Länge aller möglichen Wege bestimmen und den Weg mit geringsten Länge ausgeben. #include #include #include #include #include <list> <utility> <cmath> <algorithm> <iostream> // Typdefinitionen Stadt und Weg // typedef pair<float, float> Stadt; typedef list<Stadt> Weg; // eine Stadt mit ihren Koordinaten // ein Weg ist eine Liste von Staedten //----------------------------------------------------------------------// Hilfsfunktionen // Distanz zweier Staedte und Laenge eines Wegs // Vergleich von zwei Stadten // inline float dist ( Stadt s1, Stadt s2 ){ // Distanz zweier Staedte if (s1 == s2) return 0; return std::sqrt ((s1.first-s2.first)*(s1.first-s2.first) + (s1.second-s2.second)*(s1.second-s2.second)); 18 Nicht nur für Handlungsreisende, in vielen Systemen müssen Dinge mit möglichst geringem Aufwand verbunden werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 187 } float wegLaenge (Weg w) { // Laenge eines Wegs if ( w.size() < 2 ) return 0; // Weniger als 2 Stationen -> Laenge = 0 float l; Weg::const_iterator v = w.end(); // Vorgaenger for (Weg::const_iterator i = w.begin(); // einzelne Wege addieren i != w.end(); // i ist die aktuelle Station ++i) { if ( v == w.end() ) // erste Station l = 0; else l = l + dist(*v,*i); // Entfernung vom letzten zu diesem } return l; } bool operator< (Stadt s1, Stadt s2) { if (s1.first < s2.first) return true; if (s1.first == s2.first) return (s1.second < s2.second); return false; } // Vergleich von Staedten // Wird zum Sortieren gebraucht //----------------------------------------------------------------------// int main() { Weg staedte; // Stadte-Liste einlesen; for (int i=0; i<4; ++i) { float x, y; // Koordinaten; std::cin >> x >> y; staedte.push_back(pair<float,float>(x,y)); } Stadt start = *staedte.begin(); // Alle Wegpermutationen // Die kuerzeste in kWeg // Weg kWeg; // float l=1000000000.0; // berechen speichern bisher gefundener kuerzester Weg Laenge dieses Wegs Weg rest = staedte; rest.pop_front(); // alle Staedte ausser Startpunkt rest.sort(); // sortieren damit wir permutieren koennen do { // Alle moeglichen Permutationen von rest berechnen // daraus und mit start einen Weg bestimmen und messen // Weg weg = rest; // ein Weg: start->Permutation(rest)->start weg.push_front(start); weg.push_back(start); float nl = wegLaenge(weg); if (nl < l) { l = nl; kWeg = weg; } } while ( next_permutation(rest.begin(), rest.end()) ); // Gefundene Loesung ausgeben Programmierung II 188 for (Weg::const_iterator i = kWeg.begin(); i != kWeg.end(); ++i ) cout << "("<<i->first<<","<<i->second<<") "; } In diesem Programm werden alle möglichen Wege als Permutationen der Städteliste berechnet und die jeweilige Weglänge bestimmt. Die Permutation mit der kürzesten Weglänge ist die gesuchte Lösung. Diese Beispiele sollten gezeigt haben, dass die STL mächtige und elegante Konstrukte zur Verfügung stellt. Die Fülle der Möglichkeiten wurde dabei nur angedeutet. Zur Standardbibliothek von C++ im Allgemeinen und der STL im Besonderen verweisen wir auf die Literatur. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 6.6 189 Übungen Aufgabe 1 Die Nullstelle einer stetigen Funktion werden: double nullStelle ( double double double double im Intervall von bis kann mit folgender Funktion gefunden (*f)(double), u, // untere Intervallgrenze o, // obere Intervallgrenze eps ) { // Genauigkeit: minimale Intervallgroesse // existiert eine Nullstelle assert (f(u)*f(o) < 0); while ( eps < (o-u) ) { double m = (o+u)/2.0; if ( f(m)*f(o) < 0 ) u = m; else o = m; } return (u+o)/2.0; // Mitte // f(m) und f(o) haben unterschiedliche Vorzeichen // wir suchen weiter im obereren Intervall // wir suchen weiter im unteren Intervall } Wandeln Sie die Funktion nullStelle in ein Funktionstemplate um, bei dem die Funktion Templateparameter ist. Aufgabe 2 Gegeben sei folgende Definition zweidimensionaler Integer–Vektoren: class Vektor { friend ostream & operator<< (ostream &, const Vektor &); friend istream & operator>> (istream &, Vektor &); public: Vektor (); Vektor (int, int); int & x (); // x--Koordinate int & y (); // y--Koordinate Vektor operator+ (const Vektor &) const; // Vektoraddition private: int x_, y_; }; 1. Wandeln Sie diese Definition in ein Klassentemplate um (der Typ der Vektorkomponenten ist der Template– Parameter). 2. Geben Sie eine Implementierung aller Methoden des Klassentemplates an. 3. Geben Sie eine Implementierung der Ein–/Ausgabeoperatoren für das Klassentemplate an. Aufgabe 3 Schreiben Sie ein Template sort zum Sortieren eines Feldes in folgenden Varianten: Programmierung II 190 1. Alle Instanzen von sort sortieren aufsteigend, die Feldgröße wird beim Aufruf einer Instanz angegeben (die Feldgröße ist also kein Templateparameter sondern Funktionsparameter). Jede Instanz kann Felder beliebiger Größe sortieren. 2. Wie oben, aber ausserdem ist die Sortierrichtung (auf– oder abwärts) Templateparameter. 3. Wie oben, aber jetzt ist die Sortierrichtung Funktionsparameter und die Feldgröße Templateparameter. Geben Sie jeweils ein Beispiel für die Verwendung Ihres Templates an. Aufgabe 4 Ein erster Versuch, einen Datentyp für (N,M)–Matrizen mit beliebigen Elementen zu definieren, hat zu folgendem Template geführt: #include <vector> #include <iostream> template<int N, int M, class T> class Matrix { public: Matrix () : mat(N) { for ( int i=0; i<N; ++i) mat[i].resize(M); } vector<T> operator[] ( unsigned int i ) { return mat[i]; } private: vector< vector<T> > mat; }; Die Größe eines Vektors kann mit einem Konstruktor und mit resize gesetzt werden: vector<int> v(5); v.resize (10); // v hat 5 Elemente // v hat jetzt 10 Elemente Leider gibt das kleine Testprogramm int main () { Matrix<3,4,int> matrix; for ( int i=0; i<3; ++i ) for ( int j=0; j<4; ++j ) matrix[i][j] = 10*i+j; for ( int i=0; i<3; ++i ) { for ( int j=0; j<4; ++j ) { std::cout.width(4); std::cout << matrix[i][j]; } std::cout << std::endl; } } statt der erwarteten Werte 0 10 20 1 11 21 2 12 22 3 13 23 Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 191 Folgendes aus: 0 0 0 0 0 0 0 0 0 0 0 0 Analysieren Sie die Template–Klasse, identifizieren Sie den Fehler und beseitigen Sie ihn! Sind die Instanzen des Templates konkrete Datentypen? Aufgabe 5 Eine dünn besetzte Matrix ist eine Matrix, bei der fast alle Elemente den Wert Null haben. Definieren Sie ein Klassen–Template zur Implementierung von 2–dimensionalen dünn besetzten Matrizen. Überlegen Sie was Templateparameter ist und was Argument des Konstruktors ist. Ihre Klasse soll möglicht wenig Speicherplatz benötigen, möglichst einfach zu benutzen sein und so weit wie möglich und sinnvoll die Definitionen STL benutzen. Selbstverständlich müssen die Instanzen Ihres Templates konkrete Datentypen sein. Aufgabe 6 Die Determinante einer Matrix werden (vergleiche Mathematikvorlesung): wobei streicht. die kann rekursiv durch Entwicklung nach der ersten Spalte berechnet Matrix ist, die aus entsteht, wenn man die –te Zeile und die –te Spalte Schreiben Sie ein Klassen–Template mit der Berechnung der Determinanten und der Untermatrizen als als Methoden und der Größe der Matrix als Templateparamter: template<unsigned int n> class Matrix { friend istream & operator>> <n> (istream &, Matrix<n> &); friend ostream & operator<< <n> (ostream &, const Matrix<n> &); private: ... Matrix<n-1> coMatrix ( unsigned int x, unsigned int y ); ... public: ... float det (); ... }; Hinweis: Benutzen Sie partielle Instanziierung um die Template-Rekursion abzubrechen. Programmierung II 192 7 Vererbung 7.1 Basisklasse und Abgeleitete Klasse 7.1.1 Basisklasse und Abgeleitete Klasse: Art und Unterart Die Unterscheidung zwischen Objekt und Klasse teilt die Welt auf in konkrete Objekte (die Kuh Elsa) und Klassen (Arten, Typen) (die Klasse Kuh). Mit Konzepten der Vererbung können Klassen miteinander in Beziehung gesetzt werden. Alle Kühe sind Säugetiere, die Kuh Elsa ist darum auch ein Säugetier. Man sagt Säugetier ist die Basisklasse und Kuh die abgeleitete Klasse, oder auch Kuh ist eine Ableitung von Säugetier. Klasse und abgeleitete Klasse entsprechen Art und Unterart. Statt von “abgeleiteter Klasse” spricht man darum gelegentlich auch von “Unterklasse”. Mit einer Art will man das Gemeinsame ihrer Unteraten zum Ausdruck bringen. Kühe, Katzen, Menschen und Affen haben Gemeinsamkeiten, die durch das Konzept “Säugetier” zum Ausdruck gebracht werden. Umgekehrt unterscheiden sie sich aber trotzdem noch so weit, dass es sinnvoll ist weiterhin von Kühen, Katzen, Menschen und Affen zu sprechen. Es geht also um die Unterschiede und die Gemeinsamkeiten von Objekten unterschiedlicher Klassen, wenn diese als Basis– und abgeleitete Klasse in Beziehung gesetzt werden. 7.1.2 Geomtrische Objekte: Basisklasse und Abgeleitete Klassen Die geometrischen Objekte der Klassen Vektor, Punkt und Gerade haben eine Gemeinsamkeit, es sind geometrische Objekte. Jeder Vektor oder Punkt und jede Gerade ist auch ein geometrisches Objekt. Dies kann in C++ mit einer Klasse Geo explizit definiert werden: class Geo { ... }; // Basisklasse definieren class Vektor : public Geo { ... }; class Punkt : public Geo { ... }; class Gerade : public Geo { ... }; // abgeleitete Klasse definieren // ein Vektor, ein Punkt, eine Gerade // ist jeweils (auch) ein Geo Die Klasse Geo ist die Basisklasse. Vektor, Punkt und Gerade sind abgeleitete Klassen. Damit wird gesagt, dass jedes Objekt der Klassen Vektor, Punkt und Gerade auch ein Objekt der Klasse Geo ist. Das Schlüsselwort public in der Definition der abgeleiteten Klassen bringt die Beziehung der Klassen zum Ausdruck. 7.1.3 Ableitung in UML Die Ableitungsbeziehung wird in UML durch einen Pfeil von der abgeleiteten zur Basisklasse dargestellt (siehe Abbildung 68) Geo Geo oder Gerade Punkt Vektor Gerade Punkt Vektor Abbildung 68: Ableitung in UML 7.1.4 Basisklasse: reines Konzept oder Typ realer Objekte Es gibt kein Objekt das nur ein geometrisches Objekt ist, ohne gleichzeitig ein Punkt, eine Gerade oder ein Vektor zu sein, so wie es auch kein Insekt gibt, das nur ein Insekt ist, ohne gleichzeitig ein Käfer, Schmetterling, oder Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 193 ähnliches zu sein. Die Basisklassen Geometrisches–Objekt und Insekt sind also reine Konzepte. Basisklassen und ihre Ableitungen sind jedoch nicht auf die Konstellation beschränkt, bei der Ableitungen in einer Basisklasse als übergeordnetem abstrakten Konzept zusammengefasst werden. Die Basisklasse kann durchaus auch der Typ realer Objekte sein. Beispielsweise kann Angestellte eine Klasse mit vielen realen Objekten sein: Fräulein Meier, Frau Schulze, etc. sind Angestellte. Manche Angestellte gehören zum Management und sind darum Angestellte und Manager. Damit haben wir eine Basisklasse Angestellte und eine abgeleitete Klasse, die beide der Typ realer Objekte sind. 7.1.5 Verallgemeinerung und Spezialisierung Geo ist die Basisklasse, von ihr sind die Klassen Vektor, Punkt und Gerade abgeleitet. Die Beziehung zwischen den Klassen kann man in zwei Richtungen sehen: Verallgemeinerung: Die Basisklasse Geo ist eine Verallgemeinerung ihrer Ableitungen Vektor, Punkt und Gerade. Spezialisierung: Die Ableitungen Vektor, Punkt und Gerade sind Spezialisierungen der Basisklasse. 7.1.6 Basisklassen flexibilisieren das Typsystem Ein geometrisches Objekt kann ein Vektor, ein Punkt oder eine Gerade sein. Dies gilt auch für Variablen mit diesen Typen: class Geo { ... }; class Vektor : public Geo { ... }; class Punkt : public Geo { ... }; int main () { Geo geo; Vektor vec; Punkt geo = vec; // OK: ein geo = pun; // OK: ein pun = geo; // FEHLER: ein vec = pun; // FEHLER: ein vec = geo; // FEHLER: ein } pun; Vektor ist auch ein Geo Punkt ist auch ein Geo Geo ist kein Punkt Punkt ist kein Vektor Geo ist kein Vektor Man kann also eine Variable vom Basistyp Geo definieren und sie mit Werten eines abgeleiteten Typs belegen; aber nicht umgekehrt. Das ist einsichtig: Immer wenn ein Insekt benötigt wird, kann man einen Käfer verwenden, aber wenn ein Käfer verlangt wird, kann man nicht mit einem x–beliebigen Insekt kommen. Bei der Parameterübergabe gilt das Gleiche. An einen formalen Parameter vom Basistyp kann ein Objekt mit einem abgeleiteten Typ übergeben werden. Das gilt für Wertparameter, Referenzen und Zeiger. Beispiel: class Geo { ... }; class Vektor : public Geo { ... }; class Punkt : public Geo { ... }; void f1 (Geo g) { ... } void f2 (Geo & g) { ... } void f3 (Geo * g) { ... } void h (Punkt p); int main () { Geo geo; Vektor f1(vec); f1(pun); vec; // OK Punkt pun; Programmierung II 194 f2(vec); f2(pun); // OK f3(&vec);f3(&pun); // OK h(geo); // FEHLER: ein Punkt ist ein Geo, aber ein Geo ist kein Punkt } Es gibt allerdings Unterschiede zwischen der Übergabe und Zuweisung von Objekten im Gegensatz zu der Übergabe von Referenzen und Zeigern. Darauf werden wir später zurückkommen. 7.2 7.2.1 Vererbung Vererbung: Übernahme der Eigenschaften vom Basistyp Statt von Ableitung spricht man oft auch von Vererbung. Der Grund für den – in diesem Zusammenhang – etwas seltsamen Begriff liegt im Verständnis der Beziehung der Basisklasse zu ihren Ableitungen. Die Basisklasse stellt das Allgemeine dar, die Ableitung das Spezielle. Das Spezielle hat alle Eigenschaften des Allgemeinen und darüberhinaus noch eigene spezielle Eigenschaften. Definiert man eine Klasse B als Ableitung einer anderen Klasse A, dann übernimmt – erbt (!) – B alle Eigenschaften von A. Die Eigenschaften, das sind alle Komponenten, Daten oder Methoden. Beispiel: class A { public: void f(int); int i; }; // Ableitung class B : public A { float x; }; Entspricht // // // // // class B { void f(int); int i; float x; }; // von A geerbt // von A geerbt Die abgeleitete Klasse B hat alles was die Basisklasse A hat. Das spart Definitionsarbeit bei B und macht die Klassendefinition kompakter. 7.2.2 Vererbung: Objekte der abgeleiteten Klasse können an Stelle von Objekten der Basisklasse auftreten Genauso wichtig wie die kompaktere Definition einer abgeleiteten Klasse ist aber, dass – wegen der gemeinsamen Komponenten – mit einem B–Objekt all das möglich ist, was mit einem A–Objekt gemacht werden kann. Beispiel: class A { public: void f(int p) { cout << p+i << endl; } private: int i; }; class B : public A { private: float x; }; int main () { A a; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B b; a.f(1); b.f(2); a = a; a = b; // // // // f f a b 195 ist Komponente von a ist auch Komponente von b kann an a zugewiesen werden kann auch an a zugewiesen werden. } Da mit einem Objekt einer abgeleiteten Klasse all das gemacht werden kann, was mit einem Objekt der Basisklasse möglich ist, kann es an jeder Stelle verwendet werden, an der ein Objekt der Basisklasse auftreten darf. i x b class B : public A a Konversion B −> A i class A Abbildung 69: a=b; Zuweisung an eine Basisklasse 7.2.3 Zuweisung von Objekten Bei der Zuweisung eines Objekts mit abgeleitetem Typ an eine Variable vom Basistyp müssen wir unterscheiden, ob Objekte oder Zeiger bzw. Referenzen im Spiel sind. Bei einer direkten Zuweisung eines Objekts wird ein –Objekt in ein –Objekt verwandelt. Bei einer Zuweisung eines Zeigers oder einer Referenz entfällt die Umwandlung. Beispiel: class A { .. wie oben .. }; class B : public A { .. wie oben .. }; int main () { A a; A *pa = new A; B b; B *pb = new B; a = b; // b wird in ein A verwandelt // a enthaelt ein A-Objekt pa = pb; // keine Umwandlung // pa zeigt auf ein B-Objekt } Bei einer Zuweisung eines Objekts b der abgeleiteten Klasse B an eine Variable a der Basisklasse wird das Objekt der abgeleiteten Klasse in die Basisklasse konvertiert, d.h. es wird auf seine Basiskomponenten reduziert (siehe Abbildung 69). Die gleiche Konversion findet bei der Wertübergabe statt: class A { .. wie oben .. }; class B : public A { .. wie oben .. }; void f (A p) { ... } int main() { A a; B b; f(a); // OK f(b); // OK aber b wird auf ein A reduziert } Programmierung II 196 Es ist klar, dass eine entsprechende Konversion stattfinden muss. Die Variable mit dem Basistyp A ist einfach nicht groß genug, um einen Wert mit mehr Komponenten aufzunehmen. 7.2.4 Zuweisung von Referenzen und Zeigern Bei Zeigern und Referenzen stellen sich diese Platzprobleme nicht. Bei einer Zuweisung findet keine Konversion statt (siehe Abbildung 70). b i x pb class B : public A Konversion A* −> B* (keine Modifikation des Werts) pa a i class A Abbildung 70: pa=pb; Zuweisung an einen Zeiger auf die Basisklasse class A { .. wie oben .. }; class B : public A { .. wie oben .. }; int main() { A * pa = new A; B * pb = new B; pa = pb; // keine Konversion: pa zeigt auf ein B-Objekt pa->x = 0.0; // Fehler: pa->x existiert ist aber nicht zugreifbar } Die Komponenten, die zur Ableitung gehören, werden nicht “wegkonvertiert”. Sie sind aber trotzdem nicht – über pa – zugreifbar: Zwar zeigt pa auf ein B–Objekt, aber pa hat immer noch den Typ A* und damit gibt es kein pa->x. 7.2.5 Vererbung: Spezialisierung und Verallgemeinerung Das Konzept der Vererbung kann in der Praxis auf zwei Arten eingesetzt werden: Zur Spezialisierung: Ausgehend von einer Basisklasse werden Spezialisierungen definiert. Zur Verallgemeinerung: Das Gemeinsame einer Reihe verwandter Klassendefinitionen wird gesucht und in einer Basisklasse konzentriert. 7.2.6 Vererbung: Spezialisierung Betrachten wir zunächst ein Beispiel der Spezialisierung. Angenommen man hat eine Klasse zur Darstellung der Angestellten einer Firma: class Angestellter { public: Angestellter (string pname) : name(pname) {} .... private: string name; }; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 197 Irgendwann stellt sich heraus, dass zur Verwaltung der Manager noch weitere spezielle Informationen benötigt werden. Beispielsweise erhalten Manager jetzt als Leistungsanreiz nicht mehr, so wie normale Mitarbeiter, nur Aktienoptionen, sie können darüberhinaus das Recht erwerben, den Waschraum des Vorstands zu benutzen. Wenn alles andere gleich ist, dann wird der Manager einfach definiert als: class Manager : public Angestellter { public: Manager(string pname) : Angestellter(name), vsWaschraumRecht(false); {} bool verdienterManager() const { return vsWaschraumRecht; } void setzeVerdienst ( bool b) { vsWaschraumRecht = b; } private: bool vsWaschraumRecht; }; Ein Manager ist eine Variante eines Angestellten mit mehr Methoden und Datenkomponenten. Er wird zuerst wie ein Angestellter initialisiert, dann werden seine speziellen Eigenschaften mit Werten belegt. 7.2.7 Vererbung: Verallgemeinerung Umgekehrt kommt man vom Speziellen zum Allgemeinen, wenn die Komponenten, die in mehreren Klassen vorkommen, in einer Basisklasse konzentriert werden. Nehmen wir an, wir haben die Klassen der Studenten und Dozenten: class Student { public: Student (string s, unsigned int i) : name(s), matrikelNr(i) {} ... private: string name; unsigned int matrikelNr; }; class Dozent { public: enum Fach {Physik, Chemie, Informatik}; Dozent (string s, Fach f) : name(s), fach(f) {} ... private: string name; Fach fach; }; int main () { Student mueller ("Mueller", 4711); Dozent meier ("Meier", Dozent::Physik); } Studenten und Dozenten haben jeweils einen Namen. Diese Gemeinsamkeit kann in einer Basisklasse HochschulMitglied zusammengefasst werden: class HochschulMitglied { public: HochschulMitglied (string s) Programmierung II 198 Hochschulmitglied name Mueller matrikelNr 4711 mueller : Student −name name Meier fach Physik meier : Dozent name Dozent Student −matrikelNr Amsel −fach praesident : Hochschulmitglied Klassen Objkete Abbildung 71: Ableitung: Klassen und Objekte : name(s) {} ... private: string name; }; class Student : public HochschulMitglied { public: Student (string s, unsigned int i) : HochschulMitglied(s), // name in der Basisklasse belegen matrikelNr (i) {} // eigene Komponente belegen ... private: unsigned int matrikelNr; }; class Dozent : public HochschulMitglied { public: enum Fach {Physik, Chemie, Informatik}; Dozent (string s, Fach f) : HochschulMitglied (s), fach (f) {} ... private: Fach fach; }; int main () { Student mueller ( "Mueller", 4711 ); Dozent meier ( "Meier", Dozent::Physik ); HochschulMitglied praesident ( "Amsel" ); } Die Klassen Student und Dozent haben die gemeinsame Komponente name. Sie definieren sie nicht jeweils selbst, sondern erben sie von Hochschulmitglied. Die Objekte der drei Klassen haben aber stets eine eigene Namenskomponente (siehe Abbildung 71). Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 7.3 7.3.1 199 Sichtbarkeit und Vererbung: public, protected und private Sichtbarkeit in abgeleiteten Klassen: public–Komponenten Abgeleitete Klassen übernehmen alle Komponenten ihrer Basisklasse. Die Sichtbarkeitsregeln werden aber nicht in jedem Fall einfach übertragen. Eine öffentliche Komponente der Basisklasse ist auch öffentlich in jeder Ableitung. Das ist der einfachste Fall: class Basis { public: int i; }; class Ab : public Basis { void f () { i = 5; // OK: ich kann i direkt verwenden } }; int main() { Ab ab; ab.i = 6; } // OK: i ist oeffentlich in Basis und darum auch oeffentlich in Ab Sichtbarkeit vererbter public–Komponenten: Alle öffentlichen Komponenten einer Basisklasse sind öffentliche Komponenten ihrer Ableitungen. 7.3.2 Sichtbarkeit in abgeleiteten Klassen: private–Komponenten Eine private Komponente der Basisklasse ist aber nicht einfach auch eine private Komponente in den Ableitungen! Sie bleibt private Komponente in der Basisklasse. Private Komponenten sind nur in ihrer Klasse zugreifbar (= sichtbar), auch Ableitungen haben keinen Zugriff. Man verwechsle aber nicht die Zugreifbarkeit mit der Existenz. Manche Dinge kann man nicht greifen, obwohl sie existieren! Beispiel: class Basis { private: int i; }; class Ab : public Basis { void f () { i = 5; // FEHLER: i existiert zwar in jeden Ab-Objekt, } // ist aber fuer Ab-Methoden NICHT zugreifbar }; int main() { Ab ab; ab.i = 6; } // FEHLER i ist natuerlich von aussen auch nicht zugreifbar Hier hat ab zwar eine i–Komponente, kann diese aber nicht selbst – in einer eigenen Methode – verwenden. Sie gehört zu ab, ist aber noch geheimer als eine private Komponente: so geheim, dass sie sie nicht einmal selbst lesen kann. Sichtbarkeit vererbter private–Komponenten: Die privaten Komponenten einer Basisklasse sind in ihren Ableitungen nicht zugreifbar, obwohl sie in jedem Objekt einer abgeleiteten Klasse vorhanden sind. Programmierung II 200 7.3.3 Die Philosophie von public, private und Vererbung Die Sichtbarkeitsregeln in Zusammenhang mit Vererbung versteht man am besten über ihren beabsichtigten Einsatz. Eine Klasse hat private und öffentliche Komponenten. Die öffentlichen sind ihre Schnittstelle, die privaten stellen die Implementierung dar. Ist die Klasse eine Basisklasse, dann werden die öffentlichen Komponenten von allen Ableitungen übernommen. Die öffentlichen Komponenten der Basisklasse stellen jetzt die gemeinsame Schnittstelle der Klasse und all ihrer Ableitungen dar. Die privaten Komponenten der Basisklasse bleiben aber weiterhin die Implementierung nur der Basisklasse Daraus folgt dass Öffentliches in der Ableitung auch öffentlich ist, Privates aber Privates der Basisklasse bleibt: class BasisKlasse { public: -- gemeinsame Schnittstelle von BasisKlasse und --- alle ihren Ableitungen -private: -- Implementierung von BasisKlasse -}; 7.3.4 Zugriff auf Privates der Basisklasse Die Sichtbarkeit operiert auf Klassenebene. Die Komponenten existieren als Bestandteil der Objekte. Ein Objekt einer abgeleiteten Klasse kann auf seine “Geheimkomponente” – eine private Komponente der Basisklasse – nur indirekt über eine öffentliche Methode der Basisklasse zugreifen. Beispiel: class Basis { public: void fbasis () { ++x; } private: int x; // ist Komponente aller Ableitungen }; class Ab: public Basis { public: void fab () { fbasis(); } // indirekter Zugriff auf das eigene x void gab (Basis &rb) { rb.fbasis(); } // indirekter Zugriff auf rb.x }; int main () { Ab ab1, // hat ein "ganz privates" x, nur ueber Basis::fbasis zugreifbar ab2; ab1.fab(); // OK: erhoeht ab1.x ab1.fbasis();// OK: erhoeht ab1.x ab1.gab(ab2);// OK: erhoeht ab2.x } Die privaten Komponenten einer Klasse sind nur aus dieser Klasse heraus zugreifbar. Alle anderen Klassen haben keinen Zugriff. Das gilt auch für abgeleitete Klassen: class Basis { private: int x; // x ist Komponente aller Ableitungen, aber dort nicht zugreifbar }; class Ab: public Basis { Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 201 // Jedes Ab-Objekt enthaelt ein (ererbtes) unzugreifbares // int x public: void f () { ++x; } // FEHLER Basis::x ist privat, in Ab kein Zugriff }; 7.3.5 protected: Komponenten für interne Zwecke in der Ableitungskette Private Komponenten einer Basisklasse sind nur in ihr zugreifbar, öffentliche sind überall zugreifbar. Es fehlt etwas dazwischen: Eine Beschränkung der Sichtbarbeit auf die Klasse und ihre Ableitungen. Mit protected wird diese Lücke geschlossen: class Basis { protected: int x; }; class Ab: public Basis { public: void f () { ++x; } // OK, eine Ableitung darf zugreifen }; int main () { Ab ab; ab.f(); // OK ++ab.x; // FEHLER: x ist protected, also nur fuer Basis und Ab zugreifbar } In main kann auf ab.x nicht zugegriffen werden. Es hat nach aussen die gleiche Sichtbarkeit wie eine private Komponenten. In der abgeleiten Klasse kann es im Gegensatz zu einer privaten Komponente aber verwendet werden. Sichtbarkeit vererbter protected–Komponenten: Die geschützten Komponenten einer Basisklasse sind in ihren Ableitungen zugreifbar, nicht jedoch in Klassen, die nicht von der Basisklasse abgeleitet sind. Komponenten mit der Sichtbarkeit protected sind in der Klasse selbst und in allen Ableitungen zugreifbar. Ansonsten sind sie so geschützt wie private. protected wird eingesetzt um die Sichtbarkeit von Komponenten zu bestimmen, die zur Implementierung ihrer Klasse und deren Ableitungen gehören: class BasisKlasse { public: -- Das duerfen alle sehen: --- gemeinsame Schnittstelle von BasisKlasse und --- aller Ableitungen nach aussen -protected: -- Das duerfen Nachkommen sehen: --- Schnittstelle von BasisKlasse zu ihren --- Ableitungen. -private: -- Das geht keinen was an: --- Implementierung NUR von BasisKlasse -}; Eine Klasse zeigt ihre public–Elemente allen anderen Klassen, ihre protected–Elemente zeigt sie all ihren Ableitungen. Die Schlüsselworte public und protected definieren also Schnittstellen der Klasse zu einem jeweils unterschiedlichen Publikum. Unabhängig von der Sichtbarkeit hat jedes Objekt jeder Ableitung jede in BasisKlasse definierte Komponente, was immer auch ihre Sichtbarkeit sein mag. Programmierung II 202 7.3.6 Bei Zugriff über Klassengrenzen operiert protected auf Objektebene Mit protected wird der Zugang auf ansonsten unzugängliche Komponenten der eigenen Basisklasse geöffnet. Diese Öffnung hat aber eine Beschränkung: sie gilt nur für das gleiche Objekt. Auf Komponenten der Basisklasse mit Sichtbarkeit protected darf zwar in allen Ableitungen zugriffen werden, aber nur dann wenn sie zum gleichen Objekt gehören. class Basis { protected: int x; // ist Komponente aller Ableitungen und dort zugreifbar }; Basis b; class Ab: public Basis { public: Ab::m() { x = 0; // OK: Zugriff auf MEIN Basis::x b.x = 0; // FEHLER: Zugriff auf FREMDES Basis::x } }; Das gilt natürlich nur bein Zugriff über Klassengrenzen. Innerhalb der gleichen Klasse gibt es keine Sichtbarkeitsbeschränkungen. Ein etwas ausführlichers Beispiel ist: class Basis { protected: int x; // ist Komponente aller Ableitungen und dort zugreifbar }; class Ab: public Basis { public: void fab () { ++x; } void gab (Ab & rab) { ++rab.x; } void gb (Basis & rb) { ++rb.x; } }; // OK: // OK: // FEHLER: // eigenes Basis::x zugreifbar fremdes Ab::x zugreifbar fremdes Basis::x NICHT zugreifbar Die Sichtbarkeit von x in den verschiedenen Methoden erklärt sich wie folgt: fab: andere Klasse, gleiches Objekt: OK! Die Methode fab greift auf “ihr” x zu. Das x gehört zur Basisklasse B, darf aber zugegriffen werden, da es nur potected und nicht private ist. gab: gleiche Klasse, anderes Objekt: OK! Die Methode fab greift auf das x eines anderen Objekts zu. Dieses Objekt gehört aber zur gleichen Klasse. Der Zugriff geht nicht über Klassengrenzen und ist darum erlaubt. gb: andere Klasse, anderes Objekt: Kein Zugriff! gb operiert auf dem x eines fremden Objekts mit einem anderen Typ. Das ist nicht erlaubt: Andere Klasse, anderes Objekt, also nur Zugriff auf öffentliche Komponenten. Komponenten mit Sichtbarkeit protected haben also insgesamt einen ähnlichen aber etwas schwächeren Schutz als private Komponenten: So wie bei privaten kann innerhalb der Klasse unbeschränkt auf sie zugegriffen werden. So wie bei privaten ist der Zugriff über Klassengrenzen verboten, es sei denn – und das ist das Besondere – er erfolgt innerhalb eines Objekts. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 7.4 7.4.1 203 Vererbungstypen Öffentliche Vererbung ist nur eine Variante der Vererbung Die Art der Übernahme der Sichtbarkeitsregeln kann gesteuert werden. Bei der bisher stets betrachteten öffentlichen Vererbung herrscht keine Symmetrie. Öffentliche Komponenten bleiben öffentlich in allen Ableitungen. Private Komponenten werden in der Ableitung so privat, dass ein Objekt sie selbst nicht mehr lesen kann. Öffentliche Vererbung wird durch das Schlüsselwort public vor der Basisklasse angezeigt class Ab : public Basis ... ; // oeffentliche Vererbung und bedeutet, dass die öffentlichen Komponenten öffentlich bleiben: Public bleibt public! Ein Objekt einer Ableitung kann darum in jedem Kontext genutzt werden, in dem ein Objekt der Basisklasse auftreten kann. Die öffentliche Vererbung entspricht damit dem natürlichen Verständnis von Vererbung als einer Beziehung zwischen Art und Unterart. 7.4.2 Private Vererbung: Alles öffentliche wird privat Ableitungen können auch privat sein. Private Vererbung wird mit private gekennzeichnet. Sie bewirkt, dass alle Komponenten der Basisklasse in den Ableitungen privat sind. class Basis { public: int x; // ist Komponente aller Ableitungen }; class Ab: private Basis { public: void f () void g (Ab &ab) void h (B &b) }; // <<--- PRIVATE Vererbung ! // Alles in Basis wird privat in Ab // (also intern zugreifbar) { ++x; } // OK: eigenes Basis::x zugreifbar { ++ab.x; } // OK: fremdes Ab::x zugreifbar { ++b.x; } // OK: fremdes Basis::x zugreifbar int main () { Basis b; Ab ab; ++b.x; // OK Basis::x zugreifbar ++ab.x; // FEHLER Ab::x nicht zugreifbar } Man beachte, dass die private Vererbung Fremden zwar weniger, der abgeleiteten Klasse aber mehr Zugriffsrechte gibt als die öffentliche. Alles von Basis wird zum privaten von Ab, auch das private Basis::x ist ganz normal privat in Ab und nicht so “super–privat” wie bei der öffentlichen Vererbung. Die private Vererbung wird häufig auch Implementierungsvererbung genannt. Sie entspricht nicht dem intuitiven Verständnis von Vererbung. Ihr Einsatz wird darum bestenfalls fortgeschrittenen Entwicklern empfohlen, die ihre Entscheidung gut begründen können. 7.4.3 Geschützte Vererbung: Alles öffentliche wird protected Beim dritten Typ der Vererbung werden alle öffentlichen Komponenten der Basisklasse zu geschützten (protected) Komponenten der Ableitung. Sie können damit weiter unten in der Ableitungskette verwendet werden. .. ; class Basis class Ab : protected Basis ... ; Programmierung II 204 Auch diese Form der Vererbung ist eine fortgeschrittene Technik und sollte auf spezielle Anforderungen beschränkt bleiben. 7.4.4 Mehrfachvererbung Eine Klasse kann von mehr als einer Basisklasse abgeleitet werden. Man bezeichnet dies als Mehrfachvererbung (engl. multiple inheritance). Beispiel: class Messer { ... }; class Schere { ... }; class Feile { ... }; class SchweizerMesser : public Messer, public Schere, public Feile { ... }; Die abgeleitete Klasse erbt von allen Basisklassen. Ein Objekt hat damit alle Eigenschaften aller Basisklassen. Die Art der Vererbung kann für jede Basisklasse unterschiedlich sein: class Abgeleitet : public Basis1, protected Basis2, private Basis3 { ... }; Mehrfachvererbung führt leicht zu Komplikationen und wird nur für fortgeschrittene Anwendungen empfohlen. 7.4.5 Zusammenfassung Sichtbarkeit und Vererbung Es gibt drei Arten der Sichtbarkeit von Komponenten und drei Arten der Vererbung. Bei jeder Art der Vererbung werden alle Komponenten der Basisklasse zu Komponenten der abgeleiteten Klasse. Ihre Sichtbarkeit hängt von der Sichtbarkeit in der Basisklasse und der Art der Vererbung ab. Die Standardmethode der Vererbung ist die öffentliche Vererbung. Bei ihr werden öffentliche Komponenten der Basisklasse als öffentliche Komponenten der Ableitung übernommen. Private Komponenten werden zu ganz privaten und solche mit geschützter Sichtbarkeit werden (quasi) zu privaten Komponenten der Ableitung. Alle Ableitungen außer der öffentlichen sollten nur in besonderen und eher seltenen Fällen verwendet werden. 7.5 7.5.1 Konstruktoren und Destruktoren Vererbung und Konstruktoren Werden Variablen mit einer Klasse als Typ angelegt, wird zuerst ausreichend Platz geschaffen und dann wird der Defaultkonstruktor bzw. Initialisierer aller Komponenten aktiviert und schließlich wird der Konstruktor des Objekts selbst ausgeführt. Hat das Objekt als Typ eine abgeleitete Klasse, dann wird die Initialisierung der Basisklasse in diesen Prozess eingefügt: 1. Platz für das gesamte Objekt wird geschaffen. 2. Die ererbten Komponenten, also die die zur Basisklasse gehören, werden mit ihrem jeweiligen Initialisierer oder Defaultkonstruktor initialisiert. 3. Der Defaultkonstruktor der Basisklasse schließt die Initialisierung der Komponenten der Basisklasse ab. 4. Die Komponenten der Ableitung werden durch einen Initialisierer oder ihren Defaultkonstruktor initialisiert. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 205 5. Der Konstruktor der abgeleiteten Klasse wird ausgeführt. Die Aktionen und ihre Reihenfolge entsprechen damit genau dem was man erwartet. Beispiel: class A { public: A () : a(0) {} A (int p) : a(p) {} int a; }; class B : public A { public: B () : b(1) {} int b; }; int main () { B b; } Bei der Initialisierung von b ist die Reihenfolge im Detail 1. A–Komponenten initialisieren: (a) b.a wird vom Initialisierer mit belegt. (b) Defaultkonstruktor von A wird aktiviert. 2. B–Komponenten initialisieren: (a) b.b wird vom Initialisierer mit belegt. (b) Defaultkonstruktor von B wird aktiviert. 7.5.2 Konstruktor der Basisklasse aufrufen Bei der Initialisierung eines Objekts der Klasse C class C { pubblic: C() {} // ruft D::D() D d; }; kann der Aufruf des Defaultkonstruktors für die Komponente d mit einen Initialisierer durch einen expliziten Konstruktoraufruf ersetzt werden: class C { pubblic: C() :d(z) {} // Initialisierer: Aufruf von D::D(Z) statt D::D() D d; }; Genauso kann bei abgeleiteten Klassen der Aufruf des Defaultkonstruktors für die Basisklasse mit einen Initialisierer durch einen anderen Konstruktor ersetzt werden. Beispiel: class A { // Basisklasse public: A () : a1(0) {} A (int p) : a(p) {} int a; }; Programmierung II 206 class B : public A { // Ableitung public: B () : A(5), // <- Initialisierer fuer A, ersetzt Aufruf von A::A() b(1) {} // durch Aufruf von A::A(int) int b; }; int main () { B b; } In diesem Beispiel wird der ererbte Anteil nicht mit dem Defaultkonstruktor sondern mit dem explizit angegebenen alternativen Konstruktor A::A(int) initialisiert. Der Konstruktor der Basisklasse muss vor die Initialisierer der B–Komponenten platziert werden, da er die Initialisierung von A abschließt und diese immer der Initialisierung von B und seiner Komponenten vorangeht. Initialisierung von Objekten abgeleiteter Klassen: Die Basisklasse wird bei der Initialisierung wie eine Komponente behandelt: sie wird von ihrem Defaultkonstruktor initialisiert, es sei denn, es gibt einen Initialisierer für die Basisklasse, der die Aktivierung eines anderen Konstruktors verlangt. 7.5.3 Destruktoren Destruktoren werden in umgekehrter Reihenfolge der Konstruktoren aktiviert: 1. Der Destruktor der abgeleiteten Klasse. 2. Der Destruktor der Komponenten der abgeleiteten Klasse. 3. Der Destruktor der Basisklasse. 4. Der Destruktor der Komponenten der Basisklasse. Beispiel: class A { public: ˜A(){...} .. Z z; }; class B : public A { public: ˜B () { delete p; } X * p; Y y; }; Die Reihenfolge ist hier: 1. Destruktor von B mit delete p; (löst selbst eventuell weitere Destruktoraufrufe aus). 2. Destruktor von Y für B::y (falls Y eine Klasse mit Destruktor ist). 3. Destruktor von A. 4. Destruktor von Z für A::z (falls Z eine Klasse mit Destruktor ist). Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 207 Zerstörung von Objekten abgeleiteter Klassen: Die Basisklasse wird bei der Zerstörung wie eine Komponente behandelt: vor der Zerstörung des Objekts wird der Destruktor ihrer Basiskalsse aktiviert. 7.5.4 Kopierkonstruktor einer abgeleiteten Klasse Der Kopierkonstruktor einer abgeleiteten Kasse verhält sich wie die anderen Konstruktoren der Ableitung: Wenn im Programmcode nichts anderes gesagt wird, dann wird die Basisklasse und alle Komponenten mit dem zuständigen Defaultkonstruktor initialisiert. Beispiel: class Basis { ... Basis() : b(0) {} Basis(Basis & p_basis) : b(p_basis.b) {} int b; ... }; class Abgeleitet : public Basis { ... // Kopierkonstruktor, Version 1 (meist NICHT KORREKT) // Abgeleitet(Abgeleitet &p_ab) : a(p_ab.a) // kein Wort ueber b : b.Basis() wird aktiviert {} // Kopierkonstruktor, Version 2 (meist KORREKT) // Abgeleitet(Abgeleitet &p_ab) : Basis(p_ab) // Basis-Anteil initialisieren (mit Kopierkonstr.) : a(p_ab.a) // Abgeleitet-Anteil initialisieren {} int a; ... } Der Kopierkonstruktor der abgeleiteten Klasse sagt in der ersten Version nichts über die Belegung der Basis– Anteile beim Kopieren. Sie werden darum in der üblichen Art mit dem Defaultkonstruktor von Basis belegt. In der zweiten Version wird der Basisanteil explizit über den Kopierkonstruktor initialisierit. In den den meisten Fällen ist die zweite Version das, was man eigentlich will. Die Tatsache, dass der Kopierkonstruktor der abgeleiteten Klasse implizit den Defaultkonstruktor der Basisklasse aufruft, um seine Komponenten zu initialisieren – und nicht etwa den Kopierkonstruktor – kann zu Überaschungen führen: class Basis { public: Basis() : x(0) {} Basis(int pb) : x(pb) {} Basis(Basis &b): x(b.x) {} int x; }; class Abgeleitet : public Basis { public: Abgeleitet() {} Abgeleitet(int y) : Basis(y) {} Abgeleitet(Abgeleitet &a) {} // Kopierkonstruktor ACHTUNG Programmierung II 208 }; // ruft Basis::Basis() int main() { Abgeleitet a1(4711); Abgeleitet a2(a1); // Kopierkonstruktor cout << "a1.x: " << a1.x << " a2.x: " << a2.x << endl; // Ausgabe a1.x: 4711 // Ausgabe a2-x: 0 <--- erwartet 4711 } Hier wird a2.x mit belegt. Der Kopierkonstruktor von Abgeleitet ruft den Defaultkonstruktor zur Initialisierung seines Basis–Anteils. Das ist sicher nicht das was gewollt war. Wenn aber der Kopierkonstruktor der abgeleiteten Klasse den Kopierkonstruktor der Basisklasse rufen soll, dann muss man es ihm explizit sagen: class Basis { ... wie oben ... } class Abgeleitet : public Basis { public: Abgeleitet () {} Abgeleitet (int y) : Basis(y) {} Abgeleitet (const Abgeleitet &a) : Basis(a) // <-- OK: KOPIER-Konstruktor aktivieren {} }; int main() { Abgeleitet a1(4711); Abgeleitet a2(a1); cout << "a1.x: " << a1.x << " a2.x: " << a2.x << endl; // Ausgabe a1.x: 4711 // Ausgabe a2-x: 4711 <<-- OK } Dieses Verhalten mag auf den ersten Blick verwunderlich erscheinen. Es ist aber letzlich schlüssig: Durch die Definition eines Kopierkonstruktors gibt der Programmierer bekannt, dass er die Erzeugung von Kopien selbst steuern will.19 Der Compiler bereitet ihm mit dem Defaultkonstruktor ein korrekt konstruiertes “leeres” Objekt vor, das er dann nach eigenem Geschmack und auf eigene Verantwortung mit der Kopie füllen kann. Wenn er auf die Füllung verzichtet, bleibt es “leer”. 7.5.5 Automatisch erzeugter Kopierkonstruktor einer abgeleiteten Klasse In C++ gibt es viele Fälle, in denen es besser ist, das Programmieren dem Compiler zu überlassen. Ein solcher Fall kann der Kopierkonstruktor für abgeleitete Klassen sein. Kopierkonstruktoren gehören zu den Dingen die der Compiler generiert, wenn sie nicht explizit definiert werden. Der Compiler–erzeugte Code macht in vielen Fällen das was man von ihm erwartet. So ist es auch mit dem erzeugten Kopierkonstruktor einer abgeleiteten Klasse: Er kopiert meist korrekt. Fehlt der Kopierkonstruktor einer abgeleiteten Klasse, erzeugt der Compiler einen. Der erzeugte Kopierkonstruktor ruft den Kopierkonstruktor aller Datenkomponenten der Klasse und den Kopierkonstruktor der Basisklasse. Beispiel: class Basis { ... wie oben ... } class Abgeleitet : public Basis public: 19 Und es dann auch können sollte! { Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 209 Abgeleitet() {} Abgeleitet(int y) : Basis(y) {} // OK: KEINEN Kopier-Konstruktor definieren // Compiler erzeugt den richtigen }; int main() { Abgeleitet a1(4711); Abgeleitet a2(a1); cout << "a1.x: " << a1.x << " a2.x: " << a2.x << endl; // Ausgabe a1.x: 4711 // Ausgabe a2-x: 4711 <<-- OK } Ein fehlender Kopierkonstruktor wird also nicht von der Basisklasse ererbt sondern automatisch generiert. Der automatisch generierte entspricht dem in folgender Definition: class Basis { ... wie oben ... } class Abgeleitet : public Basis { public: ... Abgeleitet(const Abgeleitet & a) // Kopierkonstruktor entsprechend : Basis(a) {} // dem generierten }; In der abgeleiteten Klasse sollte ein Kopierkonstruktor nur dann definiert werden, wenn der Kopierkonstruktor der Basisklasse zusammen mit den Kopierkonstruktoren der Komponenten nicht geeignet ist, Objekte der abgeleiteten Klasse zu initialisieren. In dem Fall muss die gesamte Kopieraktion selbst definiert werden. Kopierkonstruktor abgeleiteter Klassen: Selbst definiert: Die Basisanteile der abgeleiteten Klassen werden bei einem selbst definierten Kopierkonstruktor nur dann vom Kopierkonstruktor der Basisklasse belegt, wenn dies explizit so programmiert wurde. Automatisch erzeugt: Die Basisanteile der abgeleiteten Klassen werden von einem vom Compiler generierten Kopierkonstruktor durch den Kopierkonstruktor der Basisklasse belegt. 7.5.6 Zuweisungsoperator einer abgeleiteten Klasse Eine ähnliche Argumentation wie für den Kopierkonstruktor gilt auch für den Zuweisungsoperator. Fehlt er in der abgeleiteten Klasse, dann wird er automatisch generiert. Der erzeugte Zuweisungsoperator macht dabei das was man von ihm erwartet: er ruft den Zuweisungsoperator der Komponenten und der Basisklasse: class Basis { ... wie oben ... } class Abgeleitet : public Basis { public: Abgeleitet() {} Abgeleitet(int y) : Basis(y) {} // OK: keinen Kopier-Konstruktor definieren // OK: KEINEN Zuweisungsoperator definieren !! }; int main() { Abgeleitet a1(4711); Programmierung II 210 Abgeleitet a2; a2 = a1; cout << "a1.x: " << a1.x << " a2.x: " << a2.x << endl; // Ausgabe a1.x: 4711 // Ausgabe a2-x: 4711 <<-- OK } Der automatisch erzeugte Zuweisungsoperator entspricht in unserem Beispiel dem in folgender Definition: class Basis { ... wie oben ... } class Abgeleitet : public Basis { public: ... // Zuweisungsoperator so wie vordefiniert // Abgeleitete & operator= (const Abgeleitet & a) { if ( this != &a ) this->Basis::operator=(a); // Zuweisung der Basisklasse return *this; } }; Weiter oben wurde im Kopierkonstruktor der abgeleiteten Klasse, als Initialisierer, der Kopierkonstruktor der Basisklasse aktiviert. Im Zuweisungsoperator hier sind Initialisierer nicht erlaubt, wir rufen darum explizit den Zuweisungsoperator der Basisklasse auf (Basis::operator=). Wird in einem selbst definierten Zuweisungsopertor nichts explizit über die Initialisierung der Basis–Anteile gesagt, dann werden sie auch nicht verändert! Zuweisungsoperator abgeleiteter Klassen: Selbst definiert: Die Basisanteile der abgeleiteten Klassen werden bei einem selbst definierten Zuweisungsoperator nur dann vom Zuweisungsoperator der Basisklasse belegt, wenn dies explizit so programmiert wurde. Automatisch erzeugt: Die Basisanteile der abgeleiteten Klassen werden von einem vom Compiler generierten Zuweisungsoperator durch den Zuweisungsoperator der Basisklasse belegt. 7.5.7 Muster für Kopierkonstruktor und Zuweisungsoperator abgeleiteter Klassen Wenn, wie üblich und anders als in unserem Beispiel oben, die abgeleitete Klasse eigene Komponenten hat, dann müssen sie natürlich auch in einem definierten Kopierkonstruktor und Zuweisungsoperator kopiert werden. class Basis { ... irgend etwas ... }; // Muster zur Kopie abgeleiteter Klassen // class Abgeleitet : public Basis { public: ... // MUSTER KOPIER-Konstruktor einer Ableitung ---------------// Abgeleitet(const Abgeleitet & a) : Basis(a), // Basisanteil kopieren x(a.x) // eigene Komponente kopieren Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 211 {} // MUSTER ZUWEISUNGS-Operator einer Ableitung --------------// Abgeleitet & operator= (const Abgeleitet & a) { if ( this != &a ) { this->Basis::operator=(a); // Basisanteil kopieren x = a.x; // eigene Komponente kopieren } return *this; } private: X x; // eigener Anteil }; Man beachte wie hier mit dem Bereichsoperator (Basis::) auf eine verdeckte Methode der Basisklasse zugegriffen wird. 7.5.8 Fehlender Defaultkonstruktor in einer abgeleiteten Klasse Defaultkonstruktoren werden nicht automatisch erzeugt. Von dieser Regel gibt es eine Ausnahme: Wenn eine Komponente einer Klasse eine Klasse mit Defaultkonstruktor hat, dann wird dieser auch dann automatisch aktiviert, wenn die Klasse selbst keinen Defaultkonstruktor hat. Beispiel: class K { public: K () : x(0) {} int x; }; class C { public: K k; }; int main() { C c; } // ohne jeden Konstruktor // Komponente mit Defaultkonstruktor // aktiviert K::K() Diese Regel wird auf abgeleitete Klassen übertragen: Hat die Basisklasse einer abgeleiteten Klasse einen Defaultkonstruktor, die abgegeleitete Klasse selbst aber keinen, dann wird trotzdem der Defaultkonstruktor der Basisklasse aktiviert: class Basis { public: Basis () : x(0) {} int x; }; class Abgeleitet : public Basis { // Kein Konstruktor ... }; int main() { Abgeleitet a; } // aktiviert Basis::Basis() Programmierung II 212 7.6 7.6.1 Beispiel Sortierte Liste: Abgeleitete Klasse als konkreter Datentyp Eine Liste Eine einfache Cursor-basierte Liste als konkreter Datentyp sieht etwa wie folgt aus: class List { public: List () : head (0) {} ˜List () { delete head; } List (const List &p_l) { ... tiefe Kopie ... } List & operator= (const List &p_l) { ... } void insert (int i) { head=new Node(i, head); } void reset () { cursor=0; } bool forward () { if (cursor == 0) cursor=head; else cursor=cursor->next; return cursor != 0; } int & current () { return cursor->v; } private: class Node { public: Node () : v(0), next(0){} Node (int i, Node * n) : v(i), next(n){} ˜Node () { delete next; } int v; Node * next; }; Node * head; Node * cursor; }; Die Klasse definiert Konstruktor, Kopierkonstruktor, Destruktor und Zuweisungsoperator. Sie ist damit in all ihrer Bescheidenheit eine vorbildliche Klasse, ein konkreter Datentyp. 7.6.2 Eine sortierte Liste ist eine Liste Listen und sortierte Listen sind sich sehr ähnlich. Der einzige Unterschied besteht darin, dass die sortierte Liste ihre Elemente sortiert einfügt. Eine sortierte Liste kann also als Ableitung der Liste mit überschriebener Einfügeoperation definiert werden. class SortedList : public List { ... void insert(int i) { ... sortiert einfuegen ... }; ... } 7.6.3 protected: Implementierungsdetails verfügbar machen Die sortierte Liste muss beim Einfügen nach einem passenden Platz suchen und das neue Element einfügen. Dazu muss auf die Knotenstruktur der Implementierung von List zugegriffen werden. Der einfachste Weg ihr den Zugriff zu ermöglichen ist, der abgeleiteten Klasse den vollen Zugriff auf die Implementierung zu geben. Aus private wird protected und List hat gegenüber ihrer Ableitung keine Geheimnisse mehr: class List { Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 213 ... wie oben ... protected: // statt private ... wie oben ... }; class SortedList : public List { public: ... void insert(int i) { Node ** p; for( p = &head; *p != 0; p = &((*p)->next) ){ if ((*p)->v >= i) break; } *p = new Node(i,*p); } }; Die Klasse List teilt sich ihre Implementierungsdetails mit all ihren Ableitungen. In dem Fall, dass nur eine Ableitung definiert wird, mag das akzeptabel sein. Die Basisklasse und ihre Ableitung haben dabei aber eine bedenklich hohe Kopplung. 7.6.4 protected: Ableitungsschnittstelle definieren Der politisch korrekte Weg, abgeleiteten Klassen Zugriff auf die eigene Implementierung zu geben, besteht darin, mit protected eine echte Schnittstelle zu den Ableitungen zu definieren. Etwa wie folgt: class List { public: //-------------------------- Das duerfen alle Klassen benutzen List () : head (0) {} ˜List () { delete head; } List (const List &p_l) { ... } List & operator= (const List &p_l) { ... } void insert (int i) { ... } void reset () { ... } bool forward () { ... } int & current () { ... } protected: //----------------------- Das duerfen Ableitungen benutzen void insert_sorted (int i) { Node ** p; for( p = &head; *p != 0; p = &((*p)->next) ){ if ((*p)->v >= i) break; } *p = new Node(i,*p); } private: //------------------------- Das darf niemand sonst benutzen ... wie oben ... }; class SortedList : public List { public: ... void insert(int i) { insert_sorted (i); } }; Programmierung II 214 Zugegeben, in diesem Beispiel ist die Trennung von private– und protceted–Schnittstelle vielleicht ein wenig übertrieben korrekt. Das Beispiel ist allerdings auch ein wenig übertrieben einfach. 7.6.5 Konstruktor, Destruktor und Zuweisung Für die abgeleitete Klasse muss außer der neuen Einfügeoperation nichts definiert werden: Der Compiler übernimmt es für uns, Kopierkonstruktor und Zuweisungsoperator zu generieren. Wenn es reicht alle Komponenten zu kopieren und die Basisklasse einen korrekten Kopierkonstruktor und Zuweisungsoperator hat, dann ist der erzeugte Kopierkonstruktor und Zuweisungsoperator korrekt und ausreichend. Das trifft hier zu: Die sortierte Liste hat keine eigenen Komponenten und die Basisklasse ist mit korrekten Kopieraktionen ausgestattet. Wenn alle Konstruktoren fehlen, dann beschwert sich der Compiler auch nicht über einen fehlenden Defaultkonstruktor. Fehlt der Defaultkonstruktor, dann werden trotzdem alle Komponenten mit einer Klasse als Typ mit deren Defaultkonstruktor initialisiert. Das trifft hier nicht zu: Die sortierte Liste hat keine eigenen Komponenten. Ebenso werden Objekte einer abgeleiteten Klasse ohne Defaultkonstruktor, deren Basisklasse einen Defaultkonstruktor hat, mit dem Defaultkonstruktor der Basisklasse initialisiert. Das trifft hier zu: List hat einen Defaultkonstruktor. Für ein Objekt mit einer abgeleiteten Klasse wird der Destruktor einer Basisklasse auch dann aktiviert, wenn die abgeleitete Klasse keinen Destruktor hat. Das trifft hier zu: die basisklasse hat einen korrekten Destruktor. Die abgeleitete Klasse ist damit einfach: class SortedList : public List { public: // Defaultkonstruktor muss nicht definiert werden, Basisklasse wird trotzdem // initialisiert: sie hat einen Defaultkonstruktor. // Kopierkonstruktor wird erzeugt, muss nicht definiert werden: // Die Basisklasse hat einen geeigneten Kopierkonstruktor. // Zuweisungsoperator wird erzeugt, muss nicht definiert werden: // Die Basisklasse hat einen geeigneten Zuweisungsoperator. // Destruktor muss nicht definiert werden: Der Destruktor der Basisklasse // wird aktiviert, auch ohne Destruktor in der Ableitung. void insert(int i) { insert_sorted (i); } }; 7.7 7.7.1 Beispiel Syntaxbaum: Ableitung um Typvarianten zu ermöglichen Ohne Vererbung: Vereinigung von Varianten in einem Typ Vererbung wird typischerweise eingesetzt um Typinformationen vom Compiler verwalten zu lassen. Betrachten wir dazu als Beispiel die arithmetischen Ausdrücke die wir weiter oben in einer Variante ohne Vererbung betrachtet haben (siehe Abschnitt 5.7). Die Knoten eines Syntaxbaums waren dort entweder Wertknoten mit einer Zahl als Wert, oder Operatorknoten mit einem Operator und zwei Verweisen auf die Knoten der Unterausdrücke. Beide Varianten eines Knotens waren in einer Klasse Ausdruck::Knoten zusammengefasst: class Ausdruck::Knoten { public: ... diverse Methoden ... Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 215 private: enum Art {wertAusdr, opAusdr}; Art art; // welche Variante von Knoten liegt vor Zahl wert; // Wert eines Wertknotens char op; // Operator eines Operatorknotens Knoten *l, *r; // Verweise eines Operatorknotens }; Da ein Zeiger immer nur auf Knoten eines bestimmten festen Typs zeigen kann, musste in verschwenderischer Weiese in jedem Knoten Platz für jede Variante freigehalten werden, auch wenn jeweils nur ein Teil des Speicherplatzes zur Speicherung der Daten genutzt wurde. Ein explizites Typfeld art diente dazu, über die aktuelle Variante des Knotens zu informieren. 7.7.2 Ableitungen als Varianten Das Konzept der Vererbung erlaubt es uns die Varianten der Knoten durch entsprechende Typen darzustellen. Die Zeiger zeigen formal auf den Basistyp Knoten, aktuell werden aber Objekte der abgeleiteten Typen WertKnoten und OpKnoten erzeugt: class Ausdruck::Knoten { ... }; // Basisklasse // Variante 1: Wert-Knoten // class Ausdruck::WertKnoten : public Ausdruck::Knoten { public: ... private: Zahl wert; }; // Variante 2: Operator-Knoten // class Ausdruck::OpKnoten : public Ausdruck::Knoten { public: ... private: char op; Knoten *l, *r; // Zeiger auf Basisklasse }; Mit diesen Definitionen können unterschiedliche Arten von Knoten erzeugt werden. Ein Leseoperator, der entsprechende Syntaxbäume aufbaut, könnte folgende Struktur haben: class Ausdruck { friend std::istream & operator>> (std::istream &, Ausdruck &); ... Ausdruck (char p_op, const Ausdruck & p_a1, const Ausdruck & p_a2) : ak(new OpKnoten(p_op, p_a1.ak, p_a2.ak)) {} ... Knoten * ak; }; ... std::istream & operator>> (std::istream &is, Ausdruck &a) { char z; Programmierung II 216 Ausdruck a1, a2; char op; do { if (!(is >> z)) return is; switch (z){ ... case ’(’ : { is >> a1; is >> op; is >> a2 a = Ausdruck(op, a1, a2); break; } ... } } Der einzige Unterschied zu der weiter oben besprochenen Variante ohne Vererbung besteht darin, dass Unterknoten vom jeweils richtigen abgeleiteten Typ erzeugt werden. 7.7.3 Problem: Aktuelle Typinformation zur Laufzeit ist nicht zugreifbar Das Beispiel soll hier nicht weiter vertieft werden, denn es enthält einen schwerwiegenden Mangel: Wir können jetzt zwar unterschiedliche Arten von Knoten erzeugen, aber es gibt noch keine Möglichkeit auf die Information darüber zuzugreifen, um welche Art von Knoten es sich handelt. l und r zeigen auf Objekte mit einem abgeleiteten Typ. Da die Zeiger selbst aber vom Typ Knoten* sind, können sie nur mit der beschränkten Funktionalität der Basisklasse Knoten benutzt werden – sie sind damit wertlos: class Ausdruck { friend ostream & operator<< (ostream &, const Ausdruck &); ... public: ... private: class Knoten; Knoten * ak; }; // Basisklasse, hat nichts zum ausgeben !! // class Ausdruck::Knoten { public: ... ostream & write (ostream &os ) const {} }; class Ausdruck::WertKnoten : public Ausdruck::Knoten { public: ... ostream & write (ostream &os) const { // Wertknoten ausgeben cout << wert; } private: Zahl wert; }; class Ausdruck::OpKnoten : public Ausdruck::Knoten { public: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 217 ... ostream & write (ostream &) const { // OpKnoten ausgeben cout << "("; l->write(); cout << op; r->write(); cout << ")"; } private: char op; Knoten *l, *r; }; // WERTLOSER Ausgabeoperator fuer Ausdruecke !!! // ostream & operator<< (ostream &os, const Ausdruck &a) { return a.ak->write(os); // <<<----- IMMER Aufruf von Knoten::write } // unabhaengig vom wahren Typ von *ak !! Das Konzept der Vererbung erlaubt es also Varianten von Knoten als Objekte einer entsprechenden Unterklasse anzulegen. Es ist mit den bisher vorgestellten Mitteln nicht möglich zur Laufzeit automatisch die Ausgabefunktion aufzurufen, die zu dem wahren Typ des jeweiligen Knotens gehört. Man könnte sich mit einem Typfeld behelfen, würde dann aber im Wesentlichen wieder bei der ersten Variante ohne Verebung laden. Um die Varianten als Ableitungen eines Basistyps nicht nur aufbauen, sondern auch nutzen zu können, benötigen wir ein Verfahren, das es erlaubt zur Laufzeit auf die aktuelle Typinformation eines Objekts zugreifen zu können, beispielsweise um die richtige Ausgabefunktion zu aktivieren: std::ostream & operator<< (std::ostream &os, const Ausdruck &a) { ... Aufruf: ak->OpKnoten::write() falls *(a.ak) vom Typ OpKnoten ... ... Aufruf: ak->WertKnoten::write() falls *(a.ak) vom Typ WertKnoten ... Der nächste Abschnitt führt mit dem Polymorphismus einen entsprechenden Mechanismen ein. Programmierung II 218 7.8 Übungen Aufgabe 1 Welche Ausgabe erzeugt: class Basis { public: Basis () : x(10) {} void f () { ++x; } int x; }; class Abgeleitet : public Basis { public: Abgeleitet () {} void f () { --x; } }; int main() { Abgeleitet * pa = new Abgeleitet; Basis * pb = pa; pa->f(); pb->f(); cout << pa->x << endl; cout << pb->x << endl; } Aufgabe 2 Betrachten Sie folgende Klassendefinitionen: class Queue { public: Queue () {} Queue (const Queue &q) : l(q.l) {} int getFirst () { return l.front(); } void put (int i) { l.push_back(i); } protected: list<int> l; }; class Deque : public Queue { public: int getLast () { return l.back(); } }; 1. Was ist mit dem Defaultkonstruktor von Deque, darf er wirklich fehlen? 2. Hat die abgeleitete Klasse einen korrekten Kopierkonstruktor und Zuweisungsoperator? Wenn ja: definieren Sie den Kopierkonstruktor und den Zuweisungsoperator, der jeweils dem automatisch erzeugten entspricht. Wenn nein: Definieren Sie einen korrekten Kopierkonstruktor und Zuweisungsoperator! Aufgabe 3 Ist folgendes Programm korrekt? Wenn ja, welche Ausgabe erzeugt es? Wenn nein, warum nicht? Th Letschert, Fachbereich MNI, FH Giessen–Friedberg #include <iostream> using namespace std; class Basis { public: Basis () : x(1), y(2), z(3) {} Basis (int px, int py, int pz) : x(px), y(py), z(pz) {} void print() { cout << x << ", " << y << ", " << z << endl; } int x; protected: int y; private: int z; }; class Abgeleitet : public Basis { public: Abgeleitet () : Basis (111, 112, 113), x(Basis::x+1000), y(Basis::y+1000) {} void print() { cout << x << ", " << y << endl; } int x; private: int y; }; int main () { Basis * pa; Abgeleitet a; pa = &a; a.print(); pa->print(); } Aufgabe 4 Ist der Kopierkonstruktor von Basis und Abgeleitet korrekt: class Basis { public: Basis (int x) : _x(x) {} Basis () : _x(-1) {} Basis (const Basis & b) : _x(b._x) {} private: int _x; }; class Abgeleitet : public Basis { public: 219 220 Programmierung II Abgeleitet (int x, int y) : Basis(x), _y(y) {} Abgeleitet() {} Abgeleitet(const Abgeleitet & a) : _y(a._y) {} private: int _y; }; Wenn nein koorigieren Sie ihn! Schreiben Sie für beide Klassen korrekte Zuweisungsoperatoren! Müssen für diese Klassen Zuweisungsoperator und Kopierkonstruktor überhaupt definiert werden? Aufgabe 5 Das Programm #include <iostream> using namespace std; class Basis { public: Basis() : b(0) {} Basis(int p_b): b(p_b) {} Basis & operator= (const Basis & p_basis) { if ( this != &p_basis ) b = p_basis.b; return *this; } void schreib () { cout << " b = " << b << endl; } private: int b; }; class Ab : public Basis { public: Ab() : a(0) {} Ab(int p_a) : Basis(p_a), a(p_a) {} Ab & operator= (const Ab & p_ab) { if ( this != &p_ab ) a = p_ab.a; return *this; } void schreib () { Basis::schreib(); cout << " a = " << a << endl; } private: int a; }; int main () { Ab ab1(7), ab2(13); ab1 = ab2; cout << "ab1: \n"; ab1.schreib(); cout << "ab2: \n"; ab2.schreib(); } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 221 erzeugt die Ausgabe: ab1: b = a = ab2: b = a = 7 13 13 13 Das ist nicht unbedingt das, was man als Ergebnis einer Zuweisung erwartet. Erklären Sie die Ausgabe und korrigieren Sie den Zuweisungsoperator! Hätte ein automatisch generierter Zuweisungsoperator das Richtige getan? Programmierung II 222 8 Polymorphismus 8.1 8.1.1 Virtuelle Methoden: Das Gleiche auf unterschiedliche Arten tun Eine Basisklasse für Klassen mit nur ideellen Gemeinsamkeiten Egal ob man vom Allgemeinen zum Speziellen geht oder umgekehrt, beim Entwurf einer Klassenhierarchie gilt es die Gemeinsamkeiten und das Trennende von Klassen festzustellen und in einem entsprechenden Ableitungssystem festzuhalten. In den Beispielen weiter oben haben wir es meist mit eindeutigen gemeinsamen Komponenten zu tun, z.B. der Name bei Angestellten (Basisklasse) und Managern (Ableitung). Die Vererbungsrelation kann aber auch auf Klassen ausgedehnt werden, die keine konkreten gemeinsamen Komponenten haben, aber trotzdem irgendwie “von gleicher Art” sind und auch gemeinsam behandelt werden sollen. Bei unseren geometrischen Objekten haben wir das Spezielle in Form der Punkten, Vektoren und Geraden. Wir suchen also das Allgemeine in Form des Gemeinsamen mehrerer gegebener Definitionen. Zunächst müssen wir feststellen, dass Punkte, Vektoren und Geraden keine gemeinsamen Datenkomponenten haben. Das Gemeinsame aller geometrischen Klassen ist “ideeller” Natur. Worauf beruht die Idee der Gemeinsamkeit? Wir möchten geometrische Objekte verarbeiten können, ohne Rücksicht darauf, um welche Art es sich jeweils genau handelt. Uns schweben also Programme folgender Art vor: int main () { Geo *g; // g zeigt auf ein geo.-Objekt irgendeiner Unterart cin >> *g; // ein geo.-Objekt irgendeiner Unterart einlesen .. geometrisches Objekt verarbeiten .. cout << *g; } Wir wollen ein geometrisches Objekt irgendeiner Unterart einlesen und verarbeiten. Aus der Diskussion über den Unterschied zwischen der Manipulation von Objekten einer Basisklasse und Zeigern auf die Basisklasse wissen wir, dass mit Zeigern gearbeitet werden muss. Es soll ja vermieden werden, dass geometrische Objekte bei jeder Zuweisung auf ihren Geo–Kern reduziert werden. 8.1.2 Ein–/Ausgabe für die Basisklasse und ihre Ableitungen Die erste Gemeinsamkeit die wir anstreben ist die Ein–/Ausgabe. Damit sie gemeinsam in allen Klassen genutzt werden kann, muss die Basisklasse sie definieren und an all ihre Ableitungen vererben. Etwa in folgender Art: class Geo { public: ... // PROBLEMATISCH: istream & read (istream &) { ..??.. } // Eingabe geometrisches Objekt ostream & write (ostream &) const { ..??.. } // Ausgabe geometrisches Objekt ... }; istream & operator>> (istream &is, Geo &go) { return go.read(is); } ostream & operator<< (ostream &os, const Geo &go) { return go.write(os); } class Vektor : public Geo { ... }; class Punkt : public Geo { ... }; class Gerade : public Geo { ... }; int main () { ... Geo-Objekte lesen, verarbeiten, ausgeben ... } Das Problem scheint fast gelöst: Wir definieren Lese– und Schreiboperationen in der Basisklasse Geo und benutzen sie in allen Ableitungen. Ein kleines Problem bleibt noch: die Lese– und Schreiboperationen in der Basisklasse Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 223 müssen noch definiert werden. Leider ist es unlösbar. Lesen und Schreiben hängt davon ab, um was für eine (Unter– ) Art von Objekt es sich handelt. Es kann also gar nicht in der Basisklasse definiert werden. Um die Art “ideeller Gemeinsamkeit” der geometrischen Klassen in einer Basisklasse konzentrieren zu können, muss der Mechanismus erweitert werden. 8.1.3 Basis und Ableitungen: Das Gleiche auf unterschiedliche Art tun Bei der Vererbung übernimmt eine Ableitung alle Daten–Komponenten und Methoden ihrer Basisklasse. Damit kann eine gemeinsame Funktionalität in der Basisklasse konzentriert werden. Datenkomponenten und Methoden sind in der Basisklasse und der Ableitung exakt gleich. Manchmal wünscht man sich Gemeinsamkeiten, ohne dass alles gleich identisch ist. Beispielsweise können Punkte, Vektoren und Geraden gelesen und geschrieben werden. Es ist darum sinnvoll read und write in der Basisklasse zu definieren und an alle Ableitungen zu vererben. Die Art, in der eine Ableitung liest und schreibt kann nicht gleich sein. Sie muss jeweils gesondert für Punkte, Vektoren und Geraden definiert werden: Die Gemeisamkeit der geometrischen Objekte ist die Tatsache, dass sie jeweils Lese– und Schreiboperationen zur Verfügung stellen. Die Lese– und Schreiboperationen selbst stellen aber keine Gemeinsamkeit dar, sie sind unterschiedlich. Die Lese– und Schreiboperation in der Basisklasse Geo sagen, dass alle geometrischen Objekte gelesen und geschrieben werden können. Die entsprechenden Methoden in den Ableitungen definieren wie das jeweils genau geht. Die Methoden in der Basisklasse sind dazu da um umdefiniert zu werden (siehe Abbildung 72). Geo +read +write alle Geo−Objekte können gelesen und geschrieben werden Vektor Punkt Gerade +read +write +read +write +read +write spezielle Lese− Schreiboperationen für die speziellen Unterarten von Geo Abbildung 72: Unterschiedliche Implementierung von Methoden in Basisklasse und Ableitung 8.1.4 Virtuelle Methode: in der Ableitung umdefinierte Methode Virtuelle Methoden sind zur Redefinition vorbereitete Methoden. Sie werden in der Basisklasse definiert und stehen darum in jeder Ableitung zur Verfügung. Die Ableitungen können die Methoden nach eigenen Bedürfnissen umdefinieren. class Geo { ... virtual istream & read (istream &) { } // Eingabe fuer Geo-Objekte, leer virtual ostream & write (ostream &) const { } // Ausgabe fuer Geo-Objekte, leer ... }; class Vektor : public Geo { ... istream & read (istream &) { ... } ostream & write (ostream &) const { ... } // Eingabe fuer Vektor-Objekte // Ausgabe fuer Vektor-Objekte Programmierung II 224 ... }; .. entspreched Punkt und Gerade ... Auf allen geometrischen Objekte sind jetzt mit read und write les– und schreibbar. Das Schlüsselwort virtual zeigt an, dass die entsprechende Methode in einer Ableitung neu definiert werden kann. Geo definiert read und write. Beide werden an alle Ableitungen vererbt, da sie virtuell sind, können sie dort umdefiniert werden. Sie müssen allerdings nicht umdefiniert werden. Der Begriff “virtual” ist hier eigentlich mit “redefinierbar” zu übersetzen. Wir sprechen zwar von “virtuellen Methoden”, an ihnen ist aber zunächst einmal ganz und gar nichts virtuelles. 20 8.2 8.2.1 Polymorphismus: Typanalyse zur Laufzeit Polymorphismus: der Typ des Objekts entscheidet zur Laufzeit über die aufgerufene Methode Bei virtuellen Methoden entscheidet der Typ des Objekts über die aufgerufene Methode. Das Phänomen, dass die gleichen Aufrufschnittstelle zu verschiedenen Methoden führt, wird dann Polymorphismus 21 genannt22, wenn die Entscheidung, welche Methode aktiv wird, zur Laufzeit erfolgt. class A { virtual void f (X p) { ... } // A::f polymorph ... }; class B public A { void f (X p) { ... } // POLYMORPHISMUS: A::f wird ersetzt ... }; A *a; B *b; X x; a = new A; a->f(x); // ruft A::f b = new B; b->f(x); // ruft B::f a = new B; a->f(x); // ruft B::f // <<--- OBWOHL a den Typ A* hat !!!! und WEIL *a JETZT den Typ B hat !!!! Hier wird mit a->f(x) die Methode B::f aktiviert, obwohl a den Typ A* hat. Da B::f virtuell ist, wird in dem Augenblick, in dem die Methode aufgerufen wird – also zur Laufzeit! – geprüft, welchen Typ das Objekt *a jetzt gerade hat und dann wird zur entsprechenden Methode verzweigt. 8.2.2 Redefinition ist kein Polymorphismus Das Schlüsselwort virtual zeigt an, dass die Entscheidung über die aufgerufene Methode zur Laufzeit, auf Basis des aktuellen tatsächlichen Typs des Objekts, erfolgt. Lassen wir im Beispiel oben etwa das virtual weg, dann wird aus dem Polymorphismus eine einfache Redefinition: class A { void f (X p) { ... } // A::f 20 In der Informatik muss aus irgendeinem unerfindlichen Grund alles “virtuell”, “transparent” oder “abstrakt” sein. Meist passen diese Begriffe auch, in diesem Fall allerdings passt “virtuell” aber nicht. 21 Vielgestaltigkeit, von vielerlei Gestalt 22 In C++ werden polymorphe Methoden also “virtual” genannt! “Polymorph” ist der passende Begiff, “virtuell” ist C++–Slang. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 225 ... }; class B public A { void f (X p) { ... } // REDEFINITION: B::f ueberschreibt A::f ... }; A *a; B *b; X x; a = new A; a->f(x); // ruft A::f b = new B; b->f(x); // ruft B::f, A::f ist ueberdeckt a = new B; a->f(x); // ruft A::f <<--- WEIL a den Typ A* hat !!!! Hier wird mit a->f(x) die Methode A::f aufgerufen. Der Compiler stellt – zur Übersetzungszeit! – fest dass a den Typ A* hat und entscheidet, dass mit a->f() die Methode A::f() zu aktivieren ist. Der aktuelle Wert von a ist irrelevant. 8.2.3 Überladung ist kein Polymorphismus Polymorphismus und Redefinition einer Methode werden gelegentlich auch mit Überladung verwechselt. Virtuelle und redefinierte Methoden sind keine überladenen Methoden. Bei der Überladung entscheidet der Typ der Parameter darüber, welche Funktion oder Methode genau aufgerufen wird. class C { void f (X p) { ... } void f (Y p) { ... } ... }; C X c; x; // f Nr-1 // f Nr-2 ueberladen ueberladen C * pc = new C; Y y; // Ueberladung // c.f(x); // f c.f(y); // f pc->f(x) // f pc->f(y) // f aufloesen durch Typ des Parameters (unabhaengig von Zeigern) Nr-1 Nr-2 Nr.-1 Nr.-2 Zusammengefasst sind die wesentlichen Eigenschaften der drei Arten, verschiedene Methoden über einen Namen anzusprechen: Überladung: Der Typ der Argumente entscheidet über die aufgerufene Funktion/Methode. Überladung wird immer zur Übersetzungszeit aufgelöst. Redefinition einer Methode in der Ableitung: Der Typ der Objekts entscheidet – zur Übersetzungszeit – über die aufgerufene Methode. Polymorphismus (Ersatzdefinition in einer Ableitung): Der Typ des Objekts entscheidet – zur Laufzeit – über die aufgerufene Methode. Programmierung II 226 8.2.4 Polymorphismus benötigt Zeiger oder Referenzen Polymorphismus entfaltet seine Wirkung nur in Kombination mit Zeigern oder Referenzen. Ein Zeiger oder eine Referenz vom Typ der Basisklasse kann auch auf ein Objekt der abgeleiteten Klasse zeigen. In dem Fall wird die Methode aktiviert, die tatsächlich zu dem Objekt gehört: class A { ... virtual class B :public A { ... A a; B b; X x; A* pa; a = b; a.f(x); // A::f pa = &b; pa->f(x); // B::f void f (X p) { ... } ... }; void f (X p) { ... } ... }; kein Zeiger -> f der Basisklasse Zeiger -> f der abgeleiteten Klasse Bei der Parameterübergabe gilt entsprechend, dass bei Wertparametern die Methode aufgerufen wird, die zum formalen Parameter gehört, bei Referenzparametern dagegen die Methode die zum tatsächlichen Objekt gehört: class A { ... virtual class B :public A { ... void f (X p) { ... } ... }; void f (X p) { ... } ... }; X x; A a; B b; void gw (A a) { a.f(x); } // immer A::f void gr (A &a) { a.f(x); // A::f oder B::f, } je nach aktuellem Parameter gw(a); gw(b); // A::f // A::f Wertparameter -> f der Basisklasse gr(a); gr(b); // A::f // B::f Referenzparameter -> f des aktuellen Objekts 8.2.5 Einsatz von Polymorphismus Welchen Sinn machen virtuelle Methoden? Dadurch, dass Entscheidungen zur Laufzeit getroffen werden, macht Polymorphismus Programmstücke flexibler und vielseitiger einsetzbar. Er ergänzt in dieser Hinsicht den Vererbungsmechanismus in ganz wesentlicher Art. Vererbung bringt Flexibilität in die Programme: Zeiger auf Objekte können gespeichert werden ohne dass man sich vorher auf einen genauen Typ festlegen muss. Polymorphismus erweitert diese Flexbilität: Eine Methode wird aufgerufen, ohne dass man an der Aufrufstelle genau wissen muss, welche das sein wird. Polymorphismus macht damit Programme folgender Art möglich: int main (){ vector<Geo *> v; // Vererbung: v speichert alle Sorten v.push_back( new Vektor(1,2) ); // von geometrischen Objekten v.push_back( new Punkt(3,2) ); v.push_back( new Gerade ( Punkt(1,1), Vektor(1,1) ) ); ... for (unsigned int i = 0; i<v.size(); ++i) { // Polymorphismus: v[i]->write(cout); // Ausgabe ohne zu wissen Th Letschert, Fachbereich MNI, FH Giessen–Friedberg cout << endl; 227 // welchen Typ das Objekt genau hat } } In der Ausgabeschleife wird Vektor::write, oder Punkt::write, oder Gerade::write aufgerufen, je nach dem welchen Typ v[i] jeweils hat. Ohne Polymorphismus (virtuelle Methoden) würde immer nur Geo::write aufgerufen. 8.2.6 Virtuelle Methoden werden dynamisch gebunden Polymorphismus bringt dynamische Bindung in C++–Programme. Nicht wie sonst üblich entscheidet der Compiler zur Übersetzungszeit welche Methode aktiviert wird (statische Bindung). Zur Laufzeit wird festgestellt welchen Typ das Objekt hat und dann wird die entsprechende Methode aufgerufen. Statische Bindung bedeutet, dass die Bindung vom Namen einer Funktion oder Methode zur deren Definition statisch, d.h. zur Übersetzungszeit erfolgt. Dynamische Bindung bedeutet, dass der Bezug vom Namen zur Definition (die Bindung des Namens) dynamisch, d.h. zur Laufzeit, erfolgt. Es ist klar, dass die Flexibilität der dynamischen Bindung mit komplexerem Maschinencode und leicht erhöhter Laufzeit bezahlt werden muss. Der Compiler generiert Code, der den aktuellen Typ eines Objekts feststellt und dann in die entsprechende Methode verzweigt. 8.3 Konstruktoren, Destruktoren, Zuweisungen und Polymorphismus 8.3.1 Virtuelle Destruktoren, Nicht–virtuelle Konstruktoren Für Konstruktoren und Destruktoren gilt die Regel: Destruktoren können bei Bedarf virtuell sein. Konstruktoren können niemals virtuell sein. Ein Konstruktor dient dazu ein Objekt mit bekanntem Typ zu initialisieren. Der Typ ist dabei immer statisch – zur Übersetzungszeit – bekannt. Objekte werden ja nur auf Veranlassung des Programms erzeugt und dabei wird der exakte Typ angegeben. Bei der Vernichtung von Objekten ist die Lage etwas anders. Räumt man Objekte mit delete weg, dann erfolgt der Zugriff über einen Zeiger. Der Zeiger kann dabei formal auf ein Objekt der Basisklasse, tatsächlich aber auf ein Objekt einer abgeleiteten Klasse zeigen. Zur Übersetzungszeit ist im Allgemeinen nicht bekannt, auf was er tatsächlich zeigt. class Basis { ... }; class Ab1 : public Basis { ... }; class Ab1 : public Basis { ... }; Basis *b; b = new Basis; b = new Ab1; b = new Ab2; ... delete b; 8.3.2 // -> Konstruktor von Basis // -> Konstruktor von Ab1 // -> Konstruktor von Ab2 // -> Destruktor von Basis, Ab1 oder Ab2 ?? Zeiger auf Basisklasse: Virtueller Destruktor in der Basisklasse Ein delete auf einen Zeiger aktiviert den Destruktor. Hat der Zeiger den Typ “Zeiger auf eine Basisklasse”, dann kann nicht einfach der Destruktor der basisklasse aufgerufen werden. Beispiel: Programmierung II 228 class Geo { ... ˜Geo(){} // Destruktor ... }; Geo * pg; ... pg = new Vektor (2, 4); ... delete pg; // PROBLEM: ruft den Destruktor der Basisklasse Geo Hier wird der Destruktor von Geo auf ein Objekt angewendet, das tatsächlich vom Typ Vektor ist. Nicht nur, dass der Destruktor eventuell das Falsche tut, er gibt möglicherweise auch zu wenig Speicherplatz zurück. Objekte vom Basistyp benötigen ja in der Regel weniger Platz als Objekte mit einem abgeleiteten Typ. Die Kur ist einfach: Wenn immer Objekte abgeleiteter Klassen über Zeiger auf ihre Basisklasse manipuliert werden, sollte der Destruktor der Basisklasse virtuell sein: class Geo { ... virtual ˜Geo(){} // virtueller Destruktor ... }; ... Geo * pg; ... pg = new Vektor (2, 4); ... delete pg; // OK: ruft den Destruktor von Vektor Man beachte dass ein implizit durch den Compiler erzeugter Destruktor niemals virtuell ist. Virtuelle Destruktoren sind darum auch dann sinnvoll, wenn sie, wie in unserem Beispiel, nichts tun. 8.3.3 Polymorphismus und Zuweisungen Ein Zuweisungsoperator kann zwar virtuell definiert werden, die Zuweisung kann aber trotzdem nicht polymorph sein, da polymorphe Funktionen immer die gleichen Argumenttypen haben müssen. Entgegen dem ersten Eindruck ist darum in: class B { public: virtual B & operator= (const B &b) { // ACHTUNG: nicht so virtuell wie gedacht return *this; } }; class A : public B { public: A() : x(0) {} A & operator= (const A &a) { // anderer Argument-Typ als B::operator= x = a.x; return *this; } int x; }; der Zuweisungsoperator nicht so polymorph wie man es sich wünschen mag! Als Folge wird in int main() { B *b; A a1, a2; a1.x = 4711; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg b = &a2; 229 // b zeigt auf a2 // a2 mit dem Wert von a1 belegen: // *b = a1; // // cout << a2.x << endl; // Funktioniert nicht wie gedacht aktiviert B::operator= NICHT A::operator= (trotz virtual in B) Ausgabe: 0, NICHT 4711 } der Zuweisungsoperator der Basisklasse B::operator= aufgerufen. Der Zuweisungsoperator in B hat als Argument eine konstante Referenz auf ein B–Objekt. Der Zuweisungsoperator in A hat einen anderen Typ. Stimmen die Typen der Argumente nicht überein, dann sind die Methoden ohne Bezug. Im Beispiel oben ist B::operator= zwar virtuell, aber in A wird diese Methode nicht redefiniert: A::operator=(const B&) hat nichts mit B::operator(const A&) zu tun. 8.4 8.4.1 Abstrakte Basisklassen Geometrische Objekte mit Ausgabe Mit dem Einsatz virtueller Funktionen können wir jetzt geometrische Objekte mit Ein– und Ausgabe definieren: class Geo { public: virtual istream & read (istream &) {} // nichts tun virtual ostream & write (ostream &) {} // nichts tun }; class Vektor : public Geo { public: Vektor (); ... istream & read (istream &is) { return os << "V(" << x_ << ", " } ostream & write (ostream &os) const char c; is >> c; if (c != ’(’) {cerr << "FEHLER: ( is >> x_; is >> c; if (c != ’,’) {cerr << "FEHLER: , is >> y_; is >> c; if (c != ’)’) {cerr << "FEHLER: ) return is; } private: ... }; // Vektoren lesen << y_ << ")"; { // Vektoren schreiben erwartet !\n"; return is;} erwartet !\n"; return is;} erwartet !\n"; return is;} ... Punkt und Gerade entsprechend .. // Aufruf der virtuellen Methoden istream & operator>> (istream is&, Geo &go) { return go.read(is); } ostream & operator<< (ostream &os, const Geo &go) { return go.write(os); } int main (){ vector<Geo *> v; Programmierung II 230 v.push_back( new Vektor(1,2) ); v.push_back( new Punkt(3,2) ); v.push_back( new Gerade ( Punkt(1,1), Vektor(1,1) ) ); for (unsigned int i = 0; i<v.size(); ++i) { cout << *(v[i]) << endl; } } In der Basisklasse Geo sind read und write leere Funktionen die nichts tun. Ein Objekt vom Typ Geo stellt ein geometrisches Objekt in voller Allgemeinheit dar. Wie sollte man so etwas lesen oder schreiben? Mit einem Aufruf wie cout << *geo; // geo vom Typ Geo * wird über den Ausgabeoperator operator<< die Methode geo->write aufgerufen. Diese Methode ist virtuell, also wird abhängig davon, auf was geo gerade zeigt, wird die richtige Ausgabemethode aufgerufen. 8.4.2 Rein virtuelle Methoden Geometrische Objekte gibt es nicht als solche, sie existieren nur in den Varianten Punkt, Vektor und so weiter. Sie sollten darum keine Ein–/Ausgabe–Methoden mit leerem Körper haben. Sie sollten überhaupt keine haben. Gleichzeitig soll aber auch ausgedrückt werden, dass alle Ableitungen – die konkreten Klassen Vektor, etc. – sehr wohl alle ein read und write anbieten. Rein virtuelle Methoden drücken genau dies aus: “Für mich gibt es diese Methode nicht, aber meine Ableitungen werden sie definieren”. class Geo { ... virtual istream & read (istream &) = 0; // rein virtuelle Methode virtual ostream & write (ostream &) = 0; // gibt es fuer diese Klasse nicht }; class Vektor : public Geo { public: ... istream & read (istream &is) { .. Vektor einlesen .. } ostream & write (ostream &os) const { .. Vektor ausgeben .. } ... }; Mit “=0” statt einer Methodendefinition wird eine virtuelle Methode zu einer rein virtuellen Methode ohne Implementierung. 8.4.3 Abstrakte Basisklasse und Schnittstelle Klassen, die eine rein virtuelle Methode enthalten, können nur noch als Basisklassen genutzt werden. Es ist nicht möglich Objekte von diesem Typ anzulegen: Geo g; Geo *pg; // FEHLER Geo ist abstrakt // OK void f(Geo g) { .. } // FEHLER Geo ist abstrakt void f(Geo &g){ .. } // OK Klassen mit rein virtuellen Funktionen werden abstrakte Basisklassen genannt. Das trifft genau die Intention unserer Geo–Klasse. Es gibt keine Objekte mit Typ Geo, es gibt nur solche, deren Typ von Geo abgeleitet ist. Klassen, Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 231 die nichts als rein virtuelle Methoden enthalten, bezeichnet man häufig auch als Schnittstelle (engl. Interface). In diesem Sinn ist Geo eine reine Schnittstelle: Sie definiert das was benuzt werden kann, ohne eine Implementierung anzugeben. 8.4.4 Abstrakte Basisklassen in UML In UML werden abstrakte Klassen und rein virtuelle Methoden durch Kursivschrift dargestellt (siehe Abbildung 73): Geo read write Vektor Punkt Gerade Abbildung 73: Abstrakte Basisklasse in UML 8.5 8.5.1 Umschlagklassen Speichern und Schreiben Bei den geometrischen Objekten sind wir fast am Ziel. Sie können in allgemeiner Form gespeichert und ausgegeben werden. Es muss nur darauf geachtet werden, dass sie immer über Zeiger gespeichert und manipuliert werden, damit der Polymorphie–Mechanismus aktiv werden kann: int main() { vector<Geo *> v; ... v.push_back( new Vektor(1,2) ); cout << *(v[i]) << endl; ... } Die Zeiger haben zwar den Typ Geo*, sie zeigen aber immer auf ein Objekt mit einem konkreten Typ Punkt, Vektor oder Gerade. 8.5.2 Probleme beim Einlesen Beim Einlesen geometrischer Objekte gibt es aber immer noch ein Problem. Wir können nicht einfach schreiben: Geo *g; g->read(cin); // Wert von g undefiniert // g->read ist rein virtuell Die Variable g ist nicht initialisiert und kann darum nicht dereferenziert werden. Andere Lösungen fallen leider ebenso weg: Geo *g = new Geo; // geht nicht Geo ist abstrakt g->read(cin); // geht nicht Geo::read ist rein virtuell Die read–Methode ist rein virtuell und kann nicht aktiviert werden. read und write könnte man von rein virtuellen wieder zu nur virtuellen Methode machen. Geo wäre nicht mehr abstrakt und so könnte dann g mit einem Verweis auf ein Geo–Objekt belegt werden: Programmierung II 232 Geo *g = new Geo; g->read(cin); // // // // Geo nicht abstrakt Aufruf moeglich, bringt uns aber nicht weiter: *g bleibt aber fuer immer ein Geo, auch wenn ein Vektor gelesen wird Jetzt können wir zwar ein Geo–Objekt lesen, aber es müsste sich selbst dann in einen Vektor, Punkt oder eine Gerade verwandeln. Das ist nicht möglich. 8.5.3 Einlesen Eine Möglichkeit des Einlesens, die das bisher Erreichte an Flexibilität nicht wieder über Bord wirft, besteht darin, zuerst zu entscheiden, welche Art von Objekt denn angelegt werden soll und es mit einer spezialisierten Methode tatsächlich zu lesen: Geo *g; char c; cin >> c; // erstes Zeichen als Hinweis welche Unterart von Geo folgt switch (c) { case ’V’: g = new Vektor; break; case ’P’: g = new Punkt; break; case ’G’: g = new Gerade; break; default: .. FEHLER .. } g->read(cin); // *g kann sich jetzt selbst einlesen 8.5.4 Eine Umschlagklasse Diese Lösung funktioniert. Sie ist aber nicht besonders elegant. Die Leseoperation sollte innerhalb der geometrischen Objekte gekapselt werden. Wir definieren dazu ein Klasse GeoObjekt als Umschlag um die “polymorphen Zeiger” vom Typ Geo und starten von ihr aus die Leseaktion: // Umschlagklasse fuer Geo* // class GeoObjekt { friend istream & operator>> (istream &, GeoObjekt &); friend ostream & operator<< (ostream &, const GeoObjekt &); private: Geo * g; }; istream & operator>> (istream &is, GeoObjekt &go) { char c; is >> c; switch (c) { // nachsehen was da kommt case ’V’: case ’v’: go.g = new Vektor; break; case ’P’: case ’p’: go.g = new Punkt; break; case ’G’: case ’g’: go.g = new Gerade; break; default: exit(1); } return go.g->read(is); // die richtige Leseoperation aufrufen } ostream & operator<< (ostream &is, const GeoObjekt &go) { return go.g->write(is); Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 233 } ... int main (){ GeoObjekt g; cin >> g; cout << g << endl; } 8.6 8.6.1 // beliebiges geometrisches Objekt einlesen // und ausgeben Geklonte Objekte Geometrische Objekte mit Konstruktoren und Destruktoren Unsere geometrischen Objekte können jetzt mit Konstruktoren, Destruktoren und Zuweisungsoperatoren zu konkreten Datentypen komplettiert werden. Eigentlich benötigt nur Geo einen virtuellen Destruktur, der Rest der Maschinerie wird vom Compiler für alle außer GeoObjekt automatisch korrekt erzeugt. Die Umschlagklasse GeoObjekt bildet eine Ausnahme, da sie eine Zeigerkomponente enthält. Für sie muss ein Destruktor definiert werden, der den Zeigerwert wegräumt. Das ist kein Problem: class Geo public: virtual virtual virtual }; { ˜Geo() {} // der Destruktor von Basisklassen sollte virtuell sein istream & read (istream &) = 0; ostream & write (ostream &) const = 0; class GeoObjekt { friend istream & operator>> (istream &, GeoObjekt &); friend ostream & operator<< (ostream &, const GeoObjekt &); public: GeoObjekt () : g(0) {} ˜GeoObjekt () { delete g; } // Kopie erzeugen, noch zu definieren // GeoObjekt (const GeoObjekt &go) { ??? } GeoObjekt & operator= (const GeoObjekt &go) { ??? } private: Geo * g; }; Kopierkonstruktor und Zuweisungsoperator müssen selbst definiert werden, da die automatisch erzeugten nur flach kopieren, also nur den Zeiger kopieren und nicht die Datenstruktur, auf die der Zeiger zeigt. 8.6.2 Problem: Wie kopiert man Objekte mit unbekanntem Typ Es bleibt also noch das Problem den Kopierkonstruktor und Zuweisungsoperator zu definieren. Das sieht auf den ersten Blick einfach aus. Nehmen wir den Kopierkonstruktor. Ein GeoObjekt hat als Komponente einen Zeiger auf ein Geo. Ein Geo selbst hat überhaupt keine Komponenten. Wir legen einfach ein neues Objekt an, lassen die Kopie darauf zeigen und haben damit die tiefe Kopie: class GeoObjekt { public: ... // Kopierkonstruktor NICHT OK ! // Programmierung II 234 GeoObjekt (const GeoObjekt & go) : g(new Geo) {} ... private: Geo * g; // g zeigt auf eine Ableitung von Geo }; So einfach kann es nicht sein. Der Zeiger g in GeoObjekt zeigt nicht auf ein Geo sondern auf einen Punkt, Vektor oder eine Gerade. Kopiert man ein GeoObjekt, dann soll sicher nicht ein sinnloses Objekt der Basisklasse erzeugt werden. Das Objekt, auf das g tatsächlich zeigt, soll kopiert werden. Leider ist in GeoObjekt aber nicht bekannt, auf was g tatsächlich zeigt. Der new–Operator kann darum hier nicht aufgerufen werden. 8.6.3 Objekte die sich selbst klonen können GeoObjekt kann das, auf das g zeigt, nicht kopieren, da es seinen genauen Typ nicht kennt. Das Objekt *g kennt sich aber selbst und kann darum auch eine korrekte Kopie seiner selbst erzeugen. Wir definieren dazu eine Methode clone in jeder Ableitung und natürlich auch virtuell in der Basisklasse: class Geo { public: ... virtual Geo * clone () const = 0; // Geo-s koennen sich klonen .. }; class Vektor : public Geo { public: ... Vektor * clone () const { // ich klone mich selbst return new Vektor(*this); // mit meinem Kopierkonstruktor! } ... }; .. Punkt, Gerade entsprechend .. Damit können jetzt Kopierkonstruktor und Zuweisungsoperator für GeoObjekt definiert werden: class GeoObjekt { public: GeoObjekt (const GeoObjekt &go) : g(go.g == 0 ? 0 : go.g->clone()) {} GeoObjekt & operator= (const GeoObjekt & go) { if (&go != this) { // Zuweisung an mich ausschliessen delete g; if ( go.g==0 ) g=0; else g = go.g->clone(); } return *this; } ... private: Geo * g; } Geo::clone ist eine rein virtuelle Methode. Mit g->clone() wird immer die Methode aktiviert, die zum Typ des Objekts gehört, auf das g gerade zeigt. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 8.6.4 235 Übersicht: Geometrische Objekte Die Definition unserer geometrischen Objekte ist jetzt komplett. Zur Übersicht listen wir noch einmal alle Klassen auf: // Abstrakte Basisklasse // class Geo { public: virtual ˜Geo() {} virtual Geo * clone () const = 0; virtual istream & read (istream &) = 0; virtual ostream & write (ostream &) const = 0; }; //----------------------------------------------------------------------// Umschlagklasse (fuer die Benutzer) // class GeoObjekt { friend istream & operator>> (istream &, GeoObjekt &); friend ostream & operator<< (ostream &, const GeoObjekt &); public: GeoObjekt (); ˜GeoObjekt (); GeoObjekt (const GeoObjekt &); GeoObjekt & operator= (const GeoObjekt &); private: Geo * g; }; //----------------------------------------------------------------------// class Vektor : public Geo { public: Vektor (); // Nullvektor Vektor (float, float); Vektor (const Vektor &); // Kopierkonstruktor Vektor * clone () const; Vektor & operator= Vektor operator+ Vektor operatorbool operator== (const (const (const (const Vektor Vektor Vektor Vektor &); &) const; &) const; &) const; // // // // Zuweisung Vektoraddition Vektorsubtraktion Vergleich float x () const; // x-Koordinate float y () const; // y-Koordinate static bool kollinear (const Vektor &, const Vektor &); static float determinante (const Vektor &, const Vektor &); istream & read (istream &); ostream & write (ostream &) const; private: float x_; float y_; }; Vektor operator* (float s, const Vektor &); // Skalarmultiplikation Programmierung II 236 //----------------------------------------------------------------------// class Punkt : public Geo { public: Punkt (); Punkt (const Vektor &); Punkt (float, float); Punkt (const Punkt &); Vektor pos () const; // Position des Punkts Punkt * clone () const; istream & read (istream &); ostream & write (ostream &) const; private: Vektor ov_; }; // ein Punkt ist durch seinen Ortsvektor definiert //----------------------------------------------------------------------// class Gerade : public Geo { public: Gerade (); Gerade (const Vektor &, const Vektor &); Gerade (const Punkt &, const Vektor &); Gerade (const Gerade &); Gerade * clone () const; Gerade & operator= (const Gerade bool operator== (const Gerade bool operator|| (const Gerade Punkt operator* (const Gerade &); &) const; &) const; &) const; // // // // Zuweisung Vergleich Parallel Schnittpunkt Vektor p () const; // Punkt Vektor a () const; // Richtung static const Gerade x_Achse; static const Gerade y_Achse; istream & read (istream &); ostream & write (ostream &) const; private: Vektor Vektor }; p_; a_; Mit diesen Definitionen sind jetzt Anwendungen folgender Art möglich: int main (){ vector<GeoObjekt> GeoObjekt g; cin >> g; v.push_back(g); cout << g << endl; } v; // Behaelterklasse ohne Zeigerelemente // Einlesen von Objekten abgeleiteter Typen // Kopieren von Objekten abgeleiteter Typen // Ausgabe von Objekten abgeleiteter Typen Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 237 Einlesen, Ausgeben, Kopieren sind jetzt an den Polymorphismus angepasst und die für den Polymorphismus erforderlichen Zeiger in GeoObjekt versteckt. Wir haben also insgesamt aus der Basisklasse und ihren Ableitungen anständige Mitglieder der Typfamilie gemacht. 8.7 Beispiel: Tupel Die am Beispiel der geometrischen Objekte zusammengetragenen Probleme und Lösungstechniken wollen wir hier an einem kleinen Beispiel noch einmal kompakt darstellen. 8.7.1 Tupel: Paare oder Tripel Ein Tupel ist entweder ein Paar mit zwei Elementen oder ein Tripel mit drei Elementen. Wir modellieren dies mit einer Ableitungshierarchie mit der Basisklasse NTupel und ihren Ableitungen Paar und Tripel und einer Umschlagklasse Tupel class NTupel {..}; // Basisklasse class Paar : public NTupel { ... private: int i, j; }; class Tripel : public NTupel { ... private: int i, j, k; }; class Tupel { ... private: NTupel * b; }; // 1. Ableitung // 2. Ableitung // Umschlagklasse Die Umschlagklasse Tupel wird zur Benutzung freigegeben (Inhalt der H-Datei), alle anderen Klassen sind Bestandteil der Implementierung von Tupel (Inhalt der C–Datei): //Tupel.h: class NTupel; //Vorausdekl. class Tupel {....}; 8.7.2 //Tuplel.cc ... Definition aller Klassen ... ... ausser Tupel ... Verwendung und Definition der Tupel Der Einfachheit halber beschränken wir uns darauf Tupel definieren, eingeben und ausgeben zu können: // Basisklasse // class NTupel { public: virtual ˜NTupel () {} virtual NTupel * clone() = 0; virtual ostream & schreib (ostream &) = 0; }; // Ableitung 1 // class Paar : public NTupel { Programmierung II 238 public: Paar (int p_i, int p_j) : i(p_i), j(p_j) {} NTupel * clone() { return new Paar(*this); // Aufruf generierter Kopierkonstr. } ostream & schreib (ostream & os) { return os << "<" << i << "," << j << ">"; } private: int i, j; }; // Ableitung 2 // class Tripel : public NTupel { public: Tripel (int p_i, int p_j, int p_k) : i(p_i), j(p_j), k(p_k) {} NTupel * clone() { return new Tripel(*this); // Aufruf generierter Kopierkonstr. } ostream & schreib (ostream & os) { return os << "<" << i << "," << j << "," << k << ">"; } private: int i, j, k; }; //Umschlagklasse // class Tupel { friend istream & operator>> (istream &, Tupel &); friend ostream & operator<< (ostream &, const Tupel &); public: Tupel(int i, int j) : b(new Paar(i,j)) {} Tupel(int i, int j, int k) : b(new Tripel(i,j,k)) {} ˜Tupel() { delete b; } Tupel(const Tupel &p_tupel) : b(p_tupel.b->clone()) {} Tupel & operator= (const Tupel & p_tupel) { if ( this != &p_tupel ) { delete (b); if ( p_tupel.b == 0 ) b = 0; else b = p_tupel.b->clone(); } return *this; } private: NTupel * b; }; istream & operator>> (istream & is, Tupel & t) { ... Details unterdrueckt ... t.b = new Paar(...); .. oder ... t.b = new Tripel(...); return is; } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 239 ostream & operator<< (ostream & is, const Tupel & t) { return t.b->schreib(is); } Die Definition der Klassen wird in diesem Beispiel dadurch stark vereinfacht, dass Paare und Tripel keine Zeigerkomponenten haben und dadurch ihre vom Compiler generierten Kopierkonstruktoren und Zuweisungsoperatoren korrekt und ausreichend sind. Paare und Tripel haben hier zwar jeweils Schreibmethoden aber keine eigenen Lesemethoden. Ihre Basisklasse hat darum zwar eine virtuelle Schreib– aber keine virtuelle Lesemethode. Paare und Tupel können bei der Eingabe nicht an ihrem ersten Zeichen unterschieden werden können. Beide beginnen mit einem <–Zeichen. Die Eingabe kann darum nicht nach dem Lesen des ersten Zeichens an eine spezialisierte Eingaberoutine für Paare bzw. Tupel delegiert werden. 8.8 8.8.1 Beispiel: Syntaxbaum, Implementierung mit Polymorphismus Eine polymorphe Ausgabefunktion Mit Hilfe des Polymorphismus kann jetzt das im Kapitel Vererbung bereits begonnene Beispiel der Syntaxbäume vervollständigt werden. Damit jeder Knoten entsprechend seiner Art ausgegeben wird, muss die Ausgabefunktion virtuell sein: class Ausdruck { friend std::ostream & operator<< (std::ostream &, const Ausdruck &); ... private: ... Knoten * ak; }; class Ausdruck::Knoten { public: virtual std::ostream & write (std::ostream &) const = 0; ... }; class Ausdruck::WertKnoten : public Ausdruck::Knoten { public: ... std::ostream & write (std::ostream &os ) const { return os << wert} private: Wert wert; }; class Ausdruck::OpKnoten : public Ausdruck::Knoten { public: ... std::ostream & write (std::ostream &) const { return os << "(" << l->write() << op << r->write() << ")"; } private: char op; Knoten *l, *r; }; ostream & operator<< (std::ostream &os, const Ausdruck &a) { return a.ak->write(os); } Programmierung II 240 Äquivalent zu dieser einfachen Ausgabefunktion kann jede beliebige andere Verarbeitungsfunktion für Syntaxbäume definiert werden. 8.8.2 Syntaxbäume klonen Konstruktoren, insbesondere der Kopierkonstruktor, können nicht polymorh sein. Wollen wir Objekte, auf die Zeiger der Bsasisklasse zeigen, kopieren, dann werden Clone–Funktionen benötigt. Das ist in Beispiel der Syntaxbäume der Fall: Objekte vom Typ Ausdruck oder Ausdruck::OpKnoten enthalten Zeiger auf die Basisklasse Ausdruck::Knoten. Eine entsprechende Erweiterung der Syntaxbäume ist schnell definiert. Das vollständige Beispiel ist: class Ausdruck { friend ostream & operator<< (ostream &, const Ausdruck &); friend istream & operator>> (istream &, Ausdruck &); public: Ausdruck () : ak(0) {} Ausdruck (int i) : ak(new WertKnoten(i)) {} Ausdruck (char op, const Ausdruck &a1, const Ausdruck &a2); Ausdruck (const Ausdruck &); Ausdruck & operator=(const Ausdruck &); ˜Ausdruck (); int berechne(); // Wert des Ausdrucks berechnen private: class Knoten; class WertKnoten; class OpKnoten; Knoten * ak; }; class Ausdruck::Knoten { public: virtual ˜Knoten () {} virtual ostream & write (ostream &) const = 0; virtual Knoten * clone () const = 0; virtual int berechne () const = 0; }; // Kopierkonstruktor und Zuweisungsoperator werden // korrekt erzeugt // class Ausdruck::WertKnoten : public Ausdruck::Knoten { public: WertKnoten (int p_wert) : wert(p_wert) {} ostream & write (ostream &os) const { return os << wert; } WertKnoten * clone () const { return new WertKnoten(wert); } int berechne () { return wert; } private: int wert; }; int anwende (char op, int l, int r) { Th Letschert, Fachbereich MNI, FH Giessen–Friedberg switch (op) { case ’+’: return l+r; case ’-’: return l-r; case ’*’: return l*r; case ’/’: return l/r; default: cerr<<"Unbekannter Operator "<<op<<"\n"; exit(1); } } // clone ruft Kopierkonstruktor // Kopierkonstruktor ruft clone // tiefe Kopie durch gegenseitige Rekursion // class Ausdruck::OpKnoten : public Ausdruck::Knoten { public: OpKnoten (char p_op, Knoten *p_l, Knoten *p_r) : op(p_op), l(p_l), r(p_r) {} ˜OpKnoten () { delete l; delete r; } OpKnoten (const OpKnoten &); OpKnoten & operator= (const OpKnoten &); OpKnoten * clone () const { return new OpKnoten(*this); } ostream & write (ostream &os) const { os << "("; l->write(os); os << op; r->write(os); os << ")"; return os; } int berechne () { return anwende (op, l->berechne(), r->berechne()); } private: char op; Knoten *l, *r; }; Ausdruck::Ausdruck (char op, const Ausdruck & a1, const Ausdruck & a2) : ak( new OpKnoten(op, a1.ak->clone(), a2.ak->clone()) ) {} Ausdruck::Ausdruck (const Ausdruck &a) : ak( a.ak->clone() ) {} Ausdruck::˜Ausdruck () { delete ak; } Ausdruck & Ausdruck::operator=(const Ausdruck &a) { if ( &a != this ) { delete (ak); ak = a.ak->clone(); } return *this; } Ausdruck::OpKnoten::OpKnoten (const OpKnoten & k) { op = k.op; l = k.l->clone(); r = k.r->clone(); } Ausdruck::OpKnoten & Ausdruck::OpKnoten::operator=(const OpKnoten & k) { if (&k != this) { // keine Zuweisung an sich selbst delete(l); delete(r); l = k.l->clone(); r = k.r->clone(); 241 Programmierung II 242 } return *this; } ostream & operator<< (ostream &os, const Ausdruck &a) { if ( a.ak == 0 ) return os<< "UNDEFINIERTER-AUSDRUCK"; else return a.ak->write(os); } istream & operator>> (istream &is, Ausdruck &a) { char z; Ausdruck a1, a2; char op; if (!(is >> z)) { cerr << "Eingabefehler\n"; exit (1); } switch (z){ case ’(’ : is >> a1; is >> op; is >> a2; a.ak = new Ausdruck::OpKnoten(op, a1.ak, a2.ak); a1.ak = 0; a2.ak = 0; is >> z; if ( z == ’)’ ) return is; else cerr << "Ausdrucksfehler\n"; exit (1); case ’0’: case ’1’: case ’2’: case ’3’: case ’4’: case ’5’: case ’6’: case ’7’: case ’8’: case ’9’: a = Ausdruck (z-’0’); return is; default: cerr << "Ausdrucksfehler\n"; exit (1); } } 8.8.3 Muster der Klassendefinitionen Die Definition der Syntaxbäume folgt dem gleichen Muster wie die der geometrischen Objekte (siehe Abbildung 74): Die Umschlagklasse (GeoObjekt, Ausdruck) enthält einen Zeiger auf Basisklasse. Der Zeiger ist notwendig um Polymorphismus zu aktivieren. Die Basisklasse schützt den Anwender vor den damit verbundenen Problemen. Die Basisklasse definiert alle Benutzeroperationen und hat die Ein–/Ausgabeoperatoren als Freunde. Die Basisklasse (Geo, Knoten) definiert die gemeinsamen Operationen aller Ableitungen, inklusive Lese– und Schreibmethoden, sowie clone als virtuelle Methoden. Die Ableitungen (Punkt, Vektor, Gerade; bzw. Wertknoten, OpKnoten) implementieren die virtuellen Methoden der Basisklasse. Diese Klassendefinitionen bilden eine Einheit deren Schnittstelle zur Anwendung von der Umschlagklasse gebildet wird; nach dem Motto: Anwender / Anwendungscode kommen weder in Kontakt mit Zeigern noch mit Ableitungen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 243 Dieses Muster ist wichtiger als die beiden Beispiele. Es sollte nach Möglichkeit bei Klassendefinitien eingesetzt werden, die denen Ableitungen und Polymorphismus eine Rolle spielen. Ausdruck Knoten berechne GeoObjekt Geo Umschlagklasse write Basisklasse clone Umschlagklasse berechne Basisklasse WertKnoten OpKnoten write clone berechne write clone berechne Vektor Ableitungen Syntaxbäume Punkt Gerade Ableitungen Geometrische Objekte Abbildung 74: Umschlagklasse, Basisklasse und Ableitungen als Muster 8.9 8.9.1 Typinformationen und dynamische Typkonversionen Expliziter Zugriff auf dynamische Typinformation Beim Aufruf einer virtuellen Methode über einen Zeiger wird der aktuelle Typ des Objekts bestimmt und dann die dazu passende richtige Methode aktiviert. Dies alles erfolgt ohne Zutun des Programms. Die Typinformationen werden implizit ausgewertet. C++ stellt daneben aber auch zwei Konstrukte zur Verfügung mit denen explizit auf dynamische Typinformation zugegriffen werden kann: Den Konversionsoperator dynamic cast, sowie den typeid–Operator. Der Konversionsoperator erlaubt die Konversion eines Objekts von einer Stufe in der Ableitungshierarchie seiner Klasse zu einer anderen. Der Operator stellt den aktuellen Typ eines Ausdrucks fest. 8.9.2 dynamic cast: Konversion zwischen Basisklasse und abgeleiteter Klasse Mit dem dynamic cast–Operator kann zwischen Klassen in einer gemeinsamen Ableitungshierarchie konvertiert werden, wenn die Ableitungshierarchie mindestens eine virtuelle Methode enthält. Beispiel: class Basis { public: virtual void f() {}; // <<<---- virtuelle Methode int b; // jedes Objekt muss mit einer Typmarkierung versehen sein }; // um die richtige Variante von f aufrufen zu koennen class Ab : public Basis { public: int a; }; ... Programmierung II 244 Basis * pb = new Ab; pb->a = 0; Ab // pb zeigt formal auf ein Basis-Objekt // FEHLER: pb->a nicht zugreifbar * pa; pa = pb; // FEHLER: Zuweisung nicht moeglich ! pa = dynamic_cast<Ab *>(pb); pa->a = 0; // pa zeigt formal auf ein Ab-Objekt // OK } Hier wird der Zeiger auf die Basisklasse in einen Zeiger auf die abgeleitete Klasse konvertiert. Das funktioniert natürlich nur, wenn der Zeiger tatsächlich auf ein Objekt der abgeleiteten Klasse zeigt. Wenn das nicht der Fall ist, dann liefert die Konversion den 0–Zeiger als Wert. Die dynamische Konversion eines Zeigers in den “richtigen” Typ funktioniert nur, wenn die Klassenhierarchie virtuelle Methoden enthält. Das kann man sich damit erklären, dass nur bei Klassen mit virtuellen Methoden die Notwendigkeit besteht, Objekten dieser Klassen zur Laufzeit eine Markierung anzuheften, die Auskunft über den Typ gibt. Ohne virtuelle Methoden fehlt die Markierung und die dynamische Typkonversion kann nicht ausgeführt werden. Die Verwendung von dynamic cast ist nicht auf Polymorphismus beschränkt. Sind die konvertierten Objekte nicht Mitglieder einer Klassenhierarchie mit virtuellen Methoden, dann entspricht dynamic cast einen static cast. 8.9.3 Ein Beispiel für den Einsatz von dynamic cast Der dynamic cast–Operator wird oft eingesetzt, um auf eine Funktionalität der abgeleiteten Klasse zuzugreifen, die in der Basisklasse nicht vorhanden ist. Beispiel: // Angestellte // class Angestellte { public: Angestellte (const string & n) : name(n) {} virtual ˜Angestellte () {} virtual void praemie () = 0; // alle Angestellten erhalten Praemien protected: string name; unsigned int gehalt; }; // Programmierer // class Programmierer : public Angestellte { public: Programmierer (string n, string s) : Angestellte(n), sprache(s) {} void praemie () { std::cout << name << ": " << 250 << endl; } private: string sprache; }; // 250 Euro Praemie // fuer Programmierer Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 245 // Manager // class Manager : public Angestellte { public: Manager (string n, unsigned int z) : Angestellte(n), zahlUntergebene(z) {} void praemie () { std::cout << name << ": " << 1000 << endl; // 1000 Euro Praemie } void extraPraemie () { // und eine Extra-Praemie std::cout << name << ": " << 1500 << endl; // nur fuer Manager } private: unsigned int zahlUntergebene; }; // Polymorphe Funktion // Druckt Praemien aus und - nur bei Manager - auch die Extra-Praemie // void erfolgsPraemien ( list<Angestellte *> l ) { Manager * manager; for ( list<Angestellte *>::iterator i = l.begin(); i != l.end(); ++i) { (*i)->praemie(); // Jedem das Seine manager = dynamic_cast<Manager *>(*i); if (manager) // manager != 0 falls *i ein Manager ist manager->extraPraemie(); // Managern aber noch etwas mehr! } } Die polymorphe Funktion druckt die Prämien aller Angestellten aus. Die Höhe der Prämie hängt vom Status des Angestellten ab (praemie ist virtuell). Nur Manger haben die Methode extraPraemie. Um auf sie zugreifen zu können, wird versucht jeden Zeiger auf einen Angestellten in einen Zeiger auf einen Manager zu konvertieren. Wenn dies gelingt, ist das Ergebnis der Konversion ungleich Null und zeigt auf einen Manager. Dessen extraPraemie ist jetzt zugreifbar. Normalerweise sind virtuelle Methoden ausreichend, um die Unterschiede zwischen den Klassen in einer Ableitungshierarchie zugänglich zu machen. Der dynamic cast–Operator sollte auf besondere Situationen beschränkt bleiben. 8.9.4 typeid–Operator: Typ eines Objekts feststellen Mit dem typeid–Operator kann der aktuelle Typ eines Objekts festgestellt werden. Genau wie bei dynamic cast setzt die korrekte Erkennung eines abgeleiteten Typs die Existenz mindestens einer virtuellen Methode in der Ableitunghierarchie voraus. Beispiel: #include <iostream> #include <typeinfo> using namespace std; class Basis { public: Programmierung II 246 Basis () {} virtual void f () {} // <<---- virtuelle Methode }; class Abgeleitet : public Basis { public: Abgeleitet () {} }; int main() { Basis * pb1 = new Basis; Basis * pb2 = new Abgeleitet; Abgeleitet * pa = new Abgeleitet; // Typnamen ausgeben: // cout << typeid(*pb1).name() << endl; cout << typeid(*pb2).name() << endl; cout << typeid(*pa).name() << endl; // Ausgabe: Basis // Ausgabe: Abgeleitet // Ausgabe: Abgeleitet Abgeleitet ab; Basis * pb; pb = .... // Typ dynamisch testen // if ( typeid(*pb) == typeid(ab) ) { ... pb zeigt auf ein abgeleitetes Objekt ... } } typeid kann benutzt werden um den Namen des Typs eines Ausdrucks festzustellen und auch um den Typ eines Ausdrucks durch Vergleich zu testen. So wie dynamic cast nicht auf den Einsatz in Zusammenhang mit Polymorphismus beschränkt ist kann auch der typeid–Operator für beliebige Objekte verwendet werden. Beispielsweise kann mit: int main() { cout << typeid(0.9).name() << endl; } festgestellt werden, ob der Ausdruck 0.9 für den Compiler den Typ float oder double hat. 8.9.5 type info: Information über den Typ eines Objekts Der typeid–Operator liefert als Wert ein Objekt vom Typ type info. Dieser Typ bietet die Vergleichsoperationen == und != sowie die Methode name. Die weitere Definition ist implementierungsabhängig und soll hier nicht weiter erörtert werden. 8.10 Beispiel: Multimethoden 8.10.1 Typinformationen und die Auswahl von Operationen In C++ wird die Information über den Typ der Objekte implizit genutzt, um eine von mehreren möglichen Operationen auszuwählen. Prinzipiell unterscheidet man die Mechanismen zur Auswahl der richtigen Operation danach, ob sie die zur Übersetzungszeit, oder zur Laufzeit aktiviert werden: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 247 Mechanismen zur Übersetzungszeit: – Überladung einer freien Funktion oder Methode: Der (statische) Typ der Argumente entscheidet über die zu aktivierende Methode oder Funktion. – Redefinition einer Methode in einer Ableitung: Der (statische) Typ eines Objekts entscheidet über die zu aktivierende Methode. – Funktionstemplate als Funktion verwenden: Der Typ der Argumente entscheidet darüber welche Templateinstanz erzeugt und aktiviert wird. Mechanismen zur Laufzeit: – Polymorphismus: Der (dynamische) Typ eines Objekts entscheidet über die zu aktivierende (virtuelle) Methode. Diese Aufstellung zeigt ein starkes Ungleichgewicht zu Gunsten statischer Typinformationen. Der einzige Auswahl–Mechanismus, der dynamisch bestimmte Typinformationen verarbeitet, ist der Polymorphismus: Die Festlegung auf eine von mehreren möglichen virtuellen Methoden über den dynamischen Typ eines Objekts. C++ ist nun einmal eine primär statisch typisierte Programmiersprache. 8.10.2 Polymorphismus in C++: Der dynamische Typ nur eines Objekts wird beachtet Bei der Überladung und bei der Verwendung von Templates können die Typen von im Prinzip beliebig vielen Objekten als Auswahlkriterium herangezogen werden. Für die dynamische Auswahl steht nur der Polymorphismus zur Verfügung und der richtet sich stets nach dem Typ genau eines Objekts. x = f(a, b, c); x = a->f(b, c); // Auswahl von f durch den Typ von a, b und c // Auswahl von f nur durch den Typ von a Diese Beschränkung wird unangenehm bewußt, wenn eine Funktion implementiert werden soll, die von den Typen mehrerer Argumente abhängig ist. Angenommen wir haben mehrere arithmetische Klassen, etwa Natur für natürliche Zahlen und Rational für rationale Zahlen mit jeweils einer Addtitionsmethode und einer Konversionsoperation von natürlichen zu rationalen Zahlen: class Natur { public: ... Natur add (const Natur &) const; ... }; class Rational { public: ... Rational (const Natur &); // Konversion Natur -> Rational Rational add (const Rational &) const; ... }; Für diese arithmetischen Typen kann problemlos ein Satz überladener Additionsoperatoren für gemischte Arithmetik definiert werden: Natur operator+ (const Natur & n1, const Natur & n2) { return n1.add(n2); } Rational operator+ (const Rational & r1, const Rational & r2) { return r1.add(r2); } Rational operator+ (const Rational & r, const Natur & n) { Programmierung II 248 return r.add(Rational(n)); } Rational operator+ (const Natur return (Rational(n)).add(r); } & n, const Rational & r) { Bei dynamischer Typbestimmung kommt man dagegen in ernsthafte Probleme. Angenommen Natur und Rational haben den Basistyp Zahl mit virtueller Additionsmethode: class Zahl { public: virtual Zahl * addV (Zahl *) = 0; }; class Natur: public Zahl { public: ... Zahl * addV (Zahl *) { ... linker Operand ist klar vom Typ Natur ... ... rechter Operand: Zahl mit unbekanntem abgeleiteten Typ ... ... Welche Addition ???? ... } ... }; class Rational : public Zahl { public: ... Zahl * addV (Zahl *) { ... linker Operand ist klar vom Typ Rational ... ... rechter Operand: Zahl mit unbekanntem abgeleiteten Typ ... ... ????? Welche Additionsoperation wird aufgerufen ???? ... } ... }; Bei binären Operationen wie der Addition, deren Auswahl vom Typ beider Operanden abhängt, kann eine automatische Auswahl nur mit statischen Mechanismen getroffen werden ( Überladung oder Template). Eine dynamische Auswahl, die auf mehr als einem Argument basiert, ist nicht möglich. 8.10.3 Multimethoden Als Multimethoden bezeichnet man Funktionen, die einen Aufruf, abhängig vom dynamischen Typ von mehr als einem Argument, auf spezialisierte Funktionen verteilen.23 Eine Druck–Aktion hängt beispielsweise nur vom Typ dessen ab, was gedruckt werden soll: Sie ist keine Multimethode und kann bequem als virtuelle Methode des zu druckenden Objekts realsisert werden. Der Compiler erzeugt automatisch den Code der die Typinformation der Objekte verwaltet und bei Bedarf beachtet. Eine Multiplikation dagegen wird in ihrem Ablauf vom Typ beider Argumente beeinflusst: sie kann nicht bequem und einfach als virtuelle Methode realisiert werden: die Typen der Argumente müssen im Programmcode explizit bestimmt und beachtet werden. Für unser arithmetisches Beispiel benötigen wir eine Multimethode mit folgender Struktur: // Multimethode zur gemischten Addition rationaler und natuerlicher Zahlen // Zahl * operator+ ( Zahl * z1, Zahl * z1 ) { *z1 ist natueliche Zahl 23 Man spricht bei “Multimethoden” auch von “multiple dispatching” im Gegensatz zu “single dispatching”. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 249 ==> *z2 ist natuerliche Zahl ==> Addition natuerlicher Zahlen *z2 ist rationale Zahl ==> Konversion von *z1; Addition rationaler Zahlen *z1 ist rationale Zahl ==> *z2 ist rationale Zahl ==> Addition rationaler Zahlen *z2 ist natuerliche Zahl ==> Konversion von *z2; Addition rationaler Zahlen } Eine Multimethode kann in C++ mühsam und unübersichtlich mit virtuellen Methoden, oder übersichtlich und elegant mit explizit ausprogrammiertem Zugriff auf die dynamische Typinformation der Objekte realisiert werden. 8.10.4 Beispiel: Ausgangspunkt Gemischte Arithmetik mit statischer Typanalyse Nehmen wir an, dass Klassen zur Darstellung natürlicher und rationaler Zahlen definiert wurden. Jede Klasse hat ihre eigenen dyadischen und unären Operationen. Daneben wurden Konversionen und Operatoren für gemischte Arithmetik definiert. Der Einfachheit halber beschränken wird und auf die Multiplikation als dyadischer Operation und auf das unäre Minus: class Natur { // natuerliche Zahlen friend class Rational; public: Natur () : w(0) {} Natur (const Natur &n) : w(n.w) {} Natur (unsigned int pw) : w(pw) {} Natur mult (const Natur &pn) const { return Natur(pn.w + w); } private: unsigned int w; }; class Rational { // rationale Zahlen friend Rational operator- (const Rational &); // unaeres private: enum VZ {plus, minus}; VZ vz; unsigned int z, n; Rational (VZ, unsigned int, unsigned int); public: Rational () : z(0), n(0) {} Rational (unsigned int pz, unsigned int pn) : vz(plus), z(pz), n(pn) {} Rational (const Natur &pn) : vz(plus), z(pn.w), n(1) {} Rational (const Rational &r) : vz(r.vz), z(r.z), n(r.n) {} Rational mult (const Rational &r) const; friend Rational::VZ operator* (Rational::VZ, Rational::VZ); // * auf Vorzeichen }; Rational::VZ operator* (Rational::VZ vz1, Rational::VZ vz2) { if (vz1==vz2) return Rational::plus; else return Rational::minus; } Rational::Rational (VZ pvz, unsigned int pz, unsigned int pn) : vz(pvz), z(pz), n(pn) {} Rational Rational::mult (const Rational &r) const { Programmierung II 250 return Rational (vz * r.vz, z*r.z, n*r.n); } // unaeres Minus Rational operator- (const Rational &r) { return Rational (r.vz*Rational::minus, r.z, r.n); } // Ueberladene Operatoren fuer reine und gemischte Multiplikationen // Natur operator* (const Natur & n1, const Natur & n2) { return n1.mult(n2); } Rational operator* (const Rational & r1, const Rational & r2) { return r1.mult(r2); } Rational operator* (const Rational & r,const Natur & n) { return r.mult(Rational(n)); } Rational operator* (const Natur & n, const Rational & r) { return (Rational(n)).mult(r); } Die beiden Klassen definieren jeweils ihre eigene Multiplikation und ihren eigenen Minus–Operator. In dem überladenen operator* wird die gemischte Arithmetik zentral und übersichtlich definiert. Natürliche und rationale Argumente können problemlos gemischt werden, solange nur die jeweiligen Typen zur Übersetzungszeit bekannt sind. 8.10.5 Eine Oberklasse Wert für rationale und natürliche Zahlen Sollen Zahlen beliebigen Typs zur Laufzeit manipluliert werden, etwa indem Zahlen unterschiedlicher Art eingelesen oder in einer Liste gespeichert werden, dann wird eine dynamische Typanalyse, also Polymorphismus benötigt. Wir haben dann also eine Oberklasse zu definieren, in der die Varianten zusammengefasst sind. Entsprechend unsrer üblichen Vorgehensweise definieren wir eine Basisklasse Zahl und eine Umschlagklasse Wert. Die Ausgangsdefinition wird also wie folgt erweitert: class Zahl { ... }; // Basisklasse class Natur : public Zahl { ... }; class Rational : public Zahl { ... }; // Ableitungen class Wert { ... private: Zahl * pz; }; // Umschlagklasse Auf dem Typ Wert sollen jetzt polymorphe Varianten24 des unären Operators “operator-” und des dyadischen Operators “operator*” definiert werden. Ein erster Ansatz besteht darin Zahl mit einer virtuellen Multiplikationen und einem virtuellen Minus auszustatten, die dann in den Ableitungen redefiniert werden. Beginnen wir mit der Erweiterung für die Multiplikation: class Zahl { ... virtual Zahl * multP (const Zahl &) const; }; class Natur : public Zahl { ... Rational mult (const Rational &r) const; 24 D.h. solche mit dynamischer Typanalyse. // <-- ?? wird ersetzt durch Th Letschert, Fachbereich MNI, FH Giessen–Friedberg Zahl * 251 multP (const Zahl &) const; // polymorphe Variante ?? }; class Rational : public Zahl { ... Natur mult (const Natur &pn) const; Zahl * multP (const Zahl &) const; }; // <-- ?? wird ersetzt durch // polymorphe Variante ?? Schon diese ersten Zeilen erzeugen einen starken Widerwillen: Neben oder auch an Stelle der vorhandenen “statischen” Multiplikationen (mult) müssen polymorphe Varianten (multP) eingeführt werden. Dazu muss massiv in den vorhandenen – mühsam entwickelten und getesteten – Code für die Klassen Natur und Rational eingegriffen werden. Das widerspricht den elementarsten softwaretechnischen Prinzipen, nach denen ein neues Leistungsmerkmal möglichst unabhängig von bereits vorhanden realisiert werden soll. Kurz und gut: virtuelle Methoden führen zu Murks. 8.10.6 Multimethoden mit expliziter dynamischer Typprüfung Virtuelle Methoden sind nicht geeignet den gewünschten Polymorphismus zu den vorhandenen Klassen hinzuzufügen. Mit explizit ausprogrammierter Typprüfung sieht die Sache glücklicherweise deutlich besser aus: class Zahl { public: virtual ˜Zahl() {} }; class Natur: public Zahl { ... unveraendert ... }; class Rational : public Zahl { ... unveraendert ... }; ... ... ... Alle Methoden und die statischen Versionen der Operatoren unveraendert ... ... ... // Polymorphe Wertklasse // class Wert { friend Wert operator- (const Wert &); // unaeres friend Wert operator* (const Wert &, const Wert &); // Multiplikation von Werten public: explicit Wert( const Natur & pn ) : pz( new Natur(pn)) {} explicit Wert( const Rational & pr) : pz( new Rational(pr)) {} ˜Wert() { delete pz; } private: Zahl * pz; }; // polymorphe Version des unaeren operator// Wert operator- (const Wert &w) { if ( Rational *pr = dynamic_cast<Rational *>(w.pz) ) return Wert(-(*(pr))); if ( Natur *pn = dynamic_cast<Natur *>(w.pz) ) return Wert(-(*(pn))); std::cerr << "FEHLER: Typ(w) ??\n"; exit (1); } // polymorphe Version des dyadischen operator* Programmierung II 252 // Wert operator* (const Wert &w1, const Wert &w2){ if ( Rational *r1 = dynamic_cast<Rational *>(w1.pz) ) { if ( Rational *r2 = dynamic_cast<Rational *>(w2.pz) ) return Wert( *r1 * *r2 ); else if ( Natur *n2 = dynamic_cast<Natur *>(w2.pz) ) return Wert( *r1 * *n2 ); else { std::cerr << "FEHLER: Typ(w2) ?? \n"; exit (1); } } else if ( Natur *n1 = dynamic_cast<Natur *>(w1.pz) ) { if ( Rational *r2 = dynamic_cast<Rational *>(w2.pz) ) return Wert( *n1 * *r2 ); else if ( Natur *n2 = dynamic_cast<Natur *>(w2.pz) ) return Wert( *n1 * *n2 ); else { std::cerr << "FEHLER: Typ(w2) ??\n"; exit (1); } } std::cerr << "FEHLER: Typ(w1) ??\n"; exit (1); } In dieser Lösung bleibt der vorhandene Code praktisch unverändert, nur public Zahl wird in die beiden Klassendefinitionen eingefügt. Alle Methoden und alle freien Operatoren können unverändert weiterverwendet werden. Der vorhandene “statische” Code wird klar ersichtlich um die neue Funktionalität ergänzt: Eine Basisklasse, eine Umschlagklasse und zur jeder statischen Funktion wird eine polymorphe Version definiert. Standard–Lehrbücher von C++ empfehlen generell den Einsatz von virtuellen Methoden und erklären explizit ausprogrammierte Typprüfungen und –Konversionen zu Stilfehlern. Daran kann man erkennen, dass Vererbung und Polymorphismus wohl noch immer deutlich öfter empfohlen als tatsächlich ernsthaft praktiziert werden. 8.11 Vererbung und Templates 8.11.1 Generizität: Vielgestaltigkeit von Templates Templates werden typischerweise eingesetzt, um Behälterklassen und auf ihnen operierende Algorithmen zu realisieren. Als einfaches Beispiel ein Tripel als Behälter mit drei Elementen: template <class class Tripel { public: T & get_1() { T & get_2() { T & get_3() { private: T x, y, z; }; T> return x; } return y; } return z; } Ein solches Template beinhaltet eine Vielfalt von Klassen: Es kann die Form von Tripeln mit beliebigem Typ annehmen. Man spricht darum von Templates als generischen25 Klassen bzw. Funktionen. Die Vielgestaltigkeit von Templates – ihre Generizität – unterscheidet sich drastisch von der Polymorphie, der Vielgestaltigkeit durch virtuelle Methoden. Generizität wird von manchen, z.B. von Stoustrup, auch parametrischer Polymorphismus genannt. 8.11.2 Templates als Ableitung von Templates Eine einfache und weitverbreitete Kombination von Vererbung und Templates besteht darin, ein Template als Ableitung eines anderen Templates zu definieren: 25 generisch: das Genus, die Gattung, betreffend. Eine generische Klasse/Funktion ist eine Gattung von Klassen/Funktionen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 253 template <class T> class Tripel { ... wie oben ... } // Template als Ableitung eines Templates // template <class T> class VierTupel : public Tripel<T> { public: T & get_4() { return u; } private: T u; }; int main () { VierTupel<int> q; q.get_1() q.get_2() q.get_3() q.get_4() = = = = // VierTupel und Tripel werden instanziiert 1; 2; 3; 4; } Wenn das abgeleitete Klassentemplate instantiiert wird, dann wird auch die Basisklasse mit den gleichen Templateparametern instantiiert. Alle Möglichkeiten der Templates und der Vererbung können dabei kombiniert werden. Beispielsweise können Methoden der Basisklasse virtuell sein und die Ableitung eine Templateinstanz als Komponente enthalten: #include <iostream> #include <list> using namespace std; template <class T> class Liste { public: virtual ˜Liste() {} virtual void ein (const T &) virtual T aus () }; = 0; = 0; template <class T> class UnsortierteListe : public Liste<T> { public: void ein (const T & t) { l.push_back(t); } T aus () { T temp = l.front(); l.pop_front(); return temp; } private: list<T> l; }; template<class T> void listenTest ( Liste<T> * pl ) { for (int i=0; i<10; ++i) pl->ein(i); Programmierung II 254 for (int i=0; i<10; ++i) cout << pl->aus()<< " "; cout << endl; } int main () { UnsortierteListe<int> l; listenTest<int> (&l); } 8.11.3 Exotische Kombinationen von Templates und Vererbung Templates und Vererbung können in vielfältiger Weise kombiniert werden. So können Templates Basisklassen von Nicht–Templates sein und umgekehrt: class Base { ... }; template <class T>class Ab: public Base { ... } ; template <class T>class Base { ... }; class Ab: public Base<XY> { ... } Eine Basisklasse kann als Templateparameter übergeben werden: template <class T> class A: public T { ... }; Sinnvolle Anwendung eines derart kreativen Umgangs mit den Möglichkeiten der Sprache sind nicht offensichtlich. Zur Überraschung selbst langjähriger Kenner von C++ hat sich jetzt herausgestellt, dass es sie gibt. Sowohl den Compilern als auch den Programmierern wird dabei aber einiges abgefordert. Die Diskussion der Möglichkeiten und Anwendungen von Kombinationen der Vererbung und Templates sind darum ein fortgeschrittenes Thema. 8.12 Übersicht: Mechanismen der Vererbung und des Polymorphismus 8.12.1 Speicherplatz Vererbung und Polymorphismus sind mächtige Hilfsmittel, die am Anfang gelegentlich etwas verwirrend wirken. Sie basieren jedoch auf einigen Grundprinzipien, die für sich jeweils recht einfach und klar sind. Für sinnvolle Anwendungen muss jedoch, wie wir oben gezeigt haben, in der Regel der gesamte Mechanismus aufgefahren werden. Zum Verständnis sollte man sich auf die einzelnen Grundprinzipien konzentrieren. Das erste der einfachen Prinzipien bezieht sich auf den Speicherplatz. Speicherplatz für beliebige Objekte wird stets statisch, auf Basis des zur Übersetzungszeit bekannten Typs angelegt. Variablen von einem Basistyp können darum immer nur Objekte vom Basistyp aufnehmen: class class class class Geo Geo {...}; Vektor : public Geo {...}; Punkt : public Geo {...}; Gerade : public Geo {...}; g; // hier kann NUR ein Geo, nie ein Objekt einer Ableitung von // Geo (Punkt, Vektor, Gerade) gespeichert werden! vector<Geo> v; // v wird immer NUR Geos enthalten, NIE einen Vektor, etc. g = Vektor(2,3); // Der Vektor wird auf seine Geo-Anteile reduziert v.puh_back(Vektor(2,3)); // Der Vektor wird auf seine Geo-Anteile reduziert Will man mit Objekten diverser abgeleiteter Typen arbeiten, dann müssen diese über Zeiger angesprochen werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 255 Geo * g; // g kann ZEIGER auf Vektoren, Punkte und Geraden enthalten vector<Geo *> v; // v kann ZEIGER auf Vektoren, Punkte und Geraden enthalten 8.12.2 Konstruktoren und Zuweisungsoperatoren Der Compiler wählt den Zuweisungsoperator und die Konstruktoren, inklusive Kopierkonstruktor, immer auf Basis des statischen – zur Übersetzungszeit bekannten – Typs aus. void f (Geo pg) { ... }; Punkt p(1,1); Vektor v(0,0); f (p); Geo g = v; // nur der GEO-Anteil von p wird uebergeben // nur der GEO-Anteil von v wird zugewiesen Will man typunabhängig arbeiten, dann müssen Zeiger benutzt werden: void f (Geo * pg) { ... }; Punkt p(1,1); Vektor v(0,0); Geo * pg; f (&p); // Ein Zeiger auf Punkt wird uebergeben f (&v); // Ein Zeiger auf Vektor wird uebergeben pg = new Vektor(0,0); // Ein Zeiger auf Vektor wird kopiert 8.12.3 Polymorphismus: Typmarkierungen in Objekten Virtuelle Methoden sind solche, die in Ableitungen redefiniert werden können. Bei einer virtuellen Methode wird zur Laufzeit der Typ eines Objekts festgestellt und dann die richtige Methode aktiviert. Enthält eine Klasse virtuelle Methoden, dann sind ihre Objekte polymorph (vielgestaltig). Die aktuelle “Gestalt”, der Typ des Objekts, wird zur Laufzeit festgestellt. Dazu muss das Objekt eine Typmarkierung enthalten. Polymorphismus wird immer dann benötigt, wenn der genaue Typ von Objekten erst zur Laufzeit feststeht. Typisches Beispiel dafür sind Behälter in denen nach Belieben Objekte unterschiedlichen Typs gespeichert werden sollen (“Was immer es ist, speichere es!”), oder Operationen gleicher Art, die auf Objekten von unbekanntem Typ angewendet werden sollen (“Was immer es ist, drucke es!”). 8.12.4 Polymorphismus arbeitet nur mit Zeigern Eine aufgerufene freie Funktion oder Methode wird in aller Regel statisch – zur Übersetzungszeit – identifiziert. Das nennt man statische Bindung. Wird jedoch eine virtuelle Methode über Zeiger aktiviert, dann arbeitet das System mit dynamischer Bindung. Jetzt wird die zu aktivierende Methode erst zur Laufzeit ausgewählt. Die Objekte enthalten dann die Adressen ihrer Methoden in einer Methodentabelle. Der Compiler erzeugt keinen Sprung zu der Methode, sondern einen Sprung zu der Methode, deren Adresse in der Methodentabelle gefunden wird. Virtual ohne Zeiger und/oder Referenzen ist sinnlos! 8.12.5 Polymorphismus und das Erzeugen einer Kopie: Klonen Konstruktoren und Zuweisungen kann man nicht virtuell definieren. Ein Konstruktor ist prinzipiell an einen bestimmten Typ gebunden. Der Zuweisungsoperator kann zwar formal virtuell sein, de facto ist es aber nicht möglich eine polymorphe Zuweisung zu definieren. Wenn also Objekte abgeleiteter Typen über einen Zeiger auf ihren Basistyp kopiert werden sollen, dann muss eine virtuelle parameterlose “Klon–Funktion” definiert werden. 256 Programmierung II 8.12.6 Destruktoren von Basisklassen sollten virtuell sein Der Destruktor einer Basisklasse B sollte virtuell sein, wenn Zeiger vom Typ B* auf Objekte von abgeleiteten Typen zeigen. class B { public: ˜B(){} ... }; Damit wird erreicht, dass der Destruktor des tatsächlichen Typs des Objekts aktiviert wird. 8.12.7 Virtuelle Methoden: polymorph in nur einem Argumnet Mit virtuelle Methoden kann eine Methode abhängig vom dynamischen Typ eines Objekts aktiviert werden. Die Sprache C++ organisiert die Typverwaltung und Auswahl der richtigen Methode automatisch ohne expliziten Programmcode. Ist eine zu aktivierende Methode oder Funktion vom Typ mehr als eines Objekts abhängig, dann gibt es (in C++) keinen Automatismus mehr, der die Auswahl trifft. Sie muss explizit ausprogrammiert werden, entweder indem mit dynamic cast oder typid auf die vom Compiler generierten Typinformation zugegriffen wird, oder indem eigene Typmarkierungen erzeugt und verwaltet werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 8.13 Übungen Aufgabe 1 1. Welche Ausgabe erzeugt: class Basis { public: Basis () : x(10) {} void f () { ++x; } int x; }; class Abgeleitet : public Basis { public: Abgeleitet () {} void f () { --x; } }; int main() { Abgeleitet * pa = new Abgeleitet; Basis * pb = pa; pa->f(); pb->f(); cout << pa->x << endl; cout << pb->x << endl; } 2. Welche Ausgabe wird erzeugt, wenn die Methode Basis::f als virtuelle Methode definiert wird? 3. Was ändert sich, wenn Basis::f als rein virtuelle Methode definiert wird? Aufgabe 2 Betrachten Sie folgendes Programm: class Angestellt { public: Angestellt(string name) : _name(name) {} Angestellt() {} private: string _name; }; class Manager : public Angestellt { public: Manager(string name, string abt) : Angestellt(name), _abt(abt) {} Manager() {} private: string _abt; }; int main() { Angestellt* mitarbeiter[10]; for (int i = 0; i < 10; i++) mitarbeiter[i] = new Manager; 257 Programmierung II 258 mitarbeiter[0] = new Angestellt("Hugo Hacker"); mitarbeiter[1] = new Manager("Anita Eckberg", "Marketing"); // ... for (int i = 0; i < 10; i++) delete mitarbeiter[i]; } 1. Ist die Anweisung delete mitarbeiter[i] erlaubt obwohl Angestellt keinen Destruktor hat? 2. Mit welchen Werten belegt der Defaultkonstruktor von Manager sein Objekt? 3. Sind die Definitionen von Angestellt und Manager OK, oder sollten sie Destruktoren definieren? Aufgabe 3 Das Programm #include <iostream> using namespace std; class Basis { public: Basis () : b(1) {} virtual void print () { cout << "b= " << b << endl; } private: int b; }; class Abgeleitet : public Basis { public: Abgeleitet (int pa) : a(pa) {} void print () { cout << "a= " << a << endl; } private: int a; }; int main () { Basis *b1 = new Abgeleitet(22); Basis *b2 = new Abgeleitet(33); *b1 = *b2; b1->print(); b2->print(); } erzeugt seltsamerweise die Ausgabe a= 22 a= 33 1. Warum ist das so? Funktioniert die Zuweisung nicht so wie erwartet, stimmt etwas nicht mit print, ...? 2. Welche Ausgabe erzeugt das Programm ohne virtual vor print? Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 259 Aufgabe 4 Warum enthält folgendes Programm ein Speicherloch26 und wie ist es zu korrigieren, ohne dass dabei das Programm in seiner sonstigen Funktionalität verändert wird. class B { public: B() : x(new int[1000]) {} ˜B() { delete[] x; } int * x; }; class Ab : public B { public: Ab() : y(new int[1000]) {} ˜Ab() { delete[] y; } int * y; }; int main () { for (int i=0; i<100000; ++i) for (int j=0; j<100000; ++j) for (int k=0; k<100000; ++k) { B *b = new Ab; delete b; } } Aufgabe 5 In folgendem Beispiel soll die Klasse AusdrKnoten durch eine Basisklasse Knoten und zwei von ihr abgeleitete Klassen WertKnoten und OpKnoten ersetzt werden. class Ausdruck { friend ostream & operator<< (ostream &, const Ausdruck &); friend istream & operator>> (istream &, Ausdruck &); public: Ausdruck (); Ausdruck (const Ausdruck &); Ausdruck & operator=(const Ausdruck &); ˜Ausdruck (); private: class AusdrKnoten; AusdrKnoten * ak; }; ostream & operator<< (ostream &, const Ausdruck &); istream & operator>> (istream &, Ausdruck &); class Ausdruck::AusdrKnoten { public: AusdrKnoten (const Zahl &); AusdrKnoten (char, AusdrKnoten *, AusdrKnoten *); ˜AusdrKnoten (); 26 Ein Speicherloch (engl. memory leak) ist eine Programmkonstruktion die Speicher anfordert diesen nach Benutzung aber nicht wieder komplett frei gibt. Ein Speicherloch kann zum Programmabsturz führen. (Ein Speicherloch ist keine Loch im Speicher, sondern ein Loch durch das Speicher ausrinnt.) 260 Programmierung II AusdrKnoten (const AusdrKnoten &); AusdrKnoten & operator=(const AusdrKnoten &); ostream & write (ostream &) const; private: enum Art {wertAusdr, opAusdr}; Art art; Zahl wert; char op; AusdrKnoten *l, *r; }; 1. Analysieren Sie den angebenen Quellcode und erläutern Sie welche Funktionalität bei der Einführung von Vererbung ohne bzw. nur mit Hilfe von Polymorphismus realisiert werden kann. 2. Definieren Sie einen geeigneten Typ Zahl zur Ergänzung der Definitionen. 3. Wandeln Sie die Definitionen so um, dass AusdrKnoten durch eine Basisklasse Knoten und zwei von ihr abgeleitete Klassen WertKnoten und OpKnoten ersetzt wird. Richten Sie sich dabei auch nach Vorbild der im Skript erläuterten Definitionen für geometrische Objekte. Wird beispielsweise eine clone–Methode benötigt? Wenn ja: wozu? Wenn nein: warum wurde sie bei geometrischen Objekten eingeführt und ist hier jedoch unnötig? 4. Implementieren Sie Ihre Vererbungsvariante der Syntaxbäume. Aufgabe 6 In folgendem Programm wird der dynamic cast–Operator eingesetzt. Geben Sie eine äquivalente Lösung ohne ihn an. // ACHTUNG: VERMURKSTES PROGRAMM: BITTE KORRIGIEREN !! #include <iostream> #include <list> #include <string> using namespace std; class Angestellte { public: Angestellte (const string & n) : name(n) {} virtual ˜Angestellte () {}; void praemie () { std::cout << name << ": " << 100 << endl; } protected: string name; unsigned int gehalt; }; class Programmierer : public Angestellte { public: Programmierer (string n, string s) : Angestellte(n), sprache(s) {} void praemie () { std::cout << name << ": " << 250 << std::endl; } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 261 private: string sprache; }; class Manager : public Angestellte { public: Manager (string n, unsigned int z) : Angestellte(n), zahlUntergebene(z) {} void praemie () { std::cout << name << ": " << 1000 << std::endl; } private: unsigned int zahlUntergebene; }; void erfolgsPraemien ( list<Angestellte *> l ) { for ( list<Angestellte *>::iterator i = l.begin(); i != l.end(); ++i) { Manager * boss = dynamic_cast<Manager *>(*i); if (boss) boss->praemie(); // Manager-Praemie else { Programmierer * hacker = dynamic_cast<Programmierer *>(*i); if ( hacker ) hacker->praemie(); // Programmierer-Praemie else (*i)->praemie(); // Angestellten-Praemie } } } int main () { Manager m("CarlaMeier", 5); Programmierer p("JosefineMueller", "C++"); Angestellte a("KlausKunz"); list<Angestellte *> l; l.push_back (&m); l.push_back (&p); l.push_back (&a); erfolgsPraemien(l); } Aufgabe 7 1. Ergänzen Sie folgendes Beispiel aus dem Skript um die fehlenden Definitionen und erweitern Sie es um Quadrupel, also Tupel mit vier Elementen, und definieren Sie eine Methode anwende, die eine als Parameter bergebene Funktion auf alle Elemente des Tupels anwendet. // Basisklasse // class NTupel { public: virtual ˜NTupel () {} virtual NTupel * clone() = 0; virtual ostream & schreib (ostream &) = 0; Programmierung II 262 }; // Ableitung 1 // class Paar : public NTupel { public: Paar (int p_i, int p_j) : i(p_i), j(p_j) {} NTupel * clone() { return new Paar(*this); // Aufruf generierter Kopierkonstr. } ostream & schreib (ostream & os) { return os << "<" << i << "," << j << ">"; } private: int i, j; }; // Ableitung 2 // class Tripel : public NTupel { public: Tripel (int p_i, int p_j, int p_k) : i(p_i), j(p_j), k(p_k) {} NTupel * clone() { return new Tripel(*this); // Aufruf generierter Kopierkonstr. } ostream & schreib (ostream & os) { return os << "<" << i << "," << j << "," << k << ">"; } private: int i, j, k; }; //Umschlagklasse // class Tupel { friend istream & operator>> (istream &, Tupel &); friend ostream & operator<< (ostream &, const Tupel &); public: Tupel(int i, int j) : b(new Paar(i,j)) {} Tupel(int i, int j, int k) : b(new Tripel(i,j,k)) {} ˜Tupel() { delete b; } Tupel(const Tupel &p_tupel) : b(p_tupel.b->clone()) {} Tupel & operator= (const Tupel & p_tupel) { if ( this != &p_tupel ) { delete (b); if ( p_tupel.b == 0 ) b = 0; else b = p_tupel.b->clone(); } return *this; } private: NTupel * b; }; 2. Wandeln Sie Ihre Erweiterung in ein Template um, bei dem der Typ der Elemente Templateparameter ist. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9 263 Dateien 9.1 9.1.1 Sequentielle Dateien Dateien existieren unabhängig vom Programm Dateien sind Datenspeicher. Man kann in ihnen Daten ablegen (schreiben) und sie später wieder entnehmen (lesen). Eine Datei ist damit einem Feld sehr ähnlich. In gewissem Sinne ist eine Datei als Zusammenfassung von Werten zwar ein weiterer Behälter, von anderen Datentypen unterscheidet sie sich aber in einem sehr wesentlichen Punkt. Dateien sind, im Gegensatz etwa zu Feldern, außerhalb des Programms. Ihre Existenz ist nicht an die eines Programms gebunden. Sie existieren in der Regel bevor das Programm gestartet wurde und leben nach dessen Ende meist weiter. 9.1.2 Dateien: In der Hardware, im Betriebssystem, im Programm Dateien sind physikalische Objekte, z.B. bestimmte Bereiche auf einer Magnetplatte oder einer CD. Sie werden vom Betriebssystem verwaltet. Programme greifen (fast) immer über das Betriebssystem auf Dateien zu. Jedes Betriebssystem hat dabei seinen eigenen Satz von Systemaufrufen 27 mit deren Hilfe Programme auf die Hardware von Dateien zugreifen können. Damit ein Programm, das mit Dateien arbeitet, nicht von dem Betriebssystem abhängig ist, auf dem es abläuft, enthalten viele Programmiersprachen noch ein eigenes Dateikonzept, das dann vom Compiler auf die Gegebenheiten des Betriebssystems abgebildet wird. Dateien gibt es also in drei unterschiedlichen Ebenen: Hardware: Dateien sind Bereiche auf physikalischen Medien wie Platten, CDs, Disketten, Bänder etc. Betriebssystem: Das Betriebssystem verwaltet die physikalischen Medien, ordnet verschiedene Bereiche eines Mediums einem konzeptionell zusammenhängenden Bereich “Datei” zu, verwaltet Zugriffsrechte, Verwaltungsinformationen etc. Dabei hat jedes Betriebssystem sein Konzept von “Datei”, das sich in den Systemaufrufen niederschlägt, mit denen auf die Dateien zugegriffen werden kann. Die Systemaufrufe werden in Operationen auf physikalischen Objekten umgesetzt. Programmiersprache: Die Programmiersprachen haben ihr eigenes Konzept von “Datei”. Das vereinfacht für die Programmierer den Umgang mit Dateien und macht Quellprogramme vom Betriebssystem unabhängig. Auf Dateien wird jetzt mit bestimmten Konstrukten der Sprache zugegriffen. Diese werden vom Compiler dann auf das jeweilige Betriebssystem abgebildet. Die Sprachkonstrukte können normalerweise auch umgangen werden. Das Programm verwendet dann nicht die “Dateien der Sprache”, sondern benutzt direkt über Systemaufrufe die “Dateien des Systems”. In manchen Systemen kann ein Programm sogar das Betriebssystem umgehen und direkt auf die Hardware zugreifen. 9.1.3 Öffnen: Dateien und ihre Repräsentanten im Programm verknüpfen Die Dateikonzepte der verschiedenen Programmiersprachen sind zwar unterschiedlich, gewisse Gemeinsamkeiten gibt es aber dennoch. Typischerweise arbeiten die Programme aller Programmiersprachen mit “logischen Dateien” (Datei–Repräsentanten, Programmdatei), die mit einer “wirklichen Datei” (Betriebssystemdatei) verknüpft werden müssen. Das Verknüpfen einer logischen Programmdatei mit einer realen Betriebssystemdatei nennt man Öffnen. 9.1.4 Beispiel: Datei kopieren Als Beispiel betrachten wir ein einfaches Programm das eine Textdatei in eine andere kopiert. (Der Einfachheit und Übersichtlichkeit halber wurde auf jede Fehlerbehandlung verzichtet): 27 Im Betriebssystem implementierte Funktionen, die von Anwendungsprogrammen aufgerufen werden können. Programmierung II 264 #include <fstream> int main (int argc, char * argv[]) { ifstream quelle; // Eingabedatei (Programmdatei) ofstream ziel; // Ausgabedatei (Programmdatei) char ch; quelle.open (argv[1]); ziel.open (argv[2]); // // // // Dateien oeffnen: Programmdateien mit realen Dateien verbinden. Die Dateinamen wurden als Programmargumente uebergeben while (quelle.get(ch)) ziel.put(ch); quelle.close (); ziel.close (); // Dateien schliessen: // Verbindung reale Datei -- Programmdatei loesen } Wird dieses Programm in der Quelldatei copyDat.cc mit g++ -o copyDat copyDat.cc übersetzt und das Ergebnis mit copyDat datei-1.txt datei-2.txt aktiviert, dann wird es den Inhalt der Datei datei-1.txt in die Datei datei-2.txt kopieren. 9.1.5 Muster der Dateibearbeitung: Öffnen, Bearbeiten, Schließen Eine Datei ist nicht Bestandteil eines Programms. Man kann darum vom Programm aus nicht direkt auf sie zugreifen. Wie in fast allen Programmiersprachen erfolgt auch in C++ der Zugriff auf eine Datei nach folgendem Muster: Internen Repräsentanten erzeugen: Für eine zu bearbeitende Datei wird ein programminternes Objekt als Repräsentant erzeugt. In unserem Kopierbeispiel sind quelle und ziel solche Repräsentanten. Wir bezeichnen sie als “Programmdateien”, oder, wenn keine Verwechslungen mit den wirklichen (Betriebssystem–) Dateien (datei-1.txt und datei-2.txt) zu befürchten sind, auch einfach als “Dateien”. Öffnen: Die programminternen Repräsentanten quelle und ziel werden mit den wirklichen Dateien datei-1.txt und datei-2.txt verbunden. Diesen Prozess nennt man “Öffnen”. Beim Öffnen einer Datei muss immer zumindest der Name der Datei (auf der Ebene des Betriebssystems) angegeben werden. In unserem Beispiel werden die Dateien mit quelle.open (argv[1]); ziel.open (argv[2]); geöffnet. Die Dateinamen sind C–Strings und werden hier aus der Kommandozeile übernommen. Damit werden zwei Verbindungen erzeugt: – quelle – ziel datei-1.txt datei-2.txt Bearbeiten der aktuellen Position: Ab sofort werden alle Aktionen des Programms auf quelle und ziel an die wirklichen Dateien datei-1.txt und datei-2.txt weitergegeben. Das Programm bearbeitet also eine Datei, indem Kommandos auf deren Repräsentanten ausgeführt werden. In unserem Beispiel liest die Anweisung Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 265 quelle.get(ch) ein Zeichen aus der mit quelle verbundenen Datei in die Variable ch und ziel.put(ch) schreibt das Zeichen in ch in die mit ziel verbundene Datei. Während der Bearbeitung wird die jeweilige Datei lesend oder schreibend durchwandert. Mit einer Leseoperation wandert die aktuelle Dateiposition um eins weiter. Beim Schreiben ist die aktuelle Position immer das Dateiende. Die Datei wird allerdings mit jeder Schreiboperation etwas länger. Schließen: Sind alle Aktionen erledigt, dann wird die Verbindung zur Datei wieder gelöst. Dies bezeichnet man als “Schließen”. quelle.close () ziel.close () sind in unserem Beispiel die entsprechenden Aktionen. 9.1.6 Die Art des Öffnens einer Datei Es macht einen Unterschied, ob eine Datei zum Lesen oder zum Schreiben geöffnet wird. Eine Datei wird als erstes vollständig gelöscht, wenn man sie zum Schreiben öffnet. Beim Öffnen zum Lesen wird dagegen lediglich die aktuelle Position auf den Anfang gesetzt. Die Art, in der die Datei geöffnet werden soll, erkennt das System hier am Typ des Repräsentanten. quelle ist vom Typ ifstream (input file stream) und ziel ist vom Typ ofstream (output file stream). Damit ist klar, was mit den Dateien passieren soll und wie sie also jeweils zu öffnen sind. 9.1.7 Konstruktor und Destruktor öffnen bzw. schließen Dateien Konstruktor und Destruktor von ifstream sowie ofstream öffnen und schließen Dateien. Unser Beispiel wird damit vereinfacht zu: #include <fstream> int main (int argc, char * argv[]) { ifstream quelle (argv[1]); ofstream ziel (argv[2]); char ch; while (quelle.get(ch)) ziel.put(ch); } 9.1.8 Fehlerbehandlung Das ist aber jetzt vielleicht ein wenig zu einfach. Man sollte zumindest die Situation abfangen, in der eine Datei nicht erfolgreich geöffnet werden kann: ... ifstream quelle (argv[1]); if (!quelle) { cout << "kann " << argv[1] << " nicht oeffnen \n"; exit (1); } ofstream ziel (argv[2]); Programmierung II 266 if (!ziel) { cout << "kann " << argv[2] << " nicht oeffnen \n"; exit (1); } ... Das Öffnen einer Datei kann fehlschlagen, wenn die Datei nicht existiert, oder das Programm nicht zu der beabsichtigten Aktion – Lesen oder Schreiben – berechtigt ist. In diesem Fall ist die Datei in einem Fehlerzustand. 28 Der Dateizustand wird hier mit if (!quelle) ... und if (!ziel) ... geprüft. “!” ist hier ein für alle Ein– oder Ausgabeströme definierter Operator operator!(), der true liefert, wenn die letzte Operation fehlgeschlagen ist. !quelle ist äquivalent zu quelle.fail() 9.1.9 Fehlerzustände und Dateiflags Mit der Methode fail() kann eines von mehreren Flags abgefragt werden. Mit den Flags wird der Zustand der Datei überwacht. Neben fail gibt es noch weitere Methoden, mit denen insgesamt eine genauere Beobachtung des Dateizustands möglich ist: good() liefert true wenn kein Fehlerflag gesetzt ist, also wenn keine der anderen true liefert. bad() liefert true wenn der Strom (die Datei) in einem Fehlerzustand ist. fail() liefert true wenn die letzte Operation fehlgeschlagen ist. Normalerweise ist der Strom dann auch im bad–Zustand. eof() liefert true wenn das Ende des Stroms (der Datei) erreicht wurde. Gelegentlich will man selbst explizit den Zustand eines Eingabestroms setzen. Beispielsweise wenn Werte eines eigenen Datentyps eingelesenen werden, diese aber nicht den Formatvorgaben entsprechen. Den Eingabezustand setzt man beispielsweise mit istream is; ... is.setstate (ios::failbit); Die möglichen Zustandsbits sind: ios::badbit ios::eofbit ios::failbit ios::goodbit Mehrere Bits können mit der bitweisen Oder-Operation gesetzt werden. Beispiel: is.setstate (ios::failbit | ios::badbit); 9.1.10 ofstream ist ein Ausgabe–Zeichenstrom wie cout Ein Ausgabestrom den wir schon lange kennen ist cout. cout muss nicht deklariert und auch nicht geöffnet werden, es ist für jedes Programm mit der “Standardausgabe” verbunden. Was genau die Standardausgabe ist, definiert das Betriebssystem. In der Regel ist es das Terminal von dem aus das Programm gestartet wurde. Interessanterweise ist mit jedem Objekt vom Typ ofstream all das möglich, was mit cout möglich ist. Insbesondere kann der Operator << benutzt werden. Damit können einfach eigene Ausgaben in einer Datei platziert werden: 28 Genau gesagt ist natürlich nur die Programmdatei, der Dateirepräsentant, in einem Fehlerzustand, die wirkliche Datei bleibt von der fehlgeschlagenen Operation unbeeindruckt. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 267 ... // Text in einer Datei speichern: ziel << "-- Dies ist eine Kopie von " << argv[1]<< "\n"; ... 9.1.11 ifstream ist ein Eingabe–Zeichenstrom Komplementär dazu kann – entsprechend zu cin – der Operator >> mit Objekten vom Typ ifstream benutzt werden. Damit können z.B. Werteingaben vom Typ int oder float ohne eigenen Konversionscode von einer Datei gelesen werden. Ein einfaches Programm, das float–Zahlen aus einer als Programmargument übergebenen Datei liest und ausgibt, ist: #include <fstream> int main (int argc, char * argv[]) { ifstream quelle (argv[1]); float f; int i = 1; if (!quelle) { cout << "kann " << argv[1] << " nicht oeffnen \n"; exit (1); } quelle >> f; while (!quelle.eof()) { cout << i++ << ". " << f << endl; quelle >> f; } } !quelle.eof() testet ob die Datei zu Ende gelesen wurde. Die Leseoperation quelle >> f; vor Beginn der Schleife mag etwas verwundern, sie ist notwendig, da eof() erst nach einem erfolglosen Leseversuch das Dateiende anzeigt. 9.1.12 Eingabe und Ausgabe von Zeichen: get und put Zur Ein– und Ausgabe verwendet man normalerweise die Operatoren << und >>. Mit ihnen können Werte aller vordefinierten Datentypen, inklusive char, gelesen und geschrieben werden. Gelegentlich ist der Operator << allerdings “zu intelligent”. Weiße Zeichen – Leerzeichen, Zeilenvorschub, etc. – werden von ihm einfach ignoriert und überlesen. Will man ein “normales” Zeichen einlesen, dann ist das Überlesen in Ordnung. Will man dagegen eine Datei identisch kopieren, dann müssen auch die weißen Zeichen kopiert werden. Das “intelligente Überlesen” irrelevanter Leerzeichen und Zeilenvorschübe ist hier nicht angebracht. Statt << benutzt man die Methode get, die ohne überflüssiges Nachdenken jedes Zeichen einliest. put ist die komplementäre Ausgabemethode, wir benutzen sie aus Symmetriegründen. Will man also Zeichen eines Eingabestroms lesen, ohne dass das System in irgendeiner Weise versucht diese zu interpretieren, dann sollte get verwendet werden. ... while (!quelle.eof()) { ... //quelle >> ch; Nicht wenn Leerzeichen etc. erkannt werden sollen! quelle.get (ch); // OK ... } Programmierung II 268 ... 9.1.13 Lesen bis zum Ende quelle.get(ch) liest das nächste Zeichen von quelle ein und speichert es in ch. get und << liefern als Ergebnis wieder den Eingabestrom, auf den sie angewendet wurden. Dieser kann in einem booleschen Ausdruck verwendet werden, in dem er zu “Falsch” ausgewertet wird, wenn die Eingabe leer ist. Mit einer Schleife wie while (quelle.get (ch)) ... bzw. while (quelle >> ch) ... kann man darum wie mit while (!quelle.eof()) ... bis zum Ende einer Datei lesen. Hierbei wird wieder der Dateizustand über das fail–Flag und den !–Operator (siehe oben) getestet. 9.1.14 Eingabe von Zeilen als C–Strings: getline Ganze Zeilen können bequem mit getline eingelesen werden. Wir verwenden es beispielsweise, wenn das Kopierprogramm die Zeilenstruktur der Eingabe erkennen soll, etwa um die Zeilen nummeriert wieder auszugeben: #include <fstream> int main (int argc, char * argv[]) { ifstream quelle (argv[1]); ofstream ziel (argv[2]); char buf[256]; // Zeilenpuffer int i = 1; // Zeilennummer if (!quelle) { ... } if (!ziel) { ... } quelle.getline (buf, 256); // Zeile mit maximal 256 Zeichen einlesen while (!quelle.eof()) { ziel << "Zeile "; ziel.width (2); ziel << i++ << ": " << buf << endl; quelle.getline(buf, 256); } ... } Man hätte die Schleife auch über das Ergebnis von getline steuern können: while (quelle.getline (buf, 256)) { ... } Dabei wäre allerdings die letzte Zeile unter den Tisch gefallen. Nachdem die letzte Zeile gelesen wurde ist die Eingabe “Falsch”, die Schleife wird abgebrochen und diese Zeile wird nicht mehr ausgegeben. getline liest in ein char–Feld. Der zweite Parameter gibt die maximale Länge an. Mit ihm wird verhindert, dass getline über die Grenzen von buf hinaus schreibt. Ist die gelesene Zeile länger als die vorgegebene Grenze, dann wird der Rest der Zeile weggeworfen. “Zeile” ist dabei definiert als die Zeichenfolge bis zum nächsten Zeilenende–Zeichen. Dieses Zeichen kann auch explizit angegeben werden. Die beiden folgenden Anweisungen sind darum gleichwertig: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg quelle.getline (buf, 256); quelle.getline (buf, 256, ’\n’); 269 // Zeilenende-Zeichen default // Zeilenende-Zeichen explizit angegeben 9.1.15 Eingabe von Zeilen als Strings: getline Zeilen können nicht nur in char–Feldern eingelesen werden. Statt der Methode benutzt man dazu die Funktion getline: string s; getline (quelle, s); Die Flexibilität der Strings erlaubt das Einlesen von Zeilen beliebiger Länge. 9.2 9.2.1 Ein– und Ausgabe selbst definierter Typen Standard–I/O–Ströme cin, cout und cerr cin, cout und cerr sind vordefinierte Variablen istream cin istream cout ostream cerr die sich auf Quasi–Dateien beziehen. Sie werden für jedes Programm automatisch beim Programmstart geöffnet. cin wird dabei mit der Standard–Eingabe und cout mit der Standard–Ausgabe verbunden. cerr ist die Standard–Fehlerausgabe. Sie ist oft identisch mit der Standard–Ausgabe, kann aber – auf der Ebene des Betriebssystems – in einen anderen Kanal (z.B. eine andere Datei) geleitet werden. cin und cout sind lediglich Spezialfälle. Jede Ausgabe–Datei vom Typ ofstream kann wie ein ostream und damit wie cout behandelt werden. Jede Eingabe–Datei vom Typ ifstream kann wie ein istream und damit wie cin behandelt werden. Ein ofstream ist ein ostream und ein ifstream ist ein istream. (Vererbung: ifstream und ofstream sind Ableitungen von istream und ostream.) Wir merken uns an dieser Stelle einfach, dass alles, was mit cin und cout möglich ist, auch mit Dateien gemacht werden kann. 9.2.2 Die Operatoren >> und << Die wichtigsten Aktionen auf cin und cout sind die I/O–Operationen mit operator>> und operator<<. Diese beiden Operatoren sind für alle einfachen Typen wie int, float etc. definiert: int x; cout << x; das entspricht: int x; operator<< (cout, x); Der <<–Operator hat zwei Parameter – eine Referenz auf einen ostream und ein auszugebendes Objekt – und liefert einen Wert vom Typ ostream & als Ergebenis: ostream & operator<< (ostream &, T); Damit können mehrere Werte in folgender Form ausgegeben werden: int x; float y; cout << x << "," << y; Ausführlich hingeschrieben ist das: int x; float y; operator<< (operator<< (operator<< (cout, x), ","), y); Programmierung II 270 Für cin, istream und den >>–Operator gelten entsprechenden Aussagen. 9.2.3 Ausgabeoperator überladen Der Ausgabeoperator << kann jederzeit zur Ausgabe eines Objektes mit selbst definiertem Typ überladen werden. Man muss dabei beachten, dass << eine Referenz auf ein ostream–Objekt (output stream Objekt) als Argument hat und ein solches liefern muss. Beispiel: #include <iostream> struct Vektor { float x; float y; }; ostream & operator<< (ostream &s, const Vektor &v) { return s << ’(’ << v.x << ", " << v.y << ’)’; } int main () { Vektor v1 = {0.5, 1.0}; cout << v1 << endl; } 9.2.4 Ausgabe auf Datei Da eine geöffnete Ausgabedatei auch ein Ausgabestrom ist, kann der neu definierte Operator auch zur Ausgabe auf eine Datei benutzt werden: ... wie oben ... int main (int argc, char * argv[]) { Vektor v1 = {0.5, 1.0}; ofstream datei (argv[1]); ... datei << "Vektor: " << v1 << endl; } 9.2.5 Eingabeoperator überladen Für eine selbstdefinierte Eingabe muss der Eingabeoperator >> überladen werden. Die neuen Operatoren können dann sowohl auf cin als auch auf eine geöffnete Eingabedatei angewendet werden. Ein Beispiel mit selbst definierter Ein– und Ausgabe ist: #include <iostream> struct Vektor { float x; float y; }; ostream & operator<< (ostream &s, const Vektor &v) { return s << ’(’ << v.x << ", "<< v.y << ’)’; } istream & operator>> (istream &s, Vektor &v) { char c; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg s >> c; s >> v.x; s >> c; s >> v.y; s >> c; return s; 271 //( //, //) } int main (int argc, char * argv[]) { Vektor v1 = {10, 10}; ifstream datei_ein (argv[1]); ofstream datei_aus (argv[2]); ... datei_ein >> v1; // Aufruf operator>> (.., Vektor &v) datei_aus << "Vektor v1:" << endl; datei_aus << v1 << endl; // Aufruf operator<< (.., const Vektor &v) } Den Operator wird man sicher noch erweitern wollen, um inkorrekte Eingaben abzufangen. 9.2.6 I/O–Operatoren als Freunde Die I/O–Operatoren werden als freie Funktionen definiert. Sie haben damit keinen Zugang zu den privaten Teilen der Klasse, deren Objekte sie ein– oder ausgeben. Will man nicht für alle privaten Datenkomponenten öffentliche Zugriffsfunktionen definieren, müssen die Operatoren zu Freunden erklärt werden: class Vektor { friend ostream & operator<< (ostream &, const Vektor &); public: ... private: float x; // Zugreifbar fuer float y; // operator<< }; ostream & operator<< (ostream &s, const Vektor &v) { return s << ’(’ << v.x << ", "<< v.y << ’)’; } ... Hier ist eine freie Funktion der Freund einer Klasse. Natürlich kann der Eingabeoperator ebenfalls ein Freund sein: class Vektor { friend ostream & friend istream & public: ... private: float x; float y; }; 9.2.7 operator<< (ostream &, const Vektor &); operator>> (istream &, Vektor &); Ein/Ausgabe für Klassentemplates Klassentemplates können Funktionstemplates als Freunde haben. Beispielsweise erklärt ein Klassentemplate das Funktionstemplate das sein I/O besorgt zu Freund. Beispiel: Programmierung II 272 template<class T> class Vektor { friend ostream & operator<< <T> (ostream &, const Vektor &); friend istream & operator>> <T> (istream &, Vektor &); public: ... private: T x, y; }; ... template<class T> ostream & operator<< (ostream &s, const Vektor<T> &v) { return s << v.x << " " << v.y; template<class T> istream & operator>> (istream &s, Vektor<T> &v) { return s >> v.x >> v.y; } 9.2.8 Eingabe rückgängig machen Gelegentlich will man ein Zeichen “ungelesen machen”. Angenommen Ausdrücke seien zu analysieren. In die Ausdrücke sind Werte unterschiedlicher Art eingebettet. Beispielsweise besteht der Ausdruck: ( [+3/2] - 45 ) aus zwei Unterausdrücken. Einer der beiden ist ein Bruch–Ausdruck, der andere eine ganze Zahl. Das erste Zeichen – beispielsweise “4” – wird zuerst benötigt, um zu entscheiden, um welche Art von Unterausdruck es sich handelt, dann wird es wieder benötigt, um den Wert zu bestimmen. Die Analyse wird dadurch erleichtert, dass bereits gelesene Zeichen wieder auf den Eingabestrom zurück gelegt werden können: void ausdruckAnalyse () { ... char z; cin >> z; switch (z){ case ’(’ : .... case ’)’ : .... case ’*’: case ’+’: // .... default: { // cin.unget(); // Wert w; is >> w; // .... } } ... } Operator Wert in einer von mehreren Varianten schon gelesenes Zeichen zurueck legen Wert mit erstem Zeichen einlesen Die Methoden istream & istream::unget() istream & istream::putback(char) machen das letzte Zeichen ungelesen. Bei putback wird geprüf ob das zurück gegebene Zeichen tatsächlich dem gelesenen entspricht. Ansonsten sind die beiden äquivalent. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9.2.9 273 Ein– und Ausgabe von Datenstrukturen Die Ein– und Ausgabe selbstdefinierter Typen muss sich nicht auf Klassen oder Verbunde beschränken. Mit etwas Codierarbeit beim Schreiben und Analyseaufwand beim Lesen können auch Datenstrukturen gespeichert werden, deren Komponenten durch Zeiger miteinander verkettet sind. Man definiert dazu eine geeignete textuelle Darstellung der Datenstruktur. Eine verkettete Liste beispielsweise kann einfach sequenziell als Folge ihrer Elemente – eventuell durch Trennsymbole separiert – abgelegt werden. Ein Baum kann als geklammerter Ausdruck codiert werden. Etc. 9.2.10 Formatfehler bei der Eingabe Treten beim Einlesen selbstdefinierter Datentypen Fehler auf, dann wird man normalerweise nicht gleich das ganze Programm abbrechen wollen, sondern statt dessen den Fehler an der Aufrufstelle der Leseoperation abfangen und behandeln. Das failbit des Eingabestroms kann benutzt werden, um die Information über den Lesefehler zu melden. Dies entspricht dem Verhalten vordefinierter Eingabeoperatoren. Beispiel: #include <iostream> using namespace std; class Vektor { friend istream & operator>> (istream &, Vektor &); public: Vektor () : x(0), y(0) {}; private: int x, y; }; istream & operator>> (istream &is, Vektor &v) { char c; is >> v.x; is >> c; if ( c == ’,’ ) // Vektorkomponenten muessen mit ’,’ getrennt sein is >> v.y; else // Lesefehler: Strom ist in Fehlerzustand versetzen is.setstate(ios::failbit); return is; } int main () { Vektor v; cin >> v; while ( cin ) { // solange korrekte Vektoren eingegeben werden ... v verarbeiten ... cin >> v; } ... } 9.3 9.3.1 Darstellungskonversionen und String–Streams Darstellungskonversion mit >> und << Die I/O–Operatoren >> und << haben nicht nur die Aufgabe von Datei zu lesen bzw. auf sie schreiben. Sie konvertieren auch Daten von der programminternen Binärdarstellung in die menschenlesbare Form von ASCII–Daten und umgekehrt. Gelegentlich benötigt man diese Konversion auch programmintern, also ohne dass die Daten ausgegeben oder eingelesen werden. Dieses Konversionsproblem stellt sich in der Praxis recht oft, beispielsweise Programmierung II 274 wenn Eingabedaten des Benutzers vor ihrer Übernahme ins Programm geprüft werden sollen, bei graphischen Oberflächen (die nur Zeichen darstellen, aber keine Dateien sind), oder bei Programmen die mit anderen Daten austauschen (verteilte Anwendungen). Man kann die Konversionsfähigkeiten der Operatoren in eigenen Routinen nachprogrammieren, z.B. die Wandlung von Strings in Integerwerte: int zifferwert (char c) { return (int(c) - int(’0’));} int string2int (string s) { if (s.length() == 1) return zifferwert (s.at(0)); else return string2int (s.substr(0, s.length()-1)) * 10 + zifferwert (s.at(s.length()-1)); } Es ist natürlich wenig sinnvoll etwas, das der >>–Operator schon kann, so wie hier noch einmal zu implementieren. Will man aber den >>–Operator zur Konversion nutzen, muss man zunächst über eine Datei gehen: int string2int (string s) { // Daten auf Datei schreiben, um // spaeter den >>-Operator zu Konversion // nutzen zu koennen: ofstream fo ("temp.txt"); fo << s; fo.close(); // Daten von Datei lesen // dabei Konversion ASCII -> Binaer: ifstream fi ("temp.txt"); int i; fi >> i; fi.close(); return i; } Auch das ist keine besonders gute Lösung. Im Vergleich zu programminternen Aktionen sind Aktionen auf Dateien extrem aufwendig und langsam. In unserem Fall werden nur die Konversionsfähigkeiten von >> gebraucht: er soll die ASCII–Darstellung in Binärdarstellung umwandleln; um diese Fähigkeit nutzen zu können, muss der String dummerweise zuerst auf eine Datei geschrieben und dann von ihr gelesen werden. 9.3.2 String–Streams Ein– und Ausgabeströme sind normalerweise mit externen Dateien verbunden. Das beschränkt die I/O–Operatoren auf die Kommunikation mit Dateien. Das ist schlecht, denn die Shift–Operatoren können mehr als nur Ein- und Ausgabe. Sie können Datenforamte umkonvertieren: von Text– in Binärdarstellung und umgekehrt. Es wäre schön, wenn sie generell – und nicht nur bei der Ein– oder Ausgabe – zur Darstellungskonversion genutzt werden könnten. Um dieses Problem zu lösen, um also mit << und >> die Darstellung von Daten programmintern wechseln zu können, gibt es eine Art von “programminternen Dateien”: die String–Streams. Es gibt sie in zwei Varianten: ostringstream (Headerdatei sstream): Ein Ausgabe–String–Stream. Mit ihm kann man Daten, statt in eine Datei, in einen String–Stream hineinschreiben. ostrstream (Headerdatei strstream) ist eine alte Variante von ostringstream. istringstream (Headerdatei sstream): Ein Eingabe–String–Stream. Mit ihm kann man Daten, statt aus einer Datei, aus einem String–Stream lesen. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 275 istrstream (Headerdatei strstream) ist eine alte Variante von istringstream. Der Sinn dieser Konstrukte liegt wie gesagt darin, dass mit ihnen die mächtigen Stream–Operationen auch für programminterne Aufgaben genutzt werden können. Die älteren Konstrukte ostrstream und istrstream aus der Headerdatei strstream sollten nicht mehr benutzt werden, wenn die C++–Implementierung die modereneren Varianten ostringstream und istringstream aus der Headerdatei strstream zur Verfügung stellt. 9.3.3 ostringstream : Daten in Strings konvertieren Mit dem <<–Operator können Daten unterschiedlichsten Typs ausgegeben werden. Bei der Ausgabe werden sie in Folgen von Zeichen konvertiert und diese dann ausgegeben. Möchte man Daten in Zeichenfolgen konvertieren, ohne sie gleich auf eine Datei auszugeben, benutzt man einen ostringstream. Beispiel: #include <sstream> #include <string> using namespace std; int main () { ostringstream ostr; int i = 10; float f = 72.12; char c = ’A’; // Daten mit << in ostr hineinschieben // und dabei Binaerdaten in ASCII-Darstellung // umwandeln: // ostr << "i = " << i << endl; ostr << "f = " << f << endl; ostr << "c = " << c << endl; // Daten als String aus ostr herausholen: // string s = ostr.str(); // string s enthaelt // die korrekte Darstellung (ASCII) der Werte // von i, f und c. cout << s << endl; } Hier wird ein Objekt vom Typ ostringstream erzeugt, mit << gefüllt und schließlich mit der Methode str() in einen String verwandelt. 9.3.4 istringstream Daten aus einem String lesen Mit istrstream kann in umgekehrter Richtung aus einem String gelesen und dabei in einen beliebigen Typ konvertiert werden. Beispiel: #include <sstream> #include <string> Programmierung II 276 #include <iostream> using namespace std; int main () { string s = "10 72.12 A"; // Daten als String in istr schreiben: // istringstream istr (s); int i; float f; char c; // Daten mit >> aus istr herausholen und dabei // aus ASCII-Darstellung in Binaerdaten umwandeln: // istr >> i; istr >> f; istr >> c; // i, f und c // enthalten jetzt die Werte 10, 72.12 und ’A’ cout << i << ", "<< f << ", "<< c << endl; } Der String s wird an den Konstruktor von istr übergeben und dieser füllt ihn dann mit den Zeichen des Strings und macht damit aus einem String einen Eingabe–Stringstream. Aus diesem kann dann automatisch mit den richtigen Konvertierungsoperationen in die Variablen gelesen werden. 9.3.5 Beispiel, Konversionsfunktion: String nach Integer Unser Konversionsbeispiel von oben lässt sich mit einem Stringstream folgendermaßen realisieren: int string2int (string s) { istringstream istr (s); // Mit ASCII-Zeichen fuellen int i; istr >> i; // Konversion ASCII -> Binaer return i; } Statt wie oben den String s in eine Datei zu schreiben, wird istr mit dem Wert von s initialisiert. Der Eingabeoperator kann sich dann sofort an seine Konversionsarbeit machen. In umgekehrter Richtung benutzen wir einen ostringstream: string int2string (int i) { ostringstream of; of << i; // Konversion Binaer -> ASCII return of.str(); // ASCII-Zeichen entnehmen } Die Methode str ohne Parameter liefert den Inhalt des Stringstreams als String. Mit einem Parameter setzt sie dessen Inhalt neu: of.str(); // liefert Inhalt von of of.str("12.3"); // setzt Inhalt von of neu Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9.3.6 277 Besonderheiten von Stringstreams aus der strstream–Bibliothek Die alte Stringstream–Bibliothek mit der Headerdatei strstream unterscheidet sich in drei Punkten von der neueren: 1. strstream arbeitet mit C–Strings statt mit Strings. 2. Das Ende einer Zeichenkette wird nicht unbedingt automatisch gesetzt. 3. Die Speicherverwaltung ist nicht automatisch korrekt. Die Konsequenz der beiden ersten Einschränkungen ist, dass alle Strings in C–Strings konvertiert werden müssen, bevor sie in in eine istrsteam eingegeben werden können, sowie dass eingegebene Zeichen mit dem Endezeichen ends (0–Zeichen) abgeschlossen werden sollten. Beispiel: #include <strstream> #include <string> #include <iostream> using namespace std; int main () { string s = "10 72.12 A"; // istringstream mit C-String fuellen: // istrstream istr (s.c_str()); // s.c_str() ist s als C-String .... // ostringstream Inhalt mit ends abschliessen: // ostrstream ostr; ostr << "i = " << i << ends; } Eine Erörterung der Speicherverwaltung von strstream–Klassen geht über diese Einführung hinaus. Wir empfehlen die neuere Bibliothek zu benutzen und verweisen ansonsten auf Beschreibungen der Standardbibliothek. 9.3.7 Bibliotheksfunktion atoi Für die Konversion von ASCII (in Form eines C–Strings) nach Integer gibt es in der C–Bibliothek die vordefinierten Funktion atoi (“ASCII to Integer”). #include <stdlib.h> int string2int (string s) { return atoi (s.c_str()); } Diese spezielle Lösung wurde in C++ durch den allgemein verwendbaren Mechanismus der String–Streams ersetzt. Natürlich kann atoi weiterhin auch in C++–Programmen verwendet werden. 9.4 9.4.1 Dateien in Iteratoren umwandeln Iteratoren und Dateien (Programm–) Dateien und Iteratoren, wie wir sie zum Durchlaufen der Listen konstruierten, sind sehr ähnliche Konstrukte. Beide bieten die Möglichkeit über die Elemente einer “anderen” Datenstruktur zu wandern. Listen- Programmierung II 278 iteratoren wandern über die Elemente einer Liste und Programm–Dateien (vom Typ istream oder ostream) wandern über die Elemente einer wirklichen (Betriebssystem–) Datei. 9.4.2 Iteratoren auf Eingabedateien: istream iterator Eine Eingabedatei kann sehr einfach in einen äquivalenten Iterator istream iterator verwandelt werden. Beispiel: #include <fstream> #include <iterator> using namespace std; int main (int argc, char *argv[]) { ifstream inFile (argv[1]); istream_iterator<int> iter(inFile); istream_iterator<int> endOfInput; // Datei // Datei -> Iterator // Ende-Iterator int a [100]; int i = 0; // Datei mit Iterator-Operationen durchlaufen, // (Einlesen von int-Werten aus der Datei in das Feld a): while ((iter != endOfInput) && (i<100)) { a[i] = *iter; // Wert einlesen ++iter; // aktuelle Postion verschieben ++i; } } istream iterator ist ein Template, dessen Argument den Typ der logischen Werte in der Datei angibt. 29 Tatsächlich ist die Datei eine Textdatei, die keine ints sondern chars (ASCII–Zeichen) enthält. Sie sollen hier lediglich als int–Werte interpretiert werden. Die Umwandlung in einen Iterator unterstützt diese Interpretation. Der erste Konstruktor istream iterator<int> iter(inFile); nimmt eine Eingabedatei als Argument und erzeugt daraus einen Iterator iter über einer Sequenz von int– Werten. Der zweite (Default–) Konstruktor istream iterator<int> endOfInput; erzeugt einen “Iterator am Ende” endOfInput, dessen einziger Zweck der Vergleich mit iter ist. Man beachte dass die Namen endOfInput und iter völlig willkürlich gewählt wurden. In der while–Schleife wird die Datei durchlaufen und dabei eingelesen. Im Gegensatz zu den normalen Dateioperationen wird sie bei dieser Iterator–Version als Folge von int–Werten behandelt. So schiebt ++iter die aktuelle Position (d.h. den Iterator) nicht auf das nächste Zeichen sondern auf den nächsten int–Wert. Die int– Werte sind dabei rein virtuell, in der Datei stehen weiterhin Zeichen, keine Zahlen (d.h. keine ints in deren spezieller Binärcodierung, sondern ASCII–codierte Zeichen). (Siehe Abbildung 75): 9.4.3 Iteratoren interpretieren Eingabedateien mit dem operator>> Jede Textdatei kann in einen Iterator über einen beliebigen Typ T verwandelt werden, falls für T der Eingabeoperator 29 Die Datei kann auch die Standard–Eingabe cin sein. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 279 operator++, operator>>(..., int &) istream_iterator<int> ifstream 1 2 , 1 , 1 2 ... get, operator>>(..., char &) Abbildung 75: Interpretation von Dateien auf unterschiedlichen Ebenen istream & operator>> (istream &, T &e); definiert ist. Ein solcher Operator kann natürlich jederzeit für jeden beliebigen Typ definiert werden. Beispiel: #include #include #include #include <iostream> <fstream> <string> <sstream> using namespace std; int string2int (string s) { istrstream ifs (s); int i; ifs >> i; return i; } struct Student { Student (); Student (string, int); string name; int matrikelNr; }; Student::Student () : name ("??"), matrikelNr (-1) {} Student::Student (string p_n, int p_nr) : name (p_n), matrikelNr (p_nr) ostream & operator<< (ostream &s, const Student &e) { // Ausgabe-Operator return s << e.name << ":" << e.matrikelNr << endl; } istream & operator>> (istream &s, Student &e) { // Eingabe-Operator char lineBuf[256] = ""; s.getline(lineBuf, 256); string line = lineBuf; e.name = string(line, 0, line.find(’:’)); e.matrikelNr = string2int ( (string(line, line.find(’:’)+1, (line.length()-line.find(’:’))-1) ).c_str()); return s; } {} Programmierung II 280 int main (int argc, char *argv[]) { if (argc != 2) return 1; ifstream inFile (argv[1]); istream_iterator<Student> iter(inFile); istream_iterator<Student> endOfInput; Student s; while ((iter != endOfInput)) { // Schleife ueber eine Datei voller Studenten s = *iter; cout << s << endl; ++iter; } } Das Programm kopiert die Datei deren Namen als Programmargument übergeben wurde auf die Standardausgabe. 9.4.4 Iteratoren auf Ausgabedateien: ostream iterator Auch Ausgabedateien können zu Iteratoren über einen beliebigen Typ T transformiert werden, falls für T ein Ausgabeoperator ostream & operator<< (ostream &, const T &); definiert ist. Eine andere Version des Programms, das eine Datei mit Studentendaten auf die Standardausgabe kopiert: ... ... wie oben ... int main (int argc, char *argv[]) { if (argc != 2) return 1; ifstream inFile (argv[1]); istream_iterator<Student> iterIn(inFile); istream_iterator<Student> endOfInput; ostream_iterator<Student> iterOut(cout); while (iterIn != endOfInput) { *iterOut = *iterIn; ++iterIn; ++iterOut; } // Eingabe-Iterator // Ausgabe-Iterator // Eingabe nach Ausgabe kopieren } 9.4.5 Beispiel: Mischen Ein weiteres Beispiel ist das Mischen von zwei sortierten Dateien zu einer sortierten Gesamtdatei: #include #include #include #include #include <iostream> <fstream> <iterator> <string> <sstream> struct Element { Element (); Element (string, int); string info; int key; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 281 }; Element::Element () : info ("??"), key (-1) {} Element::Element (string p_n, int p_key) : info (p_n), key (p_key) {} ostream & operator<< (ostream &s, const Element &e) { return s << e.info << "\t" << e.key << endl; } istream & operator>> (istream &s, Element &e) { // Eingabe-Operator char lineBuf[256] = ""; // mit string-stream s.getline(lineBuf, 256); // jedes Element steht in einer Zeile: istringstream istrs (lineBuf); // Info <weisse Zeichen> Key istrs >> e.info; istrs >> e.key; return s; } // mische zwei sortierte Dateien zu // zu einer sortierten Gesamtdatei // void mische (ifstream &a, ifstream &b, ofstream &c) { istream_iterator<Element> ia(a); istream_iterator<Element> ib(b); istream_iterator<Element> endOfInput; while ((ia != endOfInput) && (ib != endOfInput)) if ((*ia).key <= (*ib).key) { c << *ia; ++ia; } else { c << *ib; ++ib; } while (ia != endOfInput) { c << *ia; ++ia; } while (ib != endOfInput) { c << *ib; ++ib; } } int main (int argc, char *argv[]) { if (argc != 4) return 1; ifstream inFile1 (argv[1]); // 1. Eingabedatei ifstream inFile2 (argv[2]); // 2. Eingabedatei ofstream outFile (argv[3]); // Ausgabedatei mische (inFile1, inFile2, outFile); } 9.5 9.5.1 Wahlfreier Zugriff Wahlfreier Zugriff: Setzen der aktuellen Dateiposition Dateien werden stets so gelesen und beschrieben, dass jede Lese– und Schreibaktion an der aktuellen Dateiposition stattfindet und diese dann um eins weiter schiebt. Die aktuelle Position wird beim Öffnen auf gesetzt, d.h. auf den Index des ersten Bytes in der Datei. Die aktuelle Position ist eine Byte–Position, unabhängig davon, was in der Datei gespeichert wurde: int– oder float–Zahlen, Zeichen, Zeichenketten, etc. In den bisherigen Beispielen wurde die aktuelle Position stets nur implizit verändert (d.h. als Seiteneffekt einer anderen Operation). In diesem Abschnitt zeigen wir Programme, die Dateipositionen willkürlich – explizit – verschieben und damit Zugriff auf beliebige Stellen in einer Datei haben. Das explizite Verschieben der aktuellen Position in einer Datei nennt man wahlfreien Zugriff (engl. random access). Programmierung II 282 9.5.2 Positionierung des Lesezugriffs Die aktuelle Position einer Datei kann im Programm sowohl abgefragt, als auch modifiziert werden. In folgendem Beispiel wird dies ausgenutzt, um Dateien ab einer bestimmten Position auszugeben und um ihre Größe festzustellen: #include <fstream> using namespace std; int main (int ifstream streampos char argc, char * argv[]) { datei (argv[1]); nr = string2int (argv[2]); // ab hier soll gelesen werden, ch; if (!datei) { cout << "kann " << argv[1] << " nicht oeffnen \n"; exit (1); } //Die Datei ist offen, die Leseposition ist am Anfang (auf Byte 0) // Absolut positionieren, // Die Datei ab der eingegebenen Position ausgeben: datei.seekg (nr); // Datei positionieren cout << "Datei " << argv[1] << " ab Position: " << datei.tellg () << endl; datei.get (ch); while (!datei.eof()) { cout << ch; datei.get (ch); } cout << "-------------------\n"; // Die Dateil"ange ausgeben: datei.seekg (0, ios::end); // auf 0-tes Byte vom Ende her, // d.h auf das Ende, positionieren cout << "Die Datei enthaelt " << datei.tellg () + 1 // aktuelle Position (=Ende) abfragen << " Zeichen\n"; // Vom Ende her positionieren, // Die letzten Bytes der Datei ausgeben: datei.clear (); // EOF-Bedingung (vom letzten Lesen) entfernen datei.seekg (-nr, ios::end); // Datei positionieren: -nr Bytes ab Dateiende cout << "Datei " << argv[1] << " ab Position: " << datei.tellg () << endl; datei.get (ch); while (!datei.eof()) { cout << ch; datei.get (ch); } cout << "-------------------\n"; } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 283 datei.seekg (nr); setzt die Leseposition (seekg = seek get) auf das Byte Nr. nr. (Das erste Byte hat die Nr. .) Mit seekg kann man absolut auf ein bestimmtes Byte und relativ vom Anfang, vom Ende und von der aktuellen Position her positionieren: datei.seekg datei.seekg datei.seekg datei.seekg datei.tellg (nr); (nr, ios::beg); (nr, ios::end); (nr, ios::cur); () positioniert absolut auf ein bestimmtes Byte. positioniert relativ zum Anfang. positioniert relativ zum Ende. positioniert relativ zur aktuellen Position. gibt die aktuelle Leseposition an. Mit datei.seekg (0, ios::end); wird auf das 0–te Byte rückwärts vom Ende her, d.h auf das Dateiende positioniert. Der Aufruf datei.clear (); im Beispiel oben ist notwendig, da die Datei zuvor bis zum Ende gelesen wurde und darum das EOF–Flag noch gesetzt ist. (Ein erfolgloses Lesen setzt die EOF–Bedingung. seekg allein entfernt sie nicht wieder!) 9.5.3 Positionierung des Schreibzugriffs Äquivalent zum Setzen der Leseposition mit seekg, kann mit seekp die Schreibposition einer Datei gesetzt werden. Beispiel: #include <fstream> #include <stdlib.h> using namespace std; int main (int argc, char * argv[]) { ofstream datei (argv[1]); streampos pos; char ch; if (!datei) { cout << "kann " << argv[1] << " nicht oeffnen\n"; exit (1); } cin >> pos; cin >> ch; while (ch != ’X’) { cout << "setze " << ch << "auf " << pos << endl; datei.seekp (pos); // Schreibposition setzen datei.put (ch); cin >> pos; cin >> ch; } } Mit diesem Programm können Bytes an beliebigen Positionen einer Datei geschrieben werden. 9.5.4 Dateien mit Lese– und Schreibzugriff Dateien können gleichzeitig zum Lesen und zum Schreiben geöffnet werden. Das ist wichtig beispielsweise für eine Datei, die als dauerhafter Speicher von Werten genutzt wird. Ein einfaches Beispiel ist: #include <fstream> #include <stdlib.h> Programmierung II 284 using namespace std; int main (int argc, char * argv[]) { // Oeffnen zum Lesen und Schreiben: fstream datei (argv[1], ios::in|ios::out); streampos int char char pos; rpos; buf[10]; c; // Byte-Puffer if (!datei) { cout << "kann " << argv[1] << " nicht oeffnen \n"; exit (1); } cout << "Aktion: L(esen) oder S(chreiben) oder E(nde): "; cin >> c; while (true) { switch (c) { case ’L’: // Lesen cout << "Position: "; cin >> rpos; pos = rpos * 10; datei.seekg (pos); cout << "Lese Pos " << datei.tellg () << "\n"; datei.read (buf, 10); cout << buf << endl; break; case ’S’: // Schreiben cout << "Was: "; cin >> buf; cout << "Position: "; cin >> rpos; pos = rpos * 10; datei.seekp (pos); cout << "Schreibe Pos " << datei.tellp () << "\n"; datei.write (buf, 10); datei.flush(); break; case ’E’: return 0; } cout << "Aktion: L(esen) oder S(chreiben) oder E(nde)\n"; cin >> c; } } Mit diesem Programm können jeweils Folgen von 10 Zeichen in eine Datei geschrieben und später wieder gelesen werden. Der Dateiinhalt bleibt bei Programmende erhalten und kann beim nächsten Aufruf wieder gelesen und weiter modifiziert werden. read und write sind Varianten von get und put, die nicht nur ein Zeichen sondern eine Zeichenfolge lesen bzw. schreiben. Ihr erster Parameter ist ein Zeiger auf ein char–Feld und der zweite ist die Zahl der Bytes (chars) die zu lesen oder zu schreiben sind. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9.5.5 285 Der Typ fstream und der Modus des Öffnens einer Datei Die (Programm–) Datei ist hier vom Typ fstream. Weiter oben hatten wir es mit Dateien vom Typ ifstream und ofstream zu tun, also mit Dateien, die auf Ein– oder Ausgabe spezialisiert waren. fstream ist allgemeiner und kann darum beides. Beim Öffnen der (realen) Datei muss darum jetzt explizit der Öffnemodus angegeben werden, d.h. die Art in der die Datei zu öffnen ist. ios::in ist der Eingabemodus, mit datei(datei name, ios::in); wird eine Datei zum Lesen, mit datei(datei name, ios::out); zum Schreiben und mit datei(datei name, ios::in|ios::out); zum Lesen und Schreiben geöffnet. Der Operator | ist ein Bit–Oder mit dem das Lese– und das Schreib–Flag gleichzeitig gesetzt werden. datei(datei name, ios::app); öffnet zum “Anhängenden” (append) Schreiben. Die Datei wird geöffnet, aber dabei nicht wie bei ios::in gelöscht. Alle Schreiboperationen hängen ihre Daten hinten an. 9.5.6 Die Bewegungen der Lese– und der Schreibposition sind nicht unbedingt unabhängig Die Lese– und Schreibposition einer Datei können sich unabhängig voneinander bewegen, sie müssen aber nicht. Wird beispielsweise mit seekg die Leseposition verändert, dann kann die Schreibposition noch den alten Wert haben, sie kann jetzt aber auch den gleichen (neuen) Wert wie die Leseposition haben. Das gleiche gilt natürlich umgekehrt auch für eine Veränderung der Schreibposition. Benötigt man beide Positionen, dann bewegt man sie am besten auch immer beide parallel. (Nach dem Verändern einer Position ist die andere undefiniert.) 9.5.7 Mit flush werden Dateipuffer geleert Der Aufruf datei.flush() bewirkt, dass die zu schreibenden Zeichen sofort auch tatsächlich in die Datei geschrieben werden. Normalerweise arbeiten die Dateioperationen mit einem internen I/O-Puffer. Mit ihm wird die Zahl der aufwendigen realen Datei–Operationen minimiert und der Zugriff erheblich beschleunigt. Bei Anwendungen die sowohl lesen als auch schreiben, kann es passieren, dass geschriebene Werte beim nächsten Lesen noch im Puffer sind und eigentlich Überschriebenes wieder gelesen wird. Dieser Effekt wird mit flush verhindert. Übrigens hat endl ebenfalls eine flush Funktionalität. ost << endl; hat die gleiche Wirkung wie ost << "\n"; ost.flush(); 9.6 9.6.1 Dateistrukturen Dateien als persistente Datenspeicher Dateien sind nicht nur dazu da, um Programmeingaben zu liefern und Programmausgaben aufzunehmen. In Dateien kann man auch Daten über die Dauer eines Programmlaufs hinaus speichern. Damit kann ein Programm auf einem Datenbestand arbeiten, ohne permanent aktiv sein zu müssen. Die Konten einer Bank sollen ja nicht gelöscht werden, nur weil das Programm, das die Konten führt, einmal angehalten wird oder abstürzt. Die peramanente Speicherung von Daten ist eine ganz wesentlichste Fähigkeit der Dateien. Sie sind damit als dauerhafte – oder wie man oft sagt persistente – Datenspeicher einsetzbar (Persistenz (engl. persistence) = Dauerhaftigkeit). Beliebige Daten können in ihnen für beliebig lange Zeiträume abgelegt und zur Bearbeitung bereitgehalten werden. Programmierung II 286 9.6.2 Dateien sind Byteströme Im Gegensatz zu den programminternen Speichern, wie etwa Feldern, kann man nicht beliebige Arten von Dateien anlegen. char–Felder, float–Felder, etc. Felder von beliebigen vor– oder selbst definierten Typen werden von der Sprache unterstützt. Das gilt aber nicht für Dateien.30 Dateien sind – in C++ – immer Folgen von Bytes und nicht etwa Folgen von Zahlen, oder von Stammsätzen eines Personalverzeichnisses etc. 9.6.3 Struktur einer Datei Nehmen wir an, die Einträge eines Telefonbuchs sollen auf Datei gespeichert werden: struct TEintrag { TEintrag (); TEintrag (string, string); string name; string telNr; }; TEintrag::TEintrag () : name ("??") {} TEintrag::TEintrag (string p_n, string p_nr) : name (p_n), telNr (p_nr) {} Es scheint zunächst leicht zu sein, solche Einträge auf Datei auszugeben: ostream & operator<< (ostream &s, TEintrag p) { return s << p.name << p.telNr; } Werden mit dieser Funktion allerdings tatsächlich Werte ausgegeben, z.B.: TEintrag hugo ("Hugo Meier", "4711"); TEintrag emil ("Emil Mueller", "4712"); datei << hugo; datei << emil; dann geht die Struktur der Daten verloren: Hugo Meier4711Emil Mueller4712 erscheint in der Datei. datei ist eben eine Datei und damit eine Folge von Bytes und keine Folge von Telefonbucheinträgen vom Typ TEintrag. Wollen wir umgekehrt von einer Datei den 17–ten Telefonbucheintrag lesen, dann können wir in der Datei nicht mit datei.seekg(17) auf den 17. Eintrag positionieren und dann lesen: datei.seekg(17) positioniert auf das 17–te Byte und nicht auf den 17–ten TEintrag. Sollen Dateien also zur Speicherung von etwas anderem als Bytes (= chars) genutzt werden, dann ist offensichtlich noch etwas eigene Programmierarbeit erforderlich. 9.6.4 (Daten–) Satz und Feld Eine Datei soll genutzt werden, um Folgen von Werten eines Typs zu speichern. Im allgemeinsten Fall ist , ... . Kurz: ist ein Verbundtyp benutzerdefiniert und enthält eine Reihe von Komponenten vom Typ , (Struct oder Klasse). 30 Diese Aussage gilt nicht für alle Programmiersprachen. In Pascal beispielsweise können Werte beliebiger Datentypen direkt in einer Datei abgelegt werden. Es hat sich jedoch herausgestellt, dass diese Art der Dateiunterstützung problematisch ist. In C++ können zwar auch Werte beliebiger Typen in einer Datei abgelegt werden. Dies muss aber von der Programmiererin selbst organisiert werden (z.B. durch Definition eines geeigneten operator<< und operator>>, Umwandlung in einen Iterator, etc.), es kann nicht der Sprachimplementierung überlassen werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 287 In der langen Tradition der Dateibearbeitung werden die logischen Elemente einer Datei (Daten–) Satz (engl. Record) genannt. Sind die Sätze selbst wieder strukturiert, dann nennt man die einzelnen Unterkomponenten jeweils Feld (engl. Field): Satz (Record): Logisches Element einer Datei. Dateien bestehen aus Folgen von Sätzen gleicher Art. Feld: Element eines Satzes. Sätze bestehen aus Folgen von Feldern. Die Felder eines Satzes sind i.A. von unterschiedlicher Art. Damit die logische Struktur einer Datei als Folge von Sätzen, bestehend aus Folgen von Feldern, nicht in deren Elementarstruktur “Bytefolge” untergeht, muss sie in irgendeiner Form erhalten werden: Dateien und die in ihnen enthaltenen Sätze müssen strukturiert werden. 9.6.5 Feldstrukturen Die Feldstruktur innerhalb eines Satzes kann mit verschiedenen Methoden kenntlich gemacht werden. Üblich sind: 1. Trennsymbole: Die einzelnen Felder werden mit einem speziellen Trennsymbol z.B. einem Strich | voneinander getrennt. Das Trennsymbol darf natürlich nicht als Bestandteil eines Felds auftreten. Die Reihenfolge der Felder innerhalb eines Satzes muss fest und dem lesenden Programm bekannt sein. 2. fixe Länge: Wenn jedem Feld eine feste Länge innerhalb der Datei zugewiesen wird, dann kann ein Programm (das diese Länge kennt) Felder wieder eindeutig aus der Datei auslesen. Die Reihenfolge der Felder innerhalb eines Satzes muss auch hier fest und dem lesenden Programm bekannt sein. 3. Längenfelder: Will man die Länge der Felder variabel halten, dann muss jedes Feld zusammen mit einer Information über dessen Länge gespeichert werden. Wieder muss die Reihenfolge der Felder innerhalb eines Satzes fest und dem lesenden Programm bekannt sein. 4. Schlüssel–Wert Speicherung: Will man die Reihenfolge der Felder innerhalb eines Satzes variabel halten, oder enthält ein Satz eventuell nur eine Teilmenge aller möglichen Felder, dann müssen diese in der Schlüssel–Wert Form gespeichert werden. 5. Schlüssel, Länge, Wert Speicherung: Das ist die flexibelste aber auch aufwendigste Art der Speicherung. 9.6.6 Satzstrukturen Bei der Speicherung der Sätze hat man im Prinzip die gleichen Probleme und Lösungen wie bei der von Feldern. Üblich ist: 1. Trennsymbole: Sätze werden mit einem speziellen Symbol voneinander getrennt. 2. fester Länge: Alle Sätze haben die gleiche feste Länge. 3. Längenfeld: Jeder Satz beginnt mit einem Feld in dem dessen Länge zu finden ist. 4. Indexdatei: In einer zweiten Datei – der Indexdatei – wird die Startadresse jedes Satzes festgehalten. Satz Nr. in der Indexdatei kann dann beispielsweise die Adresse (Position des ersten Bytes) von Satz Nr. in der ersten Datei enthalten. Mit diesem Schema kann man direkt auf jeden Satz zugreifen, obwohl diese eventuell alle unterschiedliche Längen haben. (Die Indexdatei hat natürlich eine feste Satzlänge.) Programmierung II 288 Jede Art der Speicherung hat ihre Vor– und Nachteile. Sätze und Felder fester Länge können einfach modifiziert oder durch andere ersetzt werden. Sie können aber auch zu einer enormen Verschwendung von Speicherplatz führen. Sätze und Felder variabler Länge benötigen weniger Speicherplatz, sind aber nur mit Aufwand zu modifizieren oder zu ersetzen. 9.6.7 Beispiel Als besonders einfache Variante betrachten wir ein Beispiel, bei dem die Trennsymbole Doppelpunkt und Zeilenvorschub zur Trennung der Felder bzw. der Sätze verwendet werden: struct TEintrag { TEintrag (); TEintrag (string, string); string name; string telNr; }; TEintrag::TEintrag () : name ("??") {} TEintrag::TEintrag (string p_n, string p_nr) : name (p_n), telNr (p_nr) {} ostream & operator<< (ostream &s, const TEintrag &e) { return s << e.name << ":" << e.telNr << endl; } istream & operator>> (istream &s, TEintrag &e) { char lineBuf[256]; s.getline(lineBuf, 256); string line = lineBuf; e.name = string(line, 0, line.find(’:’)); e.telNr = string(line, line.find(’:’)+1, (line.length()-line.find(’:’))-1); return s; } Das Trennsymbol der Felder ist der Doppelpunkt (:). Die Sätze werden durch einen Zeilenvorschub getrennt. Will man bei dieser Speicherung den –ten Satz einlesen, dann bleibt nichts übrig, als alle vorherigen zu überlesen: bool getRecord (fstream &d, TEintrag &e, int nr) { TEintrag buf; d.seekg (0); int count = 0; while (count != nr && d) { // Vorherige ueberlesen d >> buf; // liest einen Satz count ++; } if (d) { d >> e; // e enthaelt jetzt den gesuchten Satz if (d) return true; } return false; } 9.7 Binärdateien 9.7.1 Binärdateien Wie bereits weiter oben erläutert, unterstützt C++ direkt nur die Speicherung von Bytes. (Lesbare) Zeichen (ASCII–Zeichen) sind eine Untermenge der Bytes und ihre Speicherung ist damit unproblematisch. Bei der Speicherung beliebiger Daten kann man diese Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 289 entweder in Folgen (lesbare) Zeichen (Bytes) umwandeln und dann diese speichern (z.B. den Int–Wert als drei Bytes ’1’,’2’,’3’), oder direkt – ohne Umwandlung– als Bytefolgen genauso ablegen wie sie im Speicher liegen. Im zweiten Fall spricht man von Binärdateien. Man beachte, dass es sich dabei nicht um unterschiedliche Arten von C++–Dateikonstrukten oder gar um unterschiedliche Arten von realen Dateien handelt. Es geht nur um unterschiedliche Arten mit Dateien zu arbeiten. (Siehe Abbildung 76): Speicher im Rechner int i = 21; 4 Bytes int im Programm 0001 0101 0000 0000 0000 0000 0000 0000 Umwandlung z.B. operator<< exakte Kopie Speicherung in Textdatei 0011 0010 0011 0001 ASCII-2 ASCII-1 Binärdatei 0001 0101 0000 0000 0000 0000 0000 0000 seltsame Zeichen Abbildung 76: Text– und Binärdateien 9.7.2 Binärdaten in ASCII–Zeichen umwandeln und speichern Die Daten des Typs TEintrag von oben bestehen nur aus Zeichenfolgen. Das muss nicht immer so sein. Gelegentlich will man auch Werte anderer Basistypen (int, float, ...) abspeichern. Sollen etwa Verbunde vom Typ Student: struct Student { Student (); Student (string, int); string name; int matrikelNr; }; Student::Student () : name ("??") {} Student::Student (string p_n, int p_nr) : name (p_n), matrikelNr (p_nr) {} als Sätze in einer Datei gespeichert werden, dann kann die Matrikelnummer entweder als Folge von Zeichen oder als int–Wert abgelegt werden. Die Speicherung (Schreiben und Lesen) als ASCII Zeichenfolge ist leicht zu organisieren: ostream & operator<< (ostream &s, const Student &e) { return s << e.name << ":" << e.matrikelNr << endl; } // Ausgabe Bei der Ausgabe wird die Matrikelnummer von int in eine Zeichenfolge umgewandelt. Der operator<< leistet hierbei die Hauptarbeit. 9.7.3 Konversion eines Teilstrings in Binärdarstellung Beim Einlesen muss dann in umgekehrter Richtung konvertiert werden: von einer Zeichenfolge in einen int–Wert. Diese Konversion der Matrikelnr in Binär– (int) Darstellung ist etwas aufwendiger als die Ausgabe: Programmierung II 290 istream & operator>> (istream &s, Student &e) { // Einlesen char lineBuf[256]; s.getline(lineBuf, 256); string line = lineBuf; e.name = string(line, 0, line.find(’:’)); e.matrikelNr = atoi ( //Konversion nach int (string(line, line.find(’:’)+1, (line.length()-line.find(’:’))-1) ).c_str()); return s; } Hier wird zunächst aus der Eingabe in line der Teil–String vom Doppelpunkt (:) bis zum Ende herausgefiltert, dieser dann mit (...).c str() in eine C–String umgewandelt und aus diesem schließlich mit atoi (...) der entsprechenden int–Wert berechnet. 9.7.4 String–Streams Für die Konversionsarbeit kann natürlich auch ein String–Stream eingesetzt werden. In diesem Fall arbeitet man am besten mit weißen Zeichen als Trennsymbolen. Bleibt es beim Doppelpunkt, dann ist auch die Arbeit mit String–Streams etwas umständlich: #include <sstream> ... istringstream is ((string(line, line.find(’:’)+1, (line.length()-line.find(’:’))-1) ).c_str()); is >> e.matrikelNr; ... Mit istringstream is (..String..); wird ein String in einen Eingabe String Stream umgewandelt. Auf diesen kann dann der Eingabe–Operator >> angewendet werden und somit dessen Konversionsfähigkeiten genutzt werden: is >> ..int-Variable..; 9.7.5 Ein Zeilenvorschub ist nur in Binärdateien ein einfaches Zeichen Wie wir gesehen haben, ist die Frage, ob eine Datei eine Binär– oder eine Textdatei ist, keine Eigenschaft der Datei, sondern eine Frage der Formatierung, also der Art wie die Daten beim Lesen und Schreiben interpretiert und eventuell umgewandelt werden. Die Formatierung wird durch die Shift–Operatoren definiert und liegt damit fast vollständig in der Verantwortung des Programms. Das “fast” bezieht sich auf ein besonderes Zeichen: den Zeilenvorschub. Der Zeilenvorschub ist ein ganz besonderes Zeichen: Jedes Betriebssystem hat sein eigenes Verständnis welches Zeichen bzw. welche Zeichenfolge “Zeilenvorschub” bedeuten. Damit ein Programm von diesen Unterschieden unbehelligt bleibt, wird der systemspezifische Zeilenvorschub als “\n” an das Programm geliefert – egal welches Zeichen für des System nun gerade “Zeilenvorschub” bedeutet. Umgekehrt wird jedes “\n” des Programms in der systemspezifischen Art als Zeilenvorschub gespeichert, z.B. als zwei Zeichen “\r\n”. Bei einer Datei, deren Inhalt als Binärdaten behandelt werden, kann diese Sonderbehandkung von einzelnen Zeichen zu Fehlern führen. Das Byte “\n” kann zufällig Bestandteil einer Float–Zahl sein. Wird das Byte dann ohne Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 291 Konversion direkt gespeichert, dann wird das Basis–I/O-System von C++ es als Zeilenvorschub interpretieren, der an die Gegebenheiten und Konventionen des jeweiligen Betriebssystems anzupassen ist. Es ist klar, dass dies dazu führen kann, dass die Daten zerstört werden. Für das Lesen aus Datei gilt entsprechendes. Bei Binärdaten sollte darum die Sonderbehandlung des Zeilenvorschubs ausgeschaltet werden. Man erreicht dies durch Öffnen der Datei im Binärmodus, d.h. mit dem Flag ios::binary: ifstream datei ("dat.bin", ios::binary); // Datei binaer oeffnen: // keine Sonderbehandlung von ’\n’ ... datei << "\n"; ... datei >> c; // auf jedem Ssytem ausgegeben wird \n ausegeben // Ein Zeilenvorschub erscheint nicht unbedingt als \n Jetzt werden alle Zeichen unverändert geschrieben und gelesen. 9.7.6 Binärdateien: Binärdaten unverändert direkt speichern Die Konversionen binär nach ASCII–Daten und umgekehrt kann man dem System ersparen, wenn die Binärdaten direkt, so wie sie sind, in die Datei geschrieben werden. Eine Datei mit solchen “rohen” Daten wird Binärdatei genannt. Wir zeigen eine Variante, bei der alle Daten ohne Konversion gespeichert werden und die mit festen Feld– und Satzlängen (statt mit Trennsymbolen) arbeitet: ostream & operator<< (ostream &s, const Student &e) { //Name speichen: char buf[20]; // Puffer fuer den auf 20 Zeichen // normierten Namen strncpy (buf, "", 20); // Puffer saeubern: mit 0-en fuellen strncpy (buf, e.name.c_str(), // max 20 Zeichen des Namens kopieren e.name.length() < 20 ? e.name.length() : 20); s.write (buf, 20); // 20 Bytes in die Datei speichern //Matrikelnummer speichern: s.write (static_cast<const char *>( // int als char-Folge behandeln static_cast<const void *>( &e.matrikelNr)), sizeof (int)); // sizeof(int) Bytes werden gespeichert return s; } Der erste Teil der Routine beschäftigt sich damit, den Namen auf 20 Zeichen auszuweiten, ihn zu kürzen und auszugeben. Der zweite Teil besteht im wesentlichen darin die Konversion der Matrikelnummer von int in einen String zu verhindern. Mit einer einfachen Ausgabe in der Form s << e.name << ": < e.matrikelNr << endl; würde e.matrikelNr unweigerlich in eine Zeichenkette verwandelt. Dies kann verhindert werden, indem man das Schreiben der write–Methode überlässt, der man mit den beiden Cast–Operationen vorgaukelt, dass sie einen C–String (vom Typ char *) schreibt: s.write (static_cast<const char *>( static_cast<const void *>( &e.matrikelNr)), sizeof (int)); // // // // // // 2. Cast des Zeigers von void * nach char * 1. Cast des Zeigers von int * nach void * ein Zeiger auf int wieviele Bytes sind zu schreiben Programmierung II 292 Mit static cast<void *>(..Zeiger..) kann ein Zeiger in einen vom Typ void * konvertiert werden und static cast<..Zeiger-Typ..>(..Zeiger vom Typ void *..) ist sozusagen die Umkehrfunktion. 9.7.7 Binärdatei schreiben und ansehen Speichert man mit diesen Funktionen Datensätze: ... fstream datei ("stud.bin", ios::in|ios::out|ios::binary); ... Student hugo; ("Hugo Meier", 15); Student emil; ("Emil Muller", 254); ... datei << emil; datei << hugo; ... in einer Datei mit Namen stud.bin und sieht sich dann (unter Unix) mit dem Dump–Utility od das Ergebnis an od -xc stud.bin dann wird sich die Datei etwa wie folgt zeigen: 0000000 6d45 E 0000020 0000 \0 0000040 7265 e 0000060 0a00 \0 0000062 6c69 4d20 6cfc m i l 0000 00fe 0000 \0 \0 \0 0000 0000 0000 r \0 \0 \0 656c 0072 0000 0000 M u l l e 7548 6f67 4d20 6965 \0 \0 \0 H u 0000 0000 000f 0000 \0 \0 \0 \0 \0 r \0 \0 \0 \0 \0 g o M e i \0 \0 017 \0 \0 \0 \n Die Datei wird in Form von Zeilenpaaren – Datei–Zeile, ASCII–Decodierung der Zeile – dargestellt. Jede Zeile enthält in der ersten Spalte die Adresse und dann 16 Bytes Dateiinhalt. Die Adresse ist die Dateiposition des ersten Bytes dieser Zeile in Hex–Darstellung. Die Matrikelnummern finden sich hier als jeweils 4 Bytes mit den Hex–Werten 00fe 0000 (245) bzw. 000f 0000 (15)31. Die Einleseoperation ist komplementär zur Ausgabeoperation: istream & operator>> (istream &s, Student &e) { char buf[21]; strncpy (buf, "", 21); s.read(buf, 20); e.name = string(buf); s.read (static_cast<char *>(static_cast<void *>( &e.matrikelNr)), sizeof(int)); return s; } 9.7.8 Systemabhängige Byteordnung und Bytelänge Das Format von Binärdaten ist systemabhängig. Dateien, die auf dem einem System erzeugt werden, können nicht ohne weiteres auf einem anderen gelesen werden. Die Zahlen der Bytes pro int– oder float–Wert variieren 31 Die genaue Art der Speicherung ist systemabhängig. Das Beispiel wurde auf einem Rechner mit Intel–Prozessor erstellt, wie man unschwer an der Byteordnung der beiden Zahlen erkennt. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 293 von System zu System und das Format der Speicherung kann sich ebenfalls unterscheiden. Sind unterschiedliche Systeme im Spiel, dann einigt man sich am einfachsten darauf alles im ASCII–Format zu speichern. 9.8 9.8.1 Dateiabstraktion: Datei als Speicher von Sätzen fester Länge Modularisierung Angenommen wir wollten ein Auskunftsystem für Telefonnummern implementieren. Eine solche Anwendung umfasst stets zwei elementare Grundbestandteile – zwei Module wie man sagt. Einer der beiden Module wickelt die Anfragen ab, der andere beschäftigt sich mit der Speicherung der Daten. Eine solche Modularisierung (Zerlegung) der Aufgabe hat wesentliche Vorteile. Bei der Bearbeitung der Benutzeranfragen muss man sich nicht um Details der Datenspeicherung kümmern und umgekehrt kann die Speicherung der Daten vollständig unabhängig von der speziellen Anwendung konzipiert und realisiert werden. Änderungen des einen Moduls tangieren den anderen nicht und im Idealfall kann ein Modul oder beide auch in völlig anderen Anwendungen eingesetzt werden. 9.8.2 Dateiabstraktion Bei einer Modularisierung ist die erste und wichtigste Frage die nach der Schnittstelle der Module. In unserem Fall interessieren wir uns speziell für die Schnittstelle der Dateibehandlung zum Rest der Anwendung. Das Modul “Dateibehandlung” soll natürlich etwas mehr Service bieten als die Dateioperationen, die sowieso schon von C++ her zur Verfügung stehen – andernfalls wäre es ja völlig überflüssig. Die Frage ist, welcher “Mehrwert” denn genau geboten werden soll. Es muss so etwas wie eine mächtigere, intelligentere Version von Datei sein, eine die Besseres bietet als die Byteströme, die von den vordefinierten Dateien manipuliert werden. Alles was es nicht vordefiniert gibt, sondern selbst implementiert werden muss, wird üblicherweise “Abstraktion” genannt. Die gesuchte Schnittstelle zum Speichermodul nennen wir darum eine Dateiabstraktion. (Siehe Abbildung 77): BildschirmSteuerung Benutzeroberfläche Datenspeicherung Konzept DateiZugriff Auskunftsystem Modularisierung Schnittstelle BildschirmSteuerung Modul-1: Benutzeroberfläche Operation auf abstrakter Datei Modul-2: Datenspeicherung (abstrakte Datei) DateiZugriff Operation auf C++-Datei Operation auf realer Datei Abbildung 77: Konzept einer abstrakten Datei als Speichermodul 9.8.3 FixedFile: Datei–Abstraktion für Sätze fester Länge Wir wollen eine Dateiabstraktion implementieren, die Datensätze fester Länge speichern kann. Der Service dieser Abstraktion soll bescheiden sein, sie soll uns lediglich das Öffnen, Schließen, Schreiben und Lesen abnehmen. Wir Programmierung II 294 möchten aber auch, dass der Service genutzt werden kann, um Sätze beliebiger aber fester Länge abzuspeichern. Die Verwendung der Abstraktion FixedFile könnte wie folgt aussehen: Record r; const int SIZE_R = char buf[SIZE_R]; .... Groesse der Darstellung von R .... .... R als Bytestrom .... FixedFile ff; // neue externe Datei erzeugen: ff.create ("testdatei.test", SIZE_R); // bereits existierende Datei "offnen ff.open ("testdatei.test"); // r schreiben: r.pack (buf); ff.store (buf); ... // r lesen: ff.fetch (buf); r.unpack (buf); // Satz in Bytefolge verwandeln: r -> buf // Bytefolge abspeichern // Bytefolge von Datei lesen // Byte in Datensatz umwandeln: buf -> r Für das Objekt ff von Typ FixedFile sind Sätze nichts anderes als Bytefolgen fester Länge. Die Transformation von Sätzen in Bytes und umgekehrt liegt vollständig außerhalb von ff. Die Methoden pack und unpack vom Satztyp Record sind für diese Aktionen zuständig. 9.8.4 pack und unpack: wer wandelt Sätze in Bytefolgen und umgekehrt um Die Aufrufe von pack und unpack aus der Anwendung heraus treffen nicht unbedingt unsere Idealvorstellungen. Es wäre wesentlich schöner, wenn ein Objekt vom Typ Record direkt – ohne (un)pack – gespeichert und gelesen werden könnte: FixedFile ff; Record r; ... // r schreiben: ff.store (r); ... // r lesen: ff.fetch (r); Das steht allerdings im Widerspruch zu unserer Forderung, dass FixedFile ff mit beliebigen Sätzen zurechtkommen soll. Wenn es alle Sätze behandeln kann, dann muss es unabhängig vom Typ Record der Sätze sein und kann infolgedessen nicht wissen, wie diese gepackt und entpackt werden. Die Umwandlungen (Packen und Entpacken) kann nur eine Methode von Record vornehmen. Den Aufruf der entsprechenden Pack–Operationen kann man allerdings von der Anwendung in die Dateioperationen verschieben. Die Leseoperation folgt dann folgendem Schema: ... FixedFile::fetch (Record &r, ...) { ... char buf [..recSize..]; file.read (buf, ..recSsize..); // Bytes lesen r.unpack (buf); // Bytes in Record konvertieren ... } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 295 Jetzt ist aber FixedFile::fetch direkt vom Satz–Typ Record und dessen Größe recSsize abhängig. Damit müsste für jeden Satztyp ein eigener Dateityp FixedFile fuer Record xy definiert werden. Dem kann aber mit der Umwandlung von FixedFile in ein Template abgeholfen werden, dafür gibt es sie ja: FixedFile<Record> ff; Record r; ... // r schreiben: ff.store (r); ... // r lesen: ff.fetch (r); 9.8.5 Datei–Abstraktion für Sätze fester Länge als Template Die Transformation eines Satzes in eine Bytefolge kann jetzt wie gewünscht innerhalb von FixedFile stattfinden. Zwar ist es immer noch der Datensatz r, der weiß, wie er zu Bytes wird (pack) und aus Bytes entstehen kann (unpack). Dieses Wissen kann aber als Template–Argument in FixedFile hinein transportiert werden. Betrachten wir beispielsweise die Leseoperation fetch: template<class Record> bool FixedFile<Record>::fetch (Record &r, streampos pos) { if (!isOpen) return false; if (pos > 0) file.seekg (pos); char buf [Record::recSize]; file.read (buf, Record::recSize); if (!file) { ::perror ("Kann Datei nicht lesen\n"); return false; } r.unpack (buf); return true; } Die Satzgröße recSize kommt hier mit dem Templateparameter Record in den Code von FixedFile. Der Typ der Datensätze Record muss natürlich die entsprechenden Dienste liefern. Z.B.: struct EinRecord { ... // Wird von FixedFile benoetigt: void pack (char *) const; void unpack (const char *); static const int recSize = ...; }; ... FixedFile<EinRecord> ff; EinRecord r; ... // Satz -> Bytes // Bytes -> Satz // Anzahl der Bytes im gepackten Satz Die drei Komponenten müssen natürlich öffentlich sein, damit FixedFile sie benutzen kann. recSize wird im Template benötigt um Sätze zu lesen und zu schreiben. Die Speicheroperation beispielsweise ist: template<class Record> bool FixedFile<Record>::store (const Record &r, streampos pos) { if (!isOpen) return false; if (pos > 0) file.seekp (pos); Programmierung II 296 char buf [Record::recSize]; // Platz fuer Satz als Bytefolge r.pack (buf); // Als Bytefolge ablegen file.write (buf, recSize); // in Datei speichern file.flush(); if (!file) { ::perror ("Kann Datei nicht schreiben\n"); return false; } return true; } Die Klassendefinition des Templates für Dateien mit Sätzen fester Länge ist insgesamt: template<class Record> class FixedFile { public: FixedFile (); FixedFile (const char *fileName); ˜FixedFile (); bool open (const char *fileName); bool create (const char *fileName); void close (); bool store (const Record &, streampos pos = -1); bool fetch (Record &, streampos pos = -1); bool append (const Record &); bool rewind (); bool eof (); private: fstream int bool file; recSize; isOpen; static const int headerSize = 32; bool bool readHeader (); writeHeader (); }; Die Leseposition soll hier als die Byteposition des Satzanfangs interpretiert werden. Selbstverständlich kann man auch mit Satznummern arbeiten. Beim Zugriff muss die Satznummer einfach mit der Satzlänge multipliziert werden und schon erhält man die Byteposition. 9.8.6 Datei–Header mit Metadaten Mit open wird eine exisitierende Datei geöffnet und mit create wird eine Datei neu erzeugt. Jede Datei beginnt mit einem Header–Satz. In ihm werden Informationen über den Inhalt der Datei (also Metadaten) gespeichert. In unserem Beispiel speichern wir hier einfach die feste Länge der Sätze: template<class Record> bool FixedFile<Record>::open (const char *fileName){ if (isOpen) { close (); isOpen = false; } // Datei wird gelesen und geschrieben, sie muss bereits existieren: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 297 file.open (fileName, ios::in|ios::out|ios::nocreate); if (!file) { ::perror ("Kann Datei nicht oeffnen\n"); return false; } else { if (readHeader ()) { // Header lesen gelungen //Auf den ersten Daten-Satz positionieren: file.seekp (headerSize, ios::beg); file.seekg (headerSize, ios::beg); isOpen = true; return true; } } return false; } Mit dem Header der Datei kann jetzt geprüft werden ob zumindest die Satzlänge korrekt ist: template<class Record> bool FixedFile<Record>::readHeader () { char buf[headerSize]; file.seekg (0); file.read (buf, headerSize); if (!file) { ::perror ("Kann Header nicht lesen\n"); return false; } else { recSize = atoi (buf); if (recSize != Record::recSize) { ::perror ("Falsches Dateiformat!\n"); return false; } return true; } } Selbstverständlich ist eine passende Satzlänge allein noch keine Garantie, dass die Datei auch tatsächlich Sätze vom erwarteten Typ enthält. Ein gewisser Schutz besteht aber und bei Bedarf können weitere Informationen im Header abgelegt werden. Zur Konversion eines C–Strings nach Interger haben wir hier wieder die Funktion #include <stdlib.h> int atoi (const char *); aus der C–Bibliothek benutzt. 9.8.7 Metadaten erzeugen Die Methode create hat komplementär zu open die Aufgabe den Header zu erzeugen: template<class Record> bool FixedFile<Record>::create (const char *fileName){ if (isOpen) { close (); isOpen = false; } file.open (fileName, ios::out); if (!file) { Programmierung II 298 ::perror ("Kann Datei nicht erzeugen\n"); return false; } else { recSize = Record::recSize; file.close(); return writeHeader (); } } Der Rest der Methoden ist wohl offensichtlich. 9.8.8 Rückblick auf den Entwurf: Das Verantwortungsprinzip Der Entwurf von FixedFile und ihrem Partner Record basiert auf dem Prinzip der Verantwortung: Welche Klasse ist für was verantwortlich. Verantwortung sollte dabei nur einer Klasse übertragen werden, die auf Grund ihres Wissens in der Lage ist, die Verantwortung zu tragen. Nach diesem Prinzip hat sich folgende Aufgabenteilung ergeben: FixedFile ist für alle Operationen auf der realen Datei zuständig: Erzeugen, Öffnen, Bytefolgen lesen und schreiben. Record muss sich selbst in eine Bytefolge konvertieren und aus einer Bytefolge rekonstruieren können. Damit ergibt sich dann fast zwangsläufig die Interaktion der beiden. In UML als Interaktionsdiagramm dargestellt (Siehe Abbildung 78): open fetch (r) read unpack ff:FixedFile :fstream r:Record Abbildung 78: Interaktion von FixedFile und Record FixedFile muss, um die richtige Zahl an Bytes lesen und schreiben zu können, die Größe von Record kennen. Record exportiert den entsprechenden Wert recSize als statische Datenkomponente, er ist ja für alle Instanzen gleich. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9.9 9.9.1 299 Iteratoren auf abstrakten Satz–Dateien Interne aktuelle Position oder externe aktuelle Position (Iterator) Im letzten Abschnitt haben wir eine Dateiabstraktion betrachtet, bei der Sätze über ihre Byteposition zugreifbar waren. Das Beispiel kann leicht so modifiziert werden, dass beim Zugriff statt der Byteadresse eines Satzes dessen Nummer angegeben wird. Das Ergebnis ist eine virtuelle (= abstrakte) Satz–Datei mit wahlfreiem Zugriff. Für viele Anwendungen ist eine solche Satz–Datei die geeignete Abstraktion die man über “rohe” Dateien legt. Ein wesentliches Element realer Dateien enthält sie allerdings nicht mehr: Es gibt keine verschiebbare aktuelle Position. Das Konstrukt der Satzdatei kann bei Bedarf auch um eine verschiebbare aktuelle Dateiposition in einer abstrakten Satzdatei erweitert werden. Der wahlfreie Zugriff kann dabei erhalten bleiben oder wegfallen. Wie bei Listen oder anderen Behälterklassen hat man zwei prinzipielle Konzepte zur Verfügung um eine aktuelle Position in einer Sequenz zu realisieren: interne aktuelle Position: Die Datei–Abstraktion beinhaltet eine (oder mehrere) verschiebbare aktuelle Positionen. Beispielsweise eine Lese– und eine Schreibposition. Das ist das Positionskonzept der realen Dateien. externe aktuelle Position (Iterator): Die Datei–Abstraktion ist positionslos und wird mit einem (oder mehreren) zugordneten Iteratoren durchlaufen. Dieses Konzept wird in der Regel bei Listen und anderen Behälterklassen verwendet. Man beachte, dass wir hier von Satz–Positionen reden; nicht von Byte–Positionen. Eine reale Datei hat eine Lese– und eine Schreibposition als Byteadressen. Bei der Konstruktion einer Dateiabstraktion als permanentem Speicher von Satzfolgen kann die Unterscheidung in Lese– und Schreibposition in irgendeiner Form übernommen werden. Das muss nicht sein. 9.9.2 Datei–Abstraktion mit interner aktueller Position Bei Datei–Abstraktionen mit interner aktueller Position wird ein virtueller Cursor über die Datei von Satz zu Satz geschoben. Der Satz an der aktuellen Position kann gelesen, modifiziert und eventuell gelöscht werden. Nach dem Öffnen der Datei steht der Cursor vor dem ersten Satz, mit next wird er zum nächsten verschoben. Der Cursor ist Bestandteil der Datei (–Abstraktion) und wird mit deren Methoden verschoben. Eine entsprechende Abstraktion könnte wie folgt aussehen: template<class Record> class CursorFile { public: CursorFile (); CursorFile (const char *fileName); ˜CursorFile (); bool open (const char *fileName); bool create (const char *fileName); void close (); bool bool bool bool bool next enter update read erase bool rewind private: bool (); (const Record &); (const Record &); (Record &); (); (); offLeft; // // // // // auf den naechsten Satz positionieren neuen Satz speichern aktuellen Satz ueberschreiben aktuellen Satz lesen aktuellen Satz loeschen // zurueck an den Anfang // ist die aktuelle Position vor dem ersten ? Programmierung II 300 bool ... offRight; // ist die aktuelle Position hinter dem letzten ? }; Ein Verwendung würde etwa folgendes Aussehen haben: ... CursorFile<Student> studies("Studie-Datei"); ... studies.rewind(); // zurueck spulen while (!studies.next()) { // auf den naechsten positionieren Student s; studies.read (s); // Satz an der aktuellen Position ... s bearbeiten ... // lesen, veraendern, ... } ... 9.9.3 Datei–Abstraktion mit Iterator: istream iterator und ostream iterator reichen nicht Weiter oben haben wir gesehen wie mit istream iterator bzw. mit ostream iterator reale (Text–) Dateien (vom Typ ifstream bzw. ofstream) in Iteratoren über einem beliebigen Typ T verwandelt werden. Hier wollen wir abstrakte Dateien (mit beliebigem selbst definiertem Typ) mit Iteratoren versehen. Ein istream iterator kann nur lesen. Ein ostream iterator kann nur schreiben; beide können sich nur immer jeweils um ein Element nach vorn bewegen. Mit einem Iterator sollte man aber gleichzeitig lesen, schreiben und sich eventuell auch in beliebiger Richtung bewegen können. Ein ausgewachsener Iterator zu einer abstrakten Datei müsste also selbst definiert werden. Dateien (abstrakt oder real) mit externer Position (d.h mit zugeordnetem Iterator) sind jedoch nicht üblich. Wir verzichten darum auf diese Art der Positionierung. 9.10 Verwaltung von Sätzen 9.10.1 Elementare Dateioperationen Für die meisten Datei–orientierten Anwendungen sind Dateien Kollektionen von Sätzen. Die prinzipiellen Operationen auf einer Datei sind darum: Lesen: Soll ein Satz gelesen werden, dann muss er vorher in der Datei gefunden und identifiziert werden. Verändern: Verändern eines Satzes kann als Löschen und anschließendes Hinzufügen verstanden werden. Hinzufügen: Beim Hinzufügen muss ein Platz für den neuen Satz innerhalb der Datei gefunden werden. Im einfachsten Fall und als letzte Möglichkeit ist am Ende der Datei praktisch immer Platz für einen weiteren Satz. Löschen: Sätze können tatsächlich in der Datei gelöscht werden. Da dies aber mit erheblichem Aufwand verbunden ist – alle nachfolgenden müssen vorgeschoben werden –, wird man oft zu besseren Methoden des Löschens greifen – z.B. den Satz nur als gelöscht markieren –, die dann auch einen Einfluss auf das Hinzufügen haben. 9.10.2 Löschen und Kompaktieren Soll ein Satz in einer Datei gelöscht werden, kann man einfach die gesamte Datei mit Ausnahme von in eine neue Datei kopieren und anschließend in umbenennen. Hierbei wird praktisch im Fluge als gelöscht markiert und die Datei anschließend kompaktiert32. 32 Der Begriff “Kompaktieren” soll deutlich machen, dass die Datei in eine kompaktere Form gebracht wird, Verwechslungen mit “Kompression” sollen dabei aber vermieden werden. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 301 Diese Strategie lässt sich auch leicht auf die Veränderung eines Satzes erweitern: statt wird einfach dessen neue Version nach kopiert. Bei kleinen Dateien mit ein paar Hundert Sätzen, in denen Modifikationen nicht sehr häufig sind, ist das durchaus eine sinnvolle Strategie, die Implementierung ist einfach und das Verfahren kann auch auf Dateien mit Sätzen variabler Größe angewendet werden. Bei einem verbesserten Verfahren werden die zu löschenden Sätze mit einer speziellen Markierung versehen. Später können dann viele obsolete Sätze in einem Kompaktierungslauf entfernt werden. Es ist klar, dass als gelöscht markierte Sätze beim Lesen übersehen werden. 9.10.3 Dynamische Wiederverwendung gelöschter Sätze Enthält eine Datei Sätze, die mit “Gelöscht” markiert sind, dann spricht auch nichts dagegen diese bei den folgenden Hinzufüg–Operationen wieder zu verwenden. Bei fester Satzgröße ist das nicht problematisch, man nimmt den ersten, bei variabler Größe wird man nach einer passenden Lücke suchen müssen. Nach einiger Zeit werden dann viele kleine Lücken die Datei bevölkern und ein neuer Kompaktierungslauf ist fällig. 9.10.4 Verfügbarkeitsliste Die Suche nach einer Lücke in Form eines gelöschten Satzes kann beschleunigt werden, wenn die Lücken in einer Liste verkettet sind. Ein gelöschter Satz enthält dann neben der Löschmarke auch noch einen Verweis – in Form einer Dateiposition – auf den nächsten gelöschten: sie bilden eine Liste freier Plätze. Löschen bedeutet dann markieren und in eine Verfügbarkeitsliste einfügen. Hinzufügen bedeutet einen geeigneten Satz aus der Verfügbarkeitsliste entnehmen – falls vorhanden, ansonsten wird am Dateiende geschrieben. Bei Sätzen fester Länge kann man stets am Anfang der Verfügbarkeitsliste arbeiten, diese also wie einen Stapel behandeln. Bei variaber Satzgröße muss die Liste natürlich nach einem passenden freien Platz durchsucht werden. 9.10.5 Beispiel Verfügbarkeitsliste bei fester Satzgröße Als Beispiel wollen wir eine Verfügbarkeitsliste für Dateien mit fester Satzgröße implementieren. Die Verwaltung einer Verfügbarkeitsliste in Stapelart haben wir bereits beim Pool kennengelernt. Die Dateivariante ist zwar etwas mühsamer, bringt aber keine wesentlichen neuen Problemstellungen. Freigeben ist vorn in die Liste einhängen. Die entsprechende (private) Methode ist allocate. Sie positioniert die Datei auf den Beginn eines freien Platzes. Die (private) Datenkomponente availAnc ist der Start der Verfügbarkeitsliste: template<class Record> class AVFile { public: ... bool erase (); ... private: ... streampos availAnc; ... bool allocate (); ... }; // Satz loeschen und freigeben // Start der Verfuegbarkeitsliste, Spiegel des // entsprechenden Wertes in der Datei // auf freien Satz positionieren Der Anker der Liste availAnc darf beim Schließen der Datei nicht verloren gehen. Sein Wert wird darum nach jeder Änderung auf der Datei gesichert. Wir erklären ihn einfach zum Bestandteil des Headers und speichen mit der (privaten) Methode writeHeader. template<class Record> //positioniert auf freien Record bool AVFile<Record>::allocate () { Programmierung II 302 streampos pos; if (availAnc == 0) { // kein freier Platz in der Liste: pos = getFileEnd (); // Auf das Dateiende positionieren char buf [Record::recSize+1] = ""; buf[0] = ’A’; file.write (buf, Record::recSize+1); // Aktiv-Marke, Dummy-Record if (!file) { ::perror ("Kann Record nicht anhaengen\n"); return false; } } else { //Element aus Vefuegbarkeitsliste nehmen pos = availAnc; char buf[Record::recSize+1] = ""; file.seekg (availAnc); file.read (buf, Record::recSize+1); if (buf[0] != ’D’) { ::perror ("Datei-Inkonsistenz: freier Record nicht geloescht!\n"); return false; } availAnc = streampos (atoi (&buf[1])); //availAnc ist Headerbestandteil writeHeader (); //Header veraendert -> sichern file.seekp (pos); file.write ("A", 1); } file.flush(); file.seekg (pos); //Auf Record-Anfang positionieren file.seekp (pos); if (!file) { ::perror ("Kann Record nicht einfuegen\n"); return false; } return true; } Die komplementäre Operation des Freigebens wird beim Löschen eines Satzes benötigt: template<class Record> //aktuellen Record loeschen und Platz freigeben bool AVFile<Record>::erase () { if (!isOpen || offLeft || offRight) return false; char buf [Record::recSize+1] = ""; buf[0] = ’D’; // Loeschmarke sprintf (&buf[1], "%d", int(availAnc)); streampos pos = file.tellg (); // aktuelle Position availAnc = pos; // in Vefuegbarkeitsliste haengen writeHeader (); // Verfuegbarkeitsliste sichern file.write (buf, recSize+1); // in die Liste einhaengen file.flush(); if (!file) { ::perror ("Kann Record nicht loeschen\n"); return false; } file.seekg (pos); file.seekp (pos); return true; } Die Routine löscht den Record an der aktuellen Dateiposition durch Markieren mit einem D und Einhängen in die Verfügbarkeitsliste. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 9.11 303 Übungen Aufgabe 1 1. Schreiben Sie ein Programm das eine Textdatei in eine andere kopiert. Die Namen der beiden Dateien sollen per Programmzeile übergeben werden. Am Ende soll Ihr Programm ausgeben wieviele Zeilen und wieviele Zeichen es kopiert hat. 2. Schreiben Sie ein Programm das eine Textdatei liest und die Zahl der Zeichen, Worte und Zeilen dieser Datei ausgibt. Ein Wort ist dabei eine zusammenhängende Folge von Zeichen die kein “weisses Zeichen” enthält. Weisse Zeichen sind Leerzeichen, Tabulator und Zeilenvorschub. 3. Schreiben Sie ein Programm, das die Größe einer Datei bestimmt, ohne sie einzulesen. 4. Schreiben Sie ein Programm, das eine Datei ab einer bestimmten Byteposition ausgibt. Datei und Byteposition sind per Programmargument zu übergeben. Ihr Programm soll eine Fehlermeldung ausgeben, wenn die Datei kürzer ist, als die übergebene Byteposition. 5. Schreiben Sie ein Programm prog, das seine Eingaben von der Standardeingabe liest, wenn es ohne Parameter (Kommandozeilenargument) aufgerufen wird. Mit Parameter soll es seine Eingabe von der Datei lesen, deren Name als Argument übergeben wurde. Beispiel, Eingabe von der Standardeingabe: prog Eingabe von der Datei hugo.txt: prog hugo.txt Aufgabe 2 1. Warum ist es vorteilhafter Ein-/Augabeoperatoren zu definieren, als spezeielle Lese– und Schreibmethoden oder Funktionen? class C { ... void print (); ... }; // SCHLECHT ostream & operator<< (ostream & os, const C & c); // GUT 2. Wodurch unterscheiden sich Binärdateien von Textdateien? 3. Wodurch unterscheidet sich die Ausgabe von endl von der Ausgabe eines Zeilenvorschub–Zeichens? 4. Wie gibt man eine Matrix von Float–Zahlen so aus, dass alle Werte mit zwei Stellen hinter dem Komma und exakt ausgerichtet gedruckt werden. Aufgabe 3 Schreiben Sie ein Programm das eine Textdatei einliest und die in ihr enthaltenen Worte in einer Warteschlange speichert. Anschließend werden alle Worte entsprechend ihrer Priorität (= lexikographische Ordnung) wieder der Warteschlange entnommen. Benutzen Sie soweit wie möglich die Standardbibliothek. 304 Programmierung II Aufgabe 4 Implementieren Sie ein Template als Abstraktion “Dateien mit Sätzen fester Länge”. (Templateargument ist der Satztyp.) Ihre Abstraktion soll eine Cursor–Semantik realisieren. D.h. die Datei kann von Satz zu Satz vorwärts und rückwärts durchwandert werden. Der Satz an der aktuellen Position kann gelesen, modifiziert und gelöscht werden. Ausserdem soll es möglich sein einen neuen Record hinzuzufügen. Aufgabe 5 Implementieren Sie ein Telefonverzeichnis, das seine Daten mit Hilfe der Dateiabstraktion aus Aufgabe 2 verwaltet. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg A 305 STL: wichtige Behälterklassen Wir fassen hier in einer knappen Übersicht die wichtigsten Operationen einiger zentraler Behälterklassen der STL zusammen. A.1 Vektoren Konstruktoren vector<T> vector<T> vector<T> vector<T> v v(int) v(int,T) v(vector<T>) Defaultkonstruktor Vektor mit Größe Vektor mit Größe und Initalwert Kopierkonstruktor Elementzugriff v[i] v.front() v.back() Index Erster Letzter Einfügen v.push back(T) v.insert(iter,T) v.swap(vector<T>) hinten anhängen an iter–Position einfügen Wert mit anderem Vektor tauschen Entfernen v.pop back() v.erase(iter) v.erase(iter-1,iter-2) letzten entfernen Element an iter–Position entfernen alle im Bereich entfernen Größe v.capacity() v.size() v.resize(unsigned,T) v.empty() maximale Größe aktuelle Größe Größe verändern mit Füllwert ist der Vektor leer? Iteratoren v.begin() v.end() A.2 Anfang Ende (hinter dem letzten Element) Listen Konstruktoren list<T> l list<T> l(list<T>) Defaultkonstruktor Kopierkonstruktor Elementzugriff l.front() l.back() Erster Letzter Programmierung II 306 Einfügen und Entfernen l.push front(T) l.push back(T) l.insert(iter,T) l.swap(list<T>) l.pop front() l.pop back() l.erase(iter) l.erase(iter-1,iter-2) l.remove(T) l.remove if(f) vorn anhängen hinten anhängen an iter–Position einfügen Wert mit anderer Liste tauschen ersten entfernen letzten entfernen Element an iter–Position entfernen alle im Bereich entfernen alle Elemente mit Wert entfernen alle Werte entfernen auf die Bedingung f zutrifft Größe l.size() l.empty() aktuelle Größe ist der Liste leer? Iteratoren l.begin() l.end() Anfang Ende (hinter dem letzten Element) Listenbearbeitung l.sort() l.reverse() l.merge() A.3 Sortieren Reihenfolge umkehren Zwei Listen mischen Mengen und Multimengen Konstruktoren set<T> s set<T> s(set<T>) multiset<T> s multiset<T> s(set<T>) Defaultkonstruktor Kopierkonstruktor Defaultkonstruktor Kopierkonstruktor Einfügen und Entfernen s.insert(T) s.remove(T) s.insert(iter,T) s.swap(set<T>) s.erase(iter) s.erase(iter-1,iter-2) einfügen entfernen an iter–Position einfügen Wert mit anderer Menge tauschen Element an iter–Position entfernen alle im Bereich entfernen Test s.count(T) s.find(T) wie oft enthalten Position in der Menge (Iterator) Th Letschert, Fachbereich MNI, FH Giessen–Friedberg Größe s.size() s.empty() aktuelle Größe leer? Iteratoren s.begin() s.end() Anfang Ende (hinter dem letzten Element) Mengenverarbeitung s1 s1 s1 s1 s1 s1 == s2 != s2 <= s2 < s2 >= s2 > s2 A.4 gleich ungleich Teilmenge echte Teilmenge Obermenge echte Obermenge Abbildungen (Sortierte Schlüssel–Wert–Paare) Konstruktoren map<S, W> m map<S, W, f> m Defaultkonstruktor, S: Schlüsseltyp, W: Werttyp Defaultkonstruktor, f: Vergleichsfunktion für Schlüssel Einfügen und Entfernen m.insert(pair(S,W)) m.erase(S) m.erase(iter) Schlüssel–Wert–Paar einfügen Paar mit Schlüssel S entfernen Paar an iter–Position entfernen Test m.find(S) m[S] Position des Paars mit Schlüssel S (Iterator) Wert zu Schlüssel S Größe m.size() m.max size() m.empty() aktuelle Größe maximale Größe leer? Iteratoren m.begin() m.end() Anfang Ende (hinter dem letzten Element) Mengenverarbeitung m1 == m2 m1 != m2 gleich ungleich 307 Programmierung II 308 Literatur [1] Bjarne Stoustrup: The C++ Programming Language Addison-Wesley 1997 Das Werk des Meisters, muss man kennen, wenn auch nicht unbedingt als erstes C++–Lehrbuch lesen [2] Ulrich Breymann: C++ Einführung und professionelle Programmierung 6. Auflage Hanser 2001 Deutschsprachiges Standardlehrbuch für C++. Umfangreiche Einführung auf Hochschulniveau, enthält eine ausführliche Beschreibung der C++–Bibliothek. [3] Andrew Koenig, Barabara Moo: Accelerated C++ Addison–Wesley 2000 Kompakte Einführung mit Betonung der STL. Sehr geeignet für einen schnellen Einstieg von C++–Anfängern mit Programmiererfahrung, als Referenz zu knapp. [4] Stanley B. Lippman, Josee Lajoie: C++ Primer Addison–Wesley 1998 Sehr umfangreich, diskutiert alle Feinheiten der Sprache mit vielen Beispielen. [5] Nocolai Josuttis: The C++ Standard Library Addison–Wesley 1999 Sehr gute und umfangreiche Beschreibung der Standardbibliothek (inklusive STL). [6] Coplien, J.O.: Advanced C++ Programming Addison-Wesley 1992 Der Klassiker zum Entwurf von C++ Programmen. Noch immer lesenswert. [7] Timothy Budd: Data Structures in C++ Addison-Wesley 1998 Datenstrukturen und Algorithmen mit Hilfe der STL. [8] Bernd Oestereich: Objektorientierte Softwareentwicklung Oldenburg, 5–te Auflage 2001 Deutschsprachiges Standardwerk zum Thema seines Titels. Enthält gute Beispiele zu UML. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B 309 Lösungshinweise B.1 Lösungshinweise zu Kapitel 1 Aufgabe 1 1. Ein Struct ist einfach eine Klasse, in der alles öffentlich ist. Die Definitionen class Vektor { public: float x, y }; struct Vektor { float x, y; }; sind völlig äquivalent. 2. struct S ... private: ... ; ist das Gleiche wie class S public: ... private: ... ; 3. h ist eine freie Funktion, g und h sind Methoden von S. Nur in h gelten darum die mit public und private definierten Zugriffsbeschränkungen. Es ist unerheblich, zu welchem Objekt die Komponenten x und y jeweils gehören. Entscheidend ist, von wo aus der Zugriff erfolgt: aus einer Methode von S oder aus irgend einer anderen Methode oder Funktion. class S { int y; public: int x; int f (S); private: int g (S); }; int S::f (S ps) { x = 1; y = 2; ps.x = 1; ps.y = 2; return ps.g(ps) + ps.y; } int S::g (S ps) { x = 1; y = 2; ps.x = 1; ps.y = 2; return ps.f(ps) + ps.y; } int h (S ps) { ps.x = 1; ps.y = 2; // FEHLER return ps.f(ps) + ps.g(ps); // FEHLER } 4. Jedes Programm, in dem Klassen vorkommen, kann in ein äquivalentes Programm ohne Klassen umgesetzt werden. Klassen dienen nicht dazu Programme zu formulieren, die anders nicht möglich wären. Sie dienen dazu Programme mit besserer Struktur zu definieren. 5. (a) Kann eine Funktion ein Freund einer Klasse sein: Ja! Programmierung II 310 (b) Kann eine Klasse ein Freund einer Funktion sein: Nein! Funktionen haben nichts Öffentliches oder Privates, das Konzept der Freundschaft ist für sie sinnlos. (c) Kann eine Klasse ein Freund einer Klasse sein: Ja! 6. Welche Fehler enthält folgende Klassendefinition. Wie wird das Gewollte korrekt formuliert? class C { public: C (int x) { void incA () { static void incB () { private: int a = 0; // static int b = 1; // const int c; }; int main () { C c; // <- C c.incA(); c.incB(); // incB } c=x; } // <- c ist konstant ++a; } ++b; } <- So werden Komponenten nicht initialisiert <- So werden Komponenten nicht initialisiert hat keinen Defaultkonstruktor ist static Korrektur: class C { public: C (int x) : a(0), c(x) {} // Konstante mit Initialisierer // initialisieren, void incA () { ++a; } static void incB () { ++b; } private: int a; // Konstruktoren initialisieren static int b; const int c; }; int C::b = 1; int main () { C c(0); c.incA(); C::incB(); } // statische Komponenten definieren und initialisieren // definierten Konstruktor verwenden // Aufruf statischer Methode 7. In der Methode float Punkt::entfernt_von (Punkt p) { return sqrt ( (pos.x - p.pos.x)*(pos.x - p.pos.x) + (pos.y - p.pos.y)*(pos.y - p.pos.y)); } ist der Zugriff auf x und y nicht erlaubt: es sind private Komponenten von Vektor. Es ist dabei unerheblich, dass der Vektor Bestandteil des Punktes ist. Zur Korrektur könnte Punkt ein Freund von Vektor sein: class Vektor { friend class Punkt; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 311 public: Vektor (); ... private: float x, y; }; .... oder alternativ öffentliche Methoden abieten, um auf seine Komponenten zuzugreifen. class Vektor { public: Vektor (); ... float x_koord () const { return x; } float y_koord () const { return y; } ... private: float x, y; }; ... 8. In class S { public: S() : y(y+1) {} // y um 1 ˜S() : y(y-1) {} // y um 1 static void f () { cout << x << ", " << y<< } private: int x = 1; // x mit static int y = 0; // y mit }; erhoehen reduzieren endl; // x und y ausgeben 1 initialisieren 0 initialisieren sollten x und y korrekt initialisiert werden. x muss im Konstruktor initialisiert werden und y muss als Klassenvariable definiert und initialisiert werden. Die Belegung und gleichzeitige Modifikation der statischen Komponente y im Initialisierer ist Blödsinn. Ein Initialisierer in im Destruktor ist ebenfalls Unsinn. In einer statischen Methoden kann nicht auf nicht–statische Komponenten zugegriffen werden. Korrektur: class S { public: S() : x(1) { ++y; } // x initialisieren, y um 1 erhoehen ˜S() {--y;} // y um 1 reduzieren static void f () { cout << y << endl; // y ausgeben (kein Zugriff auf x) } private: int x; static int y; }; int S::y = 0; // y definieren und mit 0 initialisieren 9. Klassenvariablen müssen ausserhalb der Klasse definiert werden: Programmierung II 312 class C { public: static int i; }; int C::i = 1; Sie müssen darum nicht public sein. Es ist also erlaubt zu definieren: class C { private: static int i; }; int C::i = 1; das ist kein Widerspruch zur Philosophie von private, denn int C::i = 1; gehört zu C. Folgendes ist trotzdem nicht erlaubt: class C { private: static int i; }; int main () { int C::i = 1; ... } Eine Klassenvariable kann nicht in einer Funktion definiert werden, auch dann nicht, wenn diese main heißt. 10. Keine Lösung. 11. Keine Lösung. Aufgabe 2 1. Erläutern und begründen Sie jeden Einsatz von const in folgender Definition: class Menge { public: ... Menge operator+ (const Menge &) const; ˆ ˆ | +- operator+ veraendert 1tes Argument nicht +-- operator+ veraendert 2tes Argument nicht ... } 2. Die Klassifikation der Methoden und Datenkomponenten in öffentlich und privat ergibt sich aus der Trennung in Schnittstelle und Implementierung. Die Schnittstelle bietet eine an dem mathematischen Verständnis von Mengen orientierte rein wertorientierte Sicht. Zur Implementierung gehören die Datenkomponeten und Hilfsfunktionen. 3. Das erste const in folgender Deklaration Menge operator+ (const Menge) const; erklärt einen Wertparameter als const. Das ist Unsinn. Wertparameter werden prinzipiell nicht verändert. Das zweite const bezieht sich auf das Objekt selbst. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 313 4. Das erste const in Menge operator+ (const Menge &) const; ist nicht unsinnig, da es sich um einen Referenzparameter handelt. 5. Die beiden Methoden: Menge operator+ (Menge) const; Menge operator+ (const Menge &) const; unterscheiden sich in der Art der Parameterübergabe. Die zweite Variante ist vorzuziehen, da die Refernzübergabe von komplexen Argumenten wesentlich effizienter ist, als die Wertübergabe. Bei einer Referenzübergabe kann der formale Parameter aber nicht als lokale Variable der Funktion/Methode verwendet werden. 6. Vollständige Definition der Klasse Menge: class Menge { public: Menge (); // leere Menge Menge (int); // ein-elementige Menge Menge operator+ (const Menge &) const; // Vereinigung Menge operator* (const Menge &) const; // Schnitt bool istEnthalten (int i) const; // ist Element private: void fuegeEin (int i); void entferne (int i); int m[10]; int a; }; Menge::Menge () { a = -1; } // Konstr. leere Menge Menge::Menge (int i) { //Konstr. ein-elementige Menge a = 0; m[0] = i; } void Menge::fuegeEin (int i) { if (!istEnthalten (i)) if (a < 9) { a++; m[a] = i; } } void Menge::entferne (int i) { for (int j = 0; j <= a; j++) if (m[j] == i) { for (int k=j; k<a; k++) m[k] = m[k+1]; a--; return; } } bool Menge::istEnthalten (int i) const { for (int j = 0; j <=a; j++) if (m[j] == i) return true; Programmierung II 314 return false; } Menge Menge::operator+ (const Menge &s) const { Menge res; for (int j = 0; j <= a; j++) res.fuegeEin (m[j]); for (int j = 0; j <= s.a; j++) res.fuegeEin (s.m[j]); return res; } Menge Menge::operator* (const Menge &s) const { Menge res; for (int j = 0; j <= a; j++) if (s.istEnthalten (m[j])) res.fuegeEin (m[j]); return res; } 7. Vereinigung und Schnitt als statische Methoden: class Menge { public: public: Menge (); // leere Menge Menge (int); // ein-elementige Menge Menge vereinigung (const Menge &, const Menge &); Menge schnitt (const Menge &, const Menge &); bool istEnthalten (int i) const; // ist Element private: void fuegeEin (int i); void entferne (int i); int m[10]; int a; }; ... Menge Menge::vereinigung (const Menge & t, const Menge & s) { Menge res; for (int j = 0; j <= a; j++) res.fuegeEin (t.m[j]); for (int j = 0; j <= s.a; j++) res.fuegeEin (s.m[j]); return res; } Menge Menge::schnitt (const Menge &t, const Menge & s) { Menge res; for (int j = 0; j <= a; j++) if (s.istEnthalten (t.m[j])) res.fuegeEin (t.m[j]); return res; } 8. Die Ausgabe von Mengen in drei Varianten: (a) Eine Ausgabe–Methode: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 315 class Menge { public .... void print () { ... } }; (b) Eine freie Ausgabe–Funktion: class Menge { friend void print (Menge &); .... }; void print (Menge &m) { ... } (c) Eine statische Ausgabe–Methode: class Menge { public .... static void print (const Menge & m) { ... } }; 9. class Menge { friend Menge operator+ (const Menge &, const Menge &); // Vereinigung friend Menge operator* (const Menge &, const Menge &); // Schnitt public: ... private: ... }; Menge operator+ (const Menge & m1, const Menge & m2) { Menge res; for (int j = 0; j <= m1.a; j++) res.fuegeEin (m1.m[j]); for (int j = 0; j <= m2.a; j++) res.fuegeEin (m2.m[j]); return res; } .. operator* entsprechend .. Aufgabe 3 Konten haben einen Besitzer, einen Wert und einen Zinssatz. Der Zinssatz ist für alle Konten gleich: 3 Prozent für Guthaben, 5 Prozent für Kredite (= negative Guthaben). Von einem Konto zum anderen können beliebige Beträge überwiesen werden. Das Guthaben bzw. der Kredit eines Kontos kann für einen bestimmten Zeitraum verzinst werden; das Guthaben wird dabei entsprechend verändert. Diese Beschreibung eines Kontos hat zwei Aspekte: Welche Attribute (Eigenschaften) hat ein Konto? Was kann man mit einem Konto machen? Die Attribute sind: Besitzer, Wert und Zinssatz. Der Zinssatz richtet sich danach, ob es sich um ein Kredit– oder Guthabenkonto handlet. Der Zinsatz muss sich jeweils an den Kontostand anpassen. Die Attribute des Kontos werden einfach zu Datenkomponenten: class Konto { public: Konto (Besitzer b) : wert(0), zinssatz(3) {} Programmierung II 316 ... private: Besitzer besitzer; int wert; int zinssatz; }; Was man mit einem Konto machen kann bestimmt die öffentlichen Methoden und freien Funktionen. Der Defaultkonstruktor repräsentiert das Anlegen eines Kontos. Dabei muss der Besitzer angegeben werden. Es muss möglich sein Beträge einzuzahlen und abzuheben. Dabei muss der Zinssatz aktualisiert werden. Der Zins wird nach dem aktuellen Zinssatz berechnet: class Konto { public: Konto (Besitzer b) : wert(0), zinssatz(3) {} void ein (unsigned int betrag) { wert = wert + betrag; if (betrag >= 0) zinssatz = 3; } void aus (unsigned int betrag) { wert = wert - betrag; if (betrag < 0) zinssatz = -5; } void verzinse (unsigned int tage) { wert = int((float(wert) * zinssatz / 100.0) * tage / 365.0); } private: Besitzer besitzer; int wert; int zinssatz; }; Überweisungen können als freie Funktion definiert werden: void ueberweise (Konto & von, Konto & nach, unsigned int betrag) { von.aus(betrag); nach.ein(betrag); } Aufgabe 4 1. Was versteht man unter einem Konstruktor und was ist der Default–Konstruktor? Ein Konstruktor ist eine Initialisierungsfunktion die automatisch aktiviert wird, wenn ein Objekt vom entsprechenden Typ angelegt wird. Der Default–Konstruktor wird aufgerufen, wenn es keine Hinweise des Programmieres gibt, die den aufruf eines anderen Konstruktor fordern. Z.B. keine Parameter bei einer Variablendefinition. 2. Stimmt es, dass ... (a) eine Klasse immer mindestens / genau / höchstens einen Konstruktor haben muss? Nein, stimmt nicht. (b) eine Klasse immer einen Default–Konstruktor haben muss? Nein, stimmt nicht. (c) der Compiler einen Default–Konstruktor erzeugt, wenn für eine Klasse keiner definiert wurde? Nein, stimmt nicht. (d) Objekte immer mit einem Konstruktor initialisiert werden? Nein, stimmt nicht. Wenn eine Klasse keinen Konstruktor hat, dann wird auch keiner aktiviert. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 317 (e) Objekte, deren Klasse mindestens einen Konstruktor hat, immer mit einem Konstruktor initialisiert werden? Ja, das stimmt. Wenn mindestens ein Konstruktor definiert wurde, dann wird jedes Objekt mit einem Konstruktor initialisiert – wenn es keinen passenden gibt dann ist das Programm fehlerhaft. (f) Klasssen mit Konstruktor auch einen Destruktor haben müssen? Nein, stimmt nicht. (g) Klassen mit Destruktor auch einen Konstruktor haben müssen? Nein, stimmt nicht. 3. Nur Konstruktoren dürfen Initialisierer enthalten? 4. Konstante datenkomponenten und Komponenten ohne Defaultkonstruktor müssen mit einem Initialisierer initialisiert werden. 5. In class A { public: A(int p) : x(p) {} private: int x; }; class B { public: B(A a) { y = a; } // <--FALSCH Korrekt: B(A a) :y(a) {} private: A y; }; hat A keinen Defaultkonstruktor. Eine A–Komponente muss mit einem Initialisier belegt werden. In class A { public: A(int p) : x(p) {} private: int x; }; class B { public: B(A a) : y(a) {} private: A y; }; hat A gar keinen Konstruktor, eine Inititalisierung wird vom System nicht versucht und der Defaultkonstruktor nicht vermisst. Eine globale Variablendefinition für eine Variable b vom Typ B belegt die A–Komponente mit 0, eine loakle Variablendefinition lässt es uninitialisiert. 6. Das Programmstück class X { public: X () : f(0.0) {} float f; }; class C { Programmierung II 318 public: X float }; a; b; int main () { C c; } ist korrekt. Der Konstruktor von X wird aktiviert, obwohl C keinen Konstruktor hat. 7. Nicht korrekt ist: class X { public: X () : f(0.0) {} float f; }; class C { public: C (X x) : a(x) {} X a; float b; }; int main () { C c; } C hat eiunen Konstruktor, also muss der in main implizit aufgerufe Defaultkonstruktor exisitieren! 8. In der vorletzten Aufgabe haben wir den seltenen Fall eines Compiler–erzeugten Default–Konstruktors. 9. Eine Klasse erklärt eine andere zum Freund. Dies hat keinerlei Auswirkung auf die Ausführung der Konstruktoren? Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.2 319 Lösungshinweise zu Kapitel 2 Aufgabe 1 1. Konversionen von einem l–Wert in einen r–Wert finden immer dann statt, wenn ein l-Wert an Stelle eines r-Werts benutzt wird. Konversionen von einem r–Wert in einen l–Wert sind im Allegemeinen nicht möglich. Sie treten aber im Beispiel beim Aufruf der Funktion f auf: in folgendem Programm statt: ... f (i, j) = f (i+1, // <<== r-Wert -> l-Wert j) + g (i+2, j); ... 2. Das folgende Programm ist korrekt. #include <iostream> int a = 10; int & b = a; int c = a; int & f (int &b) { b++; return b; } using namespace std; int main () { int &x = c; x = a; f(c) = f(c) + f(x); cout << f(b)++ << endl; // Ausgabe 11 (Postinkrement-Operator !) cout << f(b) + f(b) << endl; // Ausgabe 28 (= 14 + 14 !!) } Der Wert des 2-ten Ausdrucks hängt von der Auswertungsstrategie des Compilers ab und kann auch 27 (13+14) sein. 3. Was ist alles falsch an folgender Definition eines Zuweisungsoperators: class X { public: ... X & operator= (const X x) { X res; res.i = x.i; return res; <<== kein Referenzparameter <<== sollte Zuweisung an sich selbst pruefen <<== sollte *this zurueck geben <<== sollte *this zurueck geben } private: int i; }; 4. Der Zuweisungsoperator ist rechts–asoziativ, die am weitesten rechts stehende Zuweisung wird zuerst ausgeführt und a=b=c entspricht a=(b=c). Die beiden geklammerten Formen sind erlaubt. Bei (a=b)=c; wird zuerst b an und dann c a zugewiesen. Bei a=(b=c) wird zuerst c an b und dann b an a zugewiesen. 5. Der Zuweisungsoperator liefert eine Referenz als Ergebnis, damit die Zuweisung ein zuweisbares Ergebnis hat, also beispielsweise die Konstruktion (a=b)=c; möglich wird. Programmierung II 320 6. Wenn der Zuweisungsoperator als Ergebnis eine konstante Referenz liefert, dann sind Zuweisungen an das Ergebnis einer Zuweisung nicht möglich: class C { ... const C & operator= (const C &); ... }; int main () { C x, y, z; x = y; // OK x = y = z; // OK x = (y = z); // OK (x = y) = z; // FEHLER } Aufgabe 2 #include <iostream> using namespace std; class Bruch { public: Bruch (); Bruch (int); Bruch (int, int); Bruch (const Bruch &); // // // // Default-Konstruktor: 0 als Bruch ganze Zahl als Bruch Zaehler, Nenner Kopierkonstruktor char vorzeichen () const; int zaehler () const; int nenner () const; Bruch & operator= (const Bruch &); // Zuweisung Bruch operator+ (const Bruch &) const; // Addition Bruch operator- (const Bruch &) const; // Subtraktion Bruch operator- () const; // unaere Subtraktion private: enum Vorzeichen {plus, minus}; Vorzeichen vz; int z; int n; static int static int kgv ggt (int, int); (int, int); static void static void static void kuerze (Bruch &); erweitere (Bruch &, int); gleichnamig (Bruch &, Bruch &); }; // I/O--Operatoren als freie Funktionen: ostream & operator<< (ostream &, const Bruch &); // Ausgabe istream & operator>> (istream &, Bruch &); // Eingabe //Freie Operatoren Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 321 // Unaeres Minus fuer Brueche: Bruch operator- (const Bruch &); // Operationen mit gemischten Operanden: Bruch operator+ (int, const Bruch &); Bruch operator+ (const Bruch &, int); Bruch operator- (int, const Bruch &); Bruch operator- (const Bruch &, int); //------------------------------------------------------------------int main () { Bruch b0, b1(2), b2(2,3), b3 = -Bruch (4,6); // // // // b0 b1 b2 b3 = = = = 0 2 2/3 -(4/6) b0 = 4; // implizite Konversion int->Bruch // Gemischte Typen, int und Bruch: b3 = Bruch(5,7) + (2 + (b1 - b2) + 3) - b0; cout << b3 << endl; } /*------------------------------------------------------------------I/O : Freie Funktionen */ istream & operator>> (istream &is, Bruch &b) { char vz, bruchstrich; int z; int n; is >> vz; is >> z; is >> bruchstrich; is >> n; if ( vz == ’+’ ) b = Bruch (z, n); else b = - Bruch (z, n); return is; } ostream & operator<< (ostream &os, const Bruch &b) { os << b.vorzeichen() << b.zaehler() << "/" << b.nenner(); return os; } /* ------------------------------------------------------------------Oeffentliche Methoden von Bruch */ Bruch::Bruch() : z(0), n(1) {} Bruch::Bruch(int i) : z(i), n(1) {} // dient auch als Konversionsfunktion Bruch::Bruch(int i, int j) : z(i), n(j) { kuerze (*this); } // Kopierkonstruktor: 322 Programmierung II Bruch::Bruch (const Bruch &b) { vz = b.vz; n = b.n; z = b.z; } char Bruch::vorzeichen () const {if (vz == plus) return ’+’; else return ’-’; } int Bruch::zaehler () const { return z; } int Bruch::nenner () const { return n; } // Zuweisungsoperator Bruch & Bruch::operator= (const Bruch &b) { if (&b != this) { vz = b.vz; n = b.n; z = b.z; } return *this; } // binaerer minus-Operator fuer Brueche Bruch Bruch::operator- (const Bruch &y) const { Bruch t = y; if (t.vz == minus) t.vz = plus; else t.vz = plus; return *this + t; } // binaerer Additionsoperator Bruch Bruch::operator+ (const Bruch &y) const { Bruch res; Bruch t1 = y; // Hilfsvariablen um die Veranderung der Bruch t2 = *this; // Argumente von + zu verhinderen gleichnamig (t1, t2); res.n = t1.n; if (t1.vz == t2.vz) { // gleiche Vorzeichen res.vz = t1.vz; res.z = t1.z + t2.z; } else { // unterschiedliche Vorzeichen if (t1.z > t2.z) { res.vz = t1.vz; res.z = t1.z - t2.z; } else { res.vz = t2.vz; res.z = t2.z - t1.z; } } kuerze (res); return res; } /* ------------------------------------------------------------------Freie Bruch-Funktionen (das unaere Minus kann nicht als Methode definiert werden.) */ Bruch operator- (const Bruch &b) { return 0 - b; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 323 } Bruch Bruch Bruch Bruch operator+ operator+ operatoroperator- (int i, const Bruch &b) (const Bruch &b, int i) (int i, const Bruch &b) (const Bruch &b, int i) { { { { return return return return Bruch (i) b + Bruch Bruch (i) b - Bruch + b; (i); - b; (i); } } } } /* ------------------------------------------------------------------Private Methoden von Bruch */ int Bruch::ggt (int x, int y) { while (x != y) { if (x > y) x = x-y; if (y > x) y = y-x; } return x; } void Bruch::kuerze (Bruch &b) { if (b.z == 0) return; int f = ggt (b.z, b.n); b.z = b.z / f; b.n = b.n / f; } int Bruch::kgv (int x, int y) { return (x*y/ggt(x,y)); } void Bruch::erweitere (Bruch &b, int f) { b.z = b.z*f; b.n = b.n*f; } void Bruch::gleichnamig (Bruch &b1, Bruch &b2) { int n = kgv (b1.n, b2.n); erweitere (b1, n/b1.n); erweitere (b2, n/b2.n); b1.n = n; b2.n = n; } Aufgabe 3 1. Ein Stapel als zustandsorientierten Datentyp ist eine einfache Stapelversion, die keinen Kopierkonstruktor und keinen Zuweisungsoperator benötigt mit Methoden die den Zustand verändern: class Stapel { public: Stapel(); void push (int x); void pop (); int top (); private: ... }; 2. Objekte wertorientierte Klassen werden wie die Werte vordefinierter Datentypen im Programm manipuliert. Programmierung II 324 Die Klassen müssen alle dazu notwendigen Mechanismen definieren. Bei zustandsorientierten Klassen ist dies meist nicht notwendig, da sie nur in eingeschränkter Form verwendet werden: Variablen anlegen und mit den Methoden der Klasse auf sie zugreifen. Keine Zuweisung, keine Wertübergabe. 3. Ein Stapel als zustandsorientierter konkreter Datentyp benötigt einen funktionierenden Koierkonstruktor und Zuweisungsoperator. Dies können jeweils die vordefinierten sein, deren korrektes Funktionieren ist aber zu überprüfen. Aufgabe 4 1. Kein Hinweis. 2. Es handelt sich um eine wertorientierte Klasse. 3. class Menge { public: Menge () { ... ++z; ... } Menge (int) { ... ++z; ... } ˜Menge () { --z; } Menge ( const Menge &m) { ... ++z; ... } Menge operator+ (Menge) const; Menge operator* (Menge) const; bool istEnthalten (int i) const; static int anzahl() { return z; } private: void fuegeEin (int i); void entferne (int i); int m[10]; int a; static int z; }; int Menge::z = 0; Der Kopierkonstruktor muss jetzt definiert werden. Erzeugen durch Kopieren ist ein Erzeugen, mit dem die Zahl der Objekt sich erhöht. 4. class Menge { friend ostream & operator<< friend istream & operator>> ... }; ostream & operator<< (ostream ... } istream & operator>> (istream ... } (ostream &, const Menge &); (istream &, Menge &); &os, const Menge &m) { &is, Menge &m) { Aufgabe 5 Bei der Ausführung des Zuweisungsoperator (im Gegensatz zu der des Kopierkonstruktors) ändert sich die Zahl der Objekte nicht. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.3 325 Lösungshinweise zu Kapitel 3 Aufgabe 1 Geben Sie zu den folgenden graphisch dargestellten Situationen geeignete Definitionen und Anweisungen an, die zu dieser Situation führen. 1. int x y int * p * q * t 2. int 3. int x y int * p ** q * t = = = = = 0, 1; &x, &y, q; = = = = = 0, 1; &x, &p, &y; x = 1, y = 2, z = 3, t = 4; int * p = &y, * q = &z, * r = &t, * o = &x; 1. t = x; ist in keiner der drei Situationen erlaubt da t und x jeweils unterschiedliche Typen haben. 2. *t = x; (a) +---------------+ | +---------|---------+ | | +-------|---------|--------+ v V V | | | +---+ +---+ +-|-+ +-|-+ +-|-+ | 0 | | 1 | | + | | + | | + | +---+ +---+ +---+ +---+ +---+ x y p q t *t = x; führt zu: +---------------+ | +---------|---------+ | | +-------|---------|--------+ v V V | | | +---+ +---+ +-|-+ +-|-+ +-|-+ | 0 | | 0 | | + | | + | | + | +---+ +---+ +---+ +---+ +---+ x y p q t (b) +---------------+ | +--------|------------------+ v V | q | +---+ +---+ +-|-+ +---+ +-|-+ | 0 | | 1 | | + |<-+ | + | | + | +---+ +---+ +---+ | +-|-+ +---+ x y p +----+ t *t = x; belegt y mit 0. Programmierung II 326 (c) +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ x p y q z r t o *t = x; nicht erlaubt. 3. t = *x; ist in keiner der drei Situationen erlaubt: x ist kein Zeiger. 4. *t = *x; ist in keiner der drei Situationen erlaubt: x ist kein Zeiger. 5. *p = *q; (a) +---------------+ | +---------|---------+ | | +-------|---------|--------+ v V V | | | +---+ +---+ +-|-+ +-|-+ +-|-+ | 0 | | 1 | | + | | + | | + | +---+ +---+ +---+ +---+ +---+ x y p q t *p = *q; führt zu: +---------------+ | +---------|---------+ | | +-------|---------|--------+ v V V | | | +---+ +---+ +-|-+ +-|-+ +-|-+ | 1 | | 1 | | + | | + | | + | +---+ +---+ +---+ +---+ +---+ x y p q t (b) +---------------+ | +--------|------------------+ v V | q | +---+ +---+ +-|-+ +---+ +-|-+ | 0 | | 1 | | + |<-+ | + | | + | +---+ +---+ +---+ | +-|-+ +---+ x y p +----+ t *p = *q; ist nicht erlaubt: p und q haben nicht den gleichen Typ. (c) +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ x p y q z r t o *p = *q; führt zu: +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 3 | | +----->| 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ x p y q z r t o 6. p = &x; (a) +---------------+ | +---------|---------+ | | +-------|---------|--------+ Th Letschert, Fachbereich MNI, FH Giessen–Friedberg v +---+ | 0 | +---+ x V V +---+ | 1 | +---+ y | +-|-+ | + | +---+ p | +-|-+ | + | +---+ q 327 | +-|-+ | + | +---+ t p = &x; hat keine Wirkung, p zeigt bereits auf x. (b) +---------------+ | +--------|------------------+ v V | q | +---+ +---+ +-|-+ +---+ +-|-+ | 0 | | 1 | | + |<-+ | + | | + | +---+ +---+ +---+ | +-|-+ +---+ x y p +----+ t p = &x; hat keine Wirkung, p zeigt bereits auf x. (c) +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ x p y q z r t o p = &x; führt zu: +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ +->| 1 | | +---+ | 2 | | +----->| 3 | | +------>| 4 | | + | | +---+ +---+ | +---+ +---+ +---+ +---+ +---+ +---+ | x p | y q z r t o +--------------+ 7. q = &t; (a) +---------------+ | +---------|---------+ | | +-------|---------|--------+ v V V | | | +---+ +---+ +-|-+ +-|-+ +-|-+ | 0 | | 1 | | + | | + | | + | +---+ +---+ +---+ +---+ +---+ x y p q t q = &t; ist nicht erlaubt. (b) +---------------+ | +--------|------------------+ v V | q | +---+ +---+ +-|-+ +---+ +-|-+ | 0 | | 1 | | + |<-+ | + | | + | +---+ +---+ +---+ | +-|-+ +---+ x y p +----+ t q = &t; führt zu: +---------------+ | +--------|------------------+ v V | q | +---+ +---+ +-|-+ +---+ +-|-+ | 0 | | 1 | | + | | + | +->| + | +---+ +---+ +---+ +-|-+ | +---+ x y p +---+ t Programmierung II 328 (c) +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 2 | | +----->| 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +---+ x p y q z r t o q = &t; führt zu: +---------------------------------------------------+ V | +---+ +---+ +---+ +---+ +---+ +---+ +---+ +-|-+ | 1 | | +----->| 2 | | +---+ | 3 | | +------>| 4 | | + | +---+ +---+ +---+ +---+ | +---+ +---+ +->+---+ +---+ x p y q | z r | t o +---------------+ Aufgabe 2 1. Überprüfen Sie Ihr Diagramm mit Hilfe des Debuggers! 2. Die Anweisung (*((*(b.n)).n)).v = 125; im angegebenen Programm entspricht: b.n->n->v = 125; Aufgabe 3 1. i habe den Typ int, und p den Typ int *. Welcher der folgenden Ausdrücke ist korrekt bzw. nicht korrekt. Geben Sie zu den korrekten Ausdrücken den Typ an. (a) i + 1 OK, int (b) *P Falsch (P nicht definiert) (c) &p OK, int ** (d) **(&p) OK, int (e) &i == p OK, bool (2 Werte vom Typ int * werden verglichen) (f) i == *p OK, bool (2 Werte vom Typ int werden verglichen) (g) *p + i > i OK, bool (2 Werte vom Typ int werden verglichen) 2. Definieren Sie folgende Variablen und Typen: (a) Ein Feld von Zeigern auf float–Variablen: float *a[10]; (b) Eine Funktion die einen Zeiger auf eine float–Variable als Argument hat und einen Zeiger auf eine int–Variable liefert.: int * f(float *); (c) Ein Feld von Zeigern auf Funktionen die einen Zeiger auf float–Variablen als Argument und Ergebnis haben: float * (*a[10])(float *); etwas übersichtlicher: typedef float * PF(float *); PF * a[10]; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 329 (d) Ein Feld von 10 Verbunden (structs) die einen Zeiger auf int und eine Methode als Komponenten haben. Die Methode soll Zeiger auf float in Zeiger auf int abbilden: struct S int *p; int * m(float *); ; S a[10]; (e) Eine Variable, die einen Zeiger auf ein float–Objekt enthält: float * p; (f) Eine Variable, die einen Zeiger auf eine Variable enthält, deren Inhalt auf ein float–Objekt zeigt: float ** p; (g) Den Typ der Zeiger auf float–Objekte: typedef float * P; (h) Den Typ S wobei jedes Objekt vom Typ S aus einem int und einem Zeiger auf ein bool, sowie einem Zeiger auf ein S Objekt besteht: struct S int x; bool * y; S * z; ; (i) Eine Variable die aus 10 S Objekten besteht: S a[10]; (j) Eine Variable die aus 10 Zeigern auf S Objekte besteht: S * a[10]; (k) Den Typ den ein Feld von Zeigern auf float–Variablen hat: typedef float * A[10]; (l) Den Typ den eine Funktion hat, die einen Zeiger auf eine float–Variable als Argument hat und einen Zeiger auf eine int–Variable liefert: typedef int * F(float *); 3. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die p[i] mit der Adresse von a[i] belegt (i = 0..9): for ( int i=0; i<10; ++i) p[i] = &a[i]; 4. Es sei definiert: int a[10] und int *p[10]. Schreiben Sie eine Schleife die a[i] mit dem Wert dessen belegt, auf das p[i] zeigt (i = 0..9): for ( int i=0; i<10; ++i) a[i] = *p[i]; 5. Der Adressoperator & wird auf l–Werte angewendet und hat einen r–Wert als Ergebnis. 6. Der Dereferenzierungsoperator * wird auf r–Werte angewendet und hat einen l–Wert als Ergebnis. 7. Experimentieren Sie, benutzen Sie Compiler und eventuell Debugger! Aufgabe 4 Kein Lösungshinweis. Aufgabe 5 1. Mit new erzeugte Variablen überleben das Ende der Funktion, in der sie erzeugt wurden. Lokale Variablen leben dagegen nur solange wie die Funktion zu der sie gehören. Desshalb ist int * get_int1 () { int * p; p = new int; return p; } Programmierung II 330 OK. Die Funktion int * get_int2 () { int i; int p = &i; return p; } liefert dagegen einen Verweis auf eine “tote” Variable. 2. Nach int * p; p = new int; *p = 17; ... delete p; zeigt p auf eine “zerstörte” Variable. Achtung delete p belegt p nicht mit = sondern führt dazu, dass es einen ungültigen Zeiger als Wert hat. 3. p = 0; delete p; ist unsinnig, da p nach der Zuweisung den Nullzeiger enthält und delete 0 ist ohne Wirkung. (Das, auf das p vor der Zuweisung zeigte, wird hier nicht freigegeben.) Die umgekehrte Reihenfolge ist sinnvoll: erst das vernichten, auf das der Zeiger zeigt, dann den Zeiger auf 0 setzten. 4. Eine Variable im Heap ist immer anonym (namenlos). 5. In struct S { int a; S * l; }; int main () { S *x, *y; x = new S; y = new S; x->a = 0; y->a = 1; x->l = y; y->l = 0; ... } sind x und y im Stack. Die Objekte, auf die sie zeigen, sind im Heap. Die erzeugte Struktur ist: (0,-)--->(1,0) ˆ ˆ | | x y 6. Nach struct S { int a; S * l; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 331 }; int main () { S * r = 0; S * l = 0; for (int i = 0; i< 5; i++) { S * n = new S; if (i == 0) { n->a = 1; r = n; } else { n->a = l->a * i; l->l = n; } l = n; n->l = 0; } ... zeigt r auf eine Kette von 5 Structs vom Typ S mit den a–Komponenten 1, 1, 2, 6, 24. l zeigt auf das letzte Element dieser Kette. 7. Eine Ausgabeschleife, welche die Datenfelder der verketteten Verbunde in der Reihenfolge ihrer Verkettung ausgibt: for ( S* p=r; p!=0; p=p->l) cout << p->a << endl; 8. Ein Programm in dem eine Zahlenfolge bis zur Eingabe von 0 eingelesen und in umgekehrter Reihenfolge in verketteten Verbunden im Heap abgelegt wird: struct S { int a; S * l; }; int main () { S * p = 0; do { int x; cin >> x; if ( x == 0 ) break; S * n = new S; n->a = x; n->l = p; p = n; } while (true); } Aufgabe 6 1. Die Struktur A-->B-->C-->D-->E-->F-->G wird beispielsweise erzeugt durch: Programmierung II 332 struct S { char a; S * l; }; int main () { S * p = 0; char c = ’H’; do { --c; S * n = new S; n->a = c; n->l = p; p = n; } while (c != ’A’); for ( S* r=p; r!=0; r=r->l) cout << r->a << endl; } 2. Die Struktur 1-->2-->3-->4-->5-->6-->7 | | | | | | | V V V V V V V A B C D E F G kann ähnlich wie die letzte erezugt werden: struct S { int a; char *b; S * l; }; int main () { S * p = 0; char c = ’H’; int i = 8; do { --i; S * n = new S; n->a = i; --c; n->b = new char; *(n->b) = c; n->l = p; p = n; } while (i != 1); } 3. Die Erzeugung der Struktur 1-->2-->3-->4-->5-->6-->7--8 ˆ | | | +--------------------------+ sollte kein Problem bereiten. Folgende Lösung ist möglich: Th Letschert, Fachbereich MNI, FH Giessen–Friedberg struct S { int a; S * l; }; int main () { S * p = 0; int i = 8; do { --i; S * n = new S; n->a = i; n->l = p; p = n; } while (i != 1); // Das letzte Element mit dem ersten verketten S * last = p; while (last->l != 0) last = last->l; last->l = p; } 4. Die Struktur 1-->2-->3-->4-->5-->6-->7 | | | | | | | V V V V V V V A-->B-->C-->D-->E-->F-->G kann erzeugt werden durch ein Programm wie: struct S { char a; S * l; }; struct T { int a; S * b; T * l; }; int main () { S * p = 0; T * q = 0; char c = ’H’; int i = 8; do { --c; --i; S * n = new S; T * m = new T; n->a = c; m->a = i; m->b = n; n->l = p; m->l = q; p = n; q = m; } while (c != ’A’); } 333 Programmierung II 334 Aufgabe 7 Kein Lösungshinweis. Aufgabe 8 1. Das Programm: #include <iostream.h> #include <string> using namespace std; void assign_1 (int *x, int *y) { x = y; } void assign_2 (int * &x, int * &y) { x = y; } int main () { int a[10] = {90,90,90,90,90,90,90,90,90,90}; int * p = new int[10]; for (int i=0; i<10; i++) p[i] = i; assign_1 (a, p); for (int i=0; i<10; i++) cout << a[i] << " , " << p[i] << endl; assign_2 (a, p); // FEHLER R-Wert als Argument fuer nicht-CONST L-Wert for (int i=0; i<10; i++) cout << a[i] << " , " << p[i] << endl; p = a; for (int i=0; i<10; i++) cout << a[i] << " , " << p[i] << endl; } enthält einen Fehler: den Aufruf assign 2 (a, p); An einen Parameter vom Typ int * & kann kein int * übergeben werden: Der formale Parameter ist keine konstante Referenz. Damit die Übergabe hätte mit folgender Definition funktioniert: void assign 2 (int * const & x, int * &y); Damit wäre aber natürlich die Zuweisung innerhalb von assign 2 nicht möglich. 2. a = p; ist nicht erlaubt: Felder sind zwar Zeiger, Zuweisungen an Felder sind nicht erlaubt. 3. Wird ein mit new T[N] angelegtes Feld statt mit delete[] mit delete freigegeben, dann wird nicht der Bereich des ganzen Felder freigegeben, sondern nur das erste Element. 4. delete 0; ist ohne Wirkung. 5. Objekte auf dem Stack werden automatisch bei Funktionsende freigegeben. Ein Versuch sie selbst explizit freizugeben wie in Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 335 int main() { int x; delete &x; // ABSTURZ } führt (hoffentlich) zum unmittelbaren Absturz des Programms. 6. Mit int *p = new int [10]; // 10 ints anlegen wird p mit einem Zeiger auf ein Feld von 10 Ints belegt. Mit int *p = new int (10); // 1 int anlegen, mit 10 initialisieren wird p mit einem Zeiger auf eine Int–Variable belegt, die mit 0 initialisiert wurde. 7. Mit int * const &p wird der Wert von p als konstant erklärt. Mit const int * &q wird das, auf das q zeigt als konstant erklärt. 8. void f (const char * x) { cout << x << endl; } int main () { char *p = new char [10]; const char *q = "Charlotte"; p[0] = ’X’; p[1] = 0; f (p); f (q); f ("Hugo"); } Aufgabe 9 Der Destruktor einer Klasse C ist dafür verantwortlich ... 1. FALSCH: ... alle Objekte der Klasse C wegzuräumen. 2. FALSCH: ... Objekte der Klasse C im Heap wegzuräumen. 3. FALSCH: ... alle Komponenten von Objekten der Klasse C wegzuräumen. 4. FALSCH: ... Komponenten von Objekten der Klasse C, die im Heap liegen, wegzuräumen. 5. FALSCH: delete ist nichts anderes, als eine spezielle Art den Destruktor aufzurufen: p sei vom Typ S*, dann ist delete p; das Gleiche wie (*p). S() Der Destruktor ist dafür verantwortlich, dass die notwendigen Aufräumarbeiten vor dem Wegräumen des Objektes erledigt werden. Das Wegräumen erledigt delete bzw. der Stack–Mechanismus. Aufgabe 10 Die Ausgabe des gegebenen Programms lässt sich experimentell überprüfen. 336 Programmierung II Aufgabe 11 Was ist alles falsch an folgender Klassendefinition: class S { public: S () : v(0), l(0) {} S (int i, S *pl) : v(i), l(pl) {} S (const S &ps) { delete l; // Kein delete im Konstruktor! l = 0; v = ps.v; for ( S *p = ps.l; p != 0; p=p->l) // Verkettung wird invertiert l = new S(p->v, l); } S & operator= (const S &ps) : l(0), v(ps->v) { // Initialisierer nur in Komstruktoren for ( S *p = ps.l; p != 0; p=p->l) // kein Test auf Selbstzuweisung l = new S(p->v, l); // Kein delete auf l return *this; // Verkettung wird invertiert } private: int v; S *l; }; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.4 337 Lösungshinweise zu Kapitel 4 Aufgabe 1 1. const–Fehler werden vom Compiler durch Analyse des Quellprogramms aufgedeckt. Es handelt sich immer um Verstöße des Programms an einer Stelle gegen Beschränkungen, die an anderer Stelle im Programm festgelegt wurden, es sind also Inonsistenzen eines Programms, die aufgedeckt werden. 2. Das Betriebssystem schützt nicht vor fehlerhaften Zugriffen innerhalb eines Programms. 3. Fehlerhafte Zugriffe, wie beispielsweise solche durch unitialisierte Zeiger, können mit dem Mechanismus der Konstanten nicht aufgedeckt werden. 4. #include <iostream> #include <stdlib.h> int main (int argc, char * argv[]) { int sum = 0; for (int i=1; i<argc; ++i) sum = sum + atoi(argv[i]); cout << sum << endl; } 5. #include <iostream> #include <stdlib.h> #include <string> int main (int argc, char * argv[]) { if ( argc != 3 ) { cout << "Falscher Aufruf" << endl; return 1; } if ( string(argv[0]) == "max" ) { cout << (atoi(argv[1])>atoi(argv[2]) ? atoi(argv[1]) : atoi(argv[2])) << endl; return 0; } if ( string(argv[0]) == "min" ) { cout << (atoi(argv[1])<atoi(argv[2]) ? atoi(argv[1]) : atoi(argv[2])) << endl; return 0; } cout << "Falscher Aufruf" << endl; return 1; } Achtung beim Vergleich von C–Strings: Mit if(argv[0] == max") wird die Gleichheit der Zeiger auf die Zeichenfolgen und nicht die Gleichheit der Zeichenfolgen geprüft. Um das zu vermeiden werden aus den C–Strings “richtige” Strings erzeugt (der eine explizit, durch den Konstruktor, der zweite implizit). Alternativ hätte man die C–Strings auch mit der Funktion strcmp vergleichen können: #include <iostream> #include <stdlib.h> #include <string.h> // C-String Funktionen int main (int argc, char * argv[]) { if ( argc != 3 ) { cout << "Falscher Aufruf" << endl; return 1; } //Achtung strcmp=0 ist Gleichheit if ( strcmp(argv[0], "max") == 0 ) { Programmierung II 338 cout << (atoi(argv[1])>atoi(argv[2]) ? atoi(argv[1]) : atoi(argv[2])) << endl; return 0; } // !strcmp ist also auch Gleichheit if ( !strcmp(argv[0], "min") ) { cout << (atoi(argv[1])<atoi(argv[2]) ? atoi(argv[1]) : atoi(argv[2])) << endl; return 0; } cout << "Falscher Aufruf" << endl; return 1; } 6. Siehe Skript. 7. Externe Bindung wird im Skript erörtert. 8. Wenn Funktionen interne Bindung hätten, dann könnte es in einem Programm zwei unterschiedliche Funktionen mit dem gleichen Namen und den gleichen Parametertypen geben. Sie müssten nur in unterschiedlichen Übersetzungseinheiten definiert werden. 9. Inline–Definitionen von freien Funktionen und Methoden gehören in H–Dateien. 10. class C { public: C (); private: static int x; }; C::C() : x(5) {} // <<== FEHLER Statische Komponenten werden definiert und in der Definition initalisiert: // H-Datei:-------------class C { public: C (); private: static int x; }; // C-Datei:-------------C::C() {} int C::x = 5; 11. Zwei Quelldateien a.cc und b.cc werden übersetzt und gebunden: a.cc: #include <iostream> float f(); typedef char X; const X a = ’a’; int main () { std::cout<<f()<<std::endl; } b.cc: #include <iostream> typedef float X; const X a = 0.0; float f () { std::cout<<a<<std::endl; return a; } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 339 Das Programm ist korrekt – Konstante haben interne Bindung – und erzeugt zweimal die Ausgabe “0”. Wird das const weggelassen, dann ist das Programm nicht mehr korrekt: globale Variablen haben externe Bindung. Aufgabe 2 1. Das Programm wird auf die Dateien B.h, B.cc und main.cc aufgeteilt. Die erste Übersetzungseinheit besetht aus B.h und B.cc, die zweite besteht aus main.cc // B.h ------------------------------class B { friend int main(); public: B(); ˜B(); int f(); static int g (); private: int x; static int y; const int z; }; // B.cc -----------------------------#include "B.h" int B::y = 0; B::B() : x(0), z(y) { ++y; } B::˜B() { --y; } int B::f() { ++x; return x+z; } int B::g() { return y; } // main.cc --------------------------#include "B.h" #include <iostream> using namespace std; int main () { cout << B::g() << endl; B b1; cout << B::y << endl; B b2; if ( b1.f() + b2.f() > 2) { B b3; cout << B::g() << endl; } cout << B::g() << endl; } 2. Makedatei: main : main.o B.o g++ -o main main.o B.o main.o : main.cc B.h g++ -Wall -c main.cc B.o : B.cc B.h g++ -Wall -c B.cc 340 Aufgabe 3 Kein Lösungshinweis. Programmierung II Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.5 341 Lösungshinweise zu Kapitel 5 Aufgabe 1 Kopierkonstruktor und Zuweisungsoperator werden am einfachsten rekursiv definiert: class S { public: S() : a(0), l(0) {} S(int p_a, S * p_l) : a(p_a), l(p_l) {} ˜S() { delete l; } S (const S &); S & operator= (const S &); private: int a; S * l; }; S::S (const S &s) : a(s.a), l(s.l==0 ? 0 : new S(*(s.l)) // rekursiver Aufruf des Kopierkonstruktors ) {} S & S::operator= (const S &s) { if (&s != this) { a = s.a; delete l; if (s.l == 0) l = 0; else l = new S(*(s.l)); // rekursiver Aufruf des Kopierkonstruktors } return *this; } Aufgabe 2 In der zweiten, der “Anfänger”–Variante: class Stapel { public: ... private: int a; Stapel * s; }; können leere Stapel nicht vernünftig dargestellt werden. Aufgabe 3 Kein Lösungshinweis. Aufgabe 4 Ausdrücke sind der Datentyp für die Benutzer. Benutzer haben nur die sichere also die tiefe Kopie zur Verfügung. Knoten sind ein interner Datentyp für den Implementier der Ausdrücke, der je nach Bedarf flach oder tief kopieren 342 Programmierung II will und darf. Im Konstruktor für Operator–Ausdrücke wird der Kopierkonstruktor auf neu erzeugte Objete angewendet und damit der Kopierkonstruktor aktiviert, dieser wiederum kopiert dann tief. Im Konstruktor für Operator–Knoten wird einfach der Zeiger kopiert. Aufgabe 5 // Ausdruck.h --------------------------------------------------------// #ifndef AUSDRUCK_H_ #define AUSDRUCK_H_ #include <Rational.h> #include <string> #include <iostream> class Ausdruck { friend ostream & operator<< (ostream &, const Ausdruck &); friend istream & operator>> (istream &, Ausdruck &); public: Ausdruck (); Ausdruck (const Ausdruck &); Ausdruck & operator=(const Ausdruck &); ˜Ausdruck (); private: class AusdrKnoten; AusdrKnoten * ak; }; ostream & operator<< (ostream &, const Ausdruck &); istream & operator>> (istream &, Ausdruck &); #endif // ---------------------------------------------------------------------- // Ausdruck.cc ---------------------------------------------------------// #include <Ausdruck.h> #include <stack> class Ausdruck::AusdrKnoten { public: AusdrKnoten (); AusdrKnoten (const Rational &); AusdrKnoten (char, AusdrKnoten *, AusdrKnoten *); ˜AusdrKnoten (); AusdrKnoten (const AusdrKnoten &); AusdrKnoten & operator=(const AusdrKnoten &); ostream & write (ostream &) const; private: enum Art {undefAusdr, wertAusdr, opAusdr}; Art art; Rational wert; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 343 char op; AusdrKnoten *l, *r; }; Ausdruck::Ausdruck () : ak(0) {} Ausdruck::Ausdruck (const Ausdruck &a) : ak( new AusdrKnoten(*(a.ak)) ) {} Ausdruck::˜Ausdruck () { delete ak; } Ausdruck & Ausdruck::operator=(const Ausdruck &a) { if ( &a != this ) { delete (ak); ak = new AusdrKnoten(*(a.ak)); } return *this; } Ausdruck::AusdrKnoten::AusdrKnoten () : art(undefAusdr), op(’#’), l(0), r(0) {} Ausdruck::AusdrKnoten::AusdrKnoten (const Rational &p_r) : art(wertAusdr), wert (p_r), op(’?’), l(0), r(0) {} Ausdruck::AusdrKnoten::AusdrKnoten (char p_op, AusdrKnoten *p_l, AusdrKnoten *p_r) : art(opAusdr), op(p_op), l(p_l), r(p_r) {} Ausdruck::AusdrKnoten::˜AusdrKnoten () { delete(l); delete(r); } Ausdruck::AusdrKnoten::AusdrKnoten (const AusdrKnoten &a) { art = a.art; op = a.op; if ( a.art == opAusdr) { l = new AusdrKnoten(*(a.l)); r = new AusdrKnoten(*(a.r)); } } Ausdruck::AusdrKnoten & Ausdruck::AusdrKnoten::operator=(const AusdrKnoten &a) { if (&a != this) { // keine Zuweisung an sich selbst delete(l); l = 0; delete(r); r = 0; art = a.art; op = a.op; if ( a.art == opAusdr) { l = new AusdrKnoten(*(a.l)); r = new AusdrKnoten(*(a.r)); } } return *this; } ostream & Ausdruck::AusdrKnoten::write(ostream & os) const { switch (art) { case wertAusdr: os << ’[’ << wert << ’]’; break; case opAusdr: if ( (l==0 ) || (r==0) ){ cerr << "FEHLERHAFTE STRUKTUR 3\n"; exit (1); } Programmierung II 344 os << ’(’; l->write(os); os << op; r->write(os); os << ’)’; break; default: cerr << "FEHLERHAFTE STRUKTUR 4: " << art << "\n"; exit (1); } return os; } std::ostream & operator<< (std::ostream &os, const Ausdruck &a) { return a.ak->write(os); } std::istream & operator>> (std::istream &is, Ausdruck &a) { stack<char> oSt; stack<Ausdruck::AusdrKnoten *> aSt; char z; Ausdruck::AusdrKnoten * a1, * a2; char op; do { if (!(is >> z)) break; switch (z){ case ’(’ : break; case ’)’ : { op = oSt.top(); oSt.pop(); a2 = aSt.top(); aSt.pop(); a1 = aSt.top(); aSt.pop(); aSt.push (new Ausdruck::AusdrKnoten(op, a1, a2)); break; } case ’+’: case ’-’: oSt.push (z); break; case ’[’: { Rational r; char tmp; is >> r; is >> tmp; if ( tmp != ’]’ ) { cerr << "Ausdrucksfehler 1\n"; exit (1); } aSt.push (new Ausdruck::AusdrKnoten(r)); break; } default: cerr << "Ausdrucksfehler 2\n"; exit (1); } } while (true); if ( !aSt.empty() ) { delete a.ak; a.ak = aSt.top(); return is; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg } else { cerr << "AUSWERTFEHLER\n"; exit(1); } } // ---------------------------------------------------------------------// Rational.h ----------------------------------------------------------// #ifndef RATIONAL_H_ #define RATIONAL_H_ #include <iostream> using namespace std; class Rational { friend ostream & operator<< (ostream &, const Rational &); friend istream & operator>> (istream &, Rational &); public: Rational (); Rational (int); Rational (int, int); Rational (const Rational &); char vorzeichen () const; int zaehler () const; int nenner () const; Rational & operator= (const Rational &); Rational operator+ (const Rational &) const; Rational operator- (const Rational &) const; Rational operator- () const; private: enum Vorzeichen {plus, minus}; Vorzeichen vz; unsigned int z; unsigned int n; static int static int kgv ggt (int, int); (int, int); static void static void static void kuerze (Rational &); erweitere (Rational &, int); gleichnamig (Rational &, Rational &); }; // I/O--Operatoren als freie Funktionen: ostream & operator<< (ostream &, const Rational &); istream & operator>> (istream &, Rational &); //Freie Operatoren // Unaeres Minus fuer Brueche: Rational operator- (const Rational &); // Operationen mit gemischten Operanden: 345 Programmierung II 346 Rational Rational Rational Rational operator+ operator+ operatoroperator- (int, const Rational &); (const Rational &, int); (int, const Rational &); (const Rational &, int); #endif // ---------------------------------------------------------------------// Rational.cc ---------------------------------------------------------// .... Rationale Zahlen sind die bekannten Brueche, sie sollten keine .... .... Probleme bereiten .... Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.6 Lösungshinweise zu Kapitel 6 Aufgabe 1 Die Umwandlung in ein Template ist trivial: template< double (*f)(double) > double nullStelle ( double u, // untere Intervallgrenze double o, // obere Intervallgrenze double eps ) { // Genauigkeit: minimale Intervallgroesse ... unveraendert .... } Aufgabe 2 Zweidimensionaler Integer–Vektoren als Template: #include <iostream> #include <string> using namespace std; template<class T> class Vektor { friend ostream & operator<< <T> (ostream &, const Vektor &); friend istream & operator>> <T> (istream &, Vektor &); public: Vektor (); Vektor (T, T); int & x (); // x--Koordinate int & y (); // y--Koordinate Vektor operator+ ( const Vektor &) const; // Vektoraddition private: T x_, y_; }; template<class T> Vektor<T>::Vektor () {} template<class T> Vektor<T>::Vektor (T px, T py) : x_(px), y_(py) {} template<class T> ostream & operator<< (ostream &os, const Vektor<T> &v) { return os << "(" << v.x_ << "," << v.y_ << ")"; } template<class T> istream & operator>> (istream &is, Vektor<T> &v) { char c; is >> c; // ( is >> v.x_; is >> c; // , is >> v.y_; is >> c; // ) } template<class T> Vektor<T> Vektor<T>::operator+ ( const Vektor &v) const { return Vektor(x_+v.x_, y_+v.y_); 347 348 Programmierung II } // Anwendungsbeispiel: int main () { Vektor<string> v1 ( "Effi ", "Briest "), v2 ( "Madame ", "Bovary "); cout << v2 + v1 << endl; } Aufgabe 3 Siehe Sortierbeispiele im Skript. Aufgabe 4 Der Operator operator[] sollte eine Referenz zurück geben. Aufgabe 5 Eine dünnbesetzte Matrix kann dargestellt werden als Abbildung von Zeilenindizes auf Zeilen, wobei Zeilen Abbildungen auf Elemente sind, etwa wie folgt: template<class T> class Matrix { private: typedef map<unsigned int, T> Zeile; map<unsigned int, Zeile> mat; ... public: ... Zeile & operator[] ( unsigned int i ) { return mat[i]; } ... }; Aufgabe 6 #include <iostream> using namespace std; template<unsigned int n> class Matrix { friend istream & operator>> <n> (istream &, Matrix<n> &); friend ostream & operator<< <n> (ostream &, const Matrix<n> &); private: typedef float Zeile[n]; Zeile m[n]; Matrix<n-1> coMatrix ( unsigned int x, unsigned int y ) { Matrix<n-1> res; for ( unsigned int i=0; i<n-1; ++i) for ( unsigned int j=0; j<n-1; ++j) res[i][j] = m[i<x?i:(i+1)][j<y?j:(j+1)]; return res; } public: Zeile & operator[] ( unsigned int i ) { return m[i]; } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg float det () { if (n == 1) return m[0][0]; else { float res = 0.0; int f = -1; for ( unsigned int i=0; i<n; ++i) { f = f*-1; res = f*m[i][0]*(coMatrix(i,0)).det() + res; } return res; } } }; template<> class Matrix<1> { friend istream & operator>> (istream &, Matrix<1> &); friend ostream & operator<< (ostream &, const Matrix<1> &); private: typedef float Zeile[1]; Zeile m[1]; public: Zeile & operator[] ( unsigned int i ) { return m[i]; } float det () { return m[0][0]; } }; template<unsigned int n> istream & operator>> (istream &is, Matrix<n> &m) { for (unsigned int i = 0; i<n; ++i) for (unsigned int j = 0; j<n; ++j) is >> m.m[i][j]; return is; } template<unsigned int ostream & operator<< for (unsigned int i for (unsigned int os << m.m[i][j] os << endl; } return os; } n> (ostream &os, const Matrix<n> &m) { = 0; i<n; ++i) { j = 0; j<n; ++j) << " "; int main() { Matrix<5> mat; cin >> mat; cout << "Die Determinante von\n" << mat << "ist" << endl; cout << mat.det() << endl; } 349 Programmierung II 350 B.7 Lösungshinweise zu Kapitel 7 Aufgabe 1 Das Programm class Basis { public: Basis () : x(10) {} void f () { ++x; } int x; }; class Abgeleitet : public Basis { public: Abgeleitet () {} void f () { --x; } }; int main() { Abgeleitet * pa = new Abgeleitet; Basis * pb = pa; pa->f(); // Aufruf von Abgeleitet::f pb->f(); // Aufruf von Basis::f cout << pa->x << endl; // Ausgabe: 10 cout << pb->x << endl; // Ausgabe: 10 } erzeugt die Ausgabe 10, 10. pa und pb zeigen zwar auf das gleiche Objekt, aber mit pa->f() und pb->f() werden unterschiedliche Methoden dieses Objekts aktiviert (es hat zwei Varianten von f!). Der Typ von pa und pb entscheidet über die jeweils zu aktivierende Methode. Aufgabe 2 Die Klasse ist OK und entspricht: class Deque : public Queue { public: Deque () {} Deque (const Deque &d) : Queue(d) {} Deque & operator= (const Deque &d) { if ( this != &d) this->Queue::operator= (d); return *this; } int getLast () { return l.back(); } }; Aufgabe 3 Das Programm ist korrekt und erzeugt die Ausgabe: 1111, 1112 111, 112, 113 a.print() aktiviert Abgeleitet::print für a. a wird mit seinem Defaultkonstruktor initialisiert auf Th Letschert, Fachbereich MNI, FH Giessen–Friedberg a: 351 Basis-Anteil Basis::x 111 Basis::y 112 Basis::z 113 Abgeleitet-Anteil Abgeleitet::x 1111 Abgeleitet::y 1112 Man beachte, dass a je zwei Komponenten mit den Namen x und y besitzt. a.print() gibt die abgeleiteten Anteile aus. pa zeigt auf das gleiche Objekt. Da pa aber vom Typ Basis* ist, wird mit pa->print() die Methode Basis::print aktiviert und die gibt den Basis–Anteil von a aus. Aufgabe 4 Der Kopierkonstruktor von Abgeleitet ist nicht korrekt. Die korrekte Lösung mit Zuweisungsoperator ist: class Basis { public: Basis (int x) : _x(x) {} Basis () : _x(-1) {} Basis (const Basis & b) : _x(b._x) {} Basis & operator= (const Basis & b) { if ( &b != this) _x = b._x; return *this; } private: int _x; }; class Abgeleitet : public Basis { public: Abgeleitet (int x, int y) : Basis(x), _y(y) {} Abgeleitet() {} Abgeleitet(const Abgeleitet & a) : Basis(a), _y(a._y) {} Abgeleitet & operator= (const Abgeleitet & a) { if ( &a != this) { Basis::operator=(a); _y = a._y; } return *this; } private: int _y; }; Zuweisungsoperator und Kopierkonstruktor hätten nicht definiert werden müssen. Die automatisch erzeugten der beiden Klassen sind korrekt. Aufgabe 5 Ein selbst definierter Zuweisungsoperator kopiert den Basisanteil nur dann, wenn dies explizit so programmiert wird. Also wenn er definiert wird, dann aber auch richtig: class Ab : public Basis { ... Ab & operator= (const Ab & p_ab) { if ( this != &p_ab ) { Programmierung II 352 Basis::operator=(p_ab); a = p_ab.a; } return *this; } ... }; Ein automatisch generierter Zuweisungsoperator hätte das Richtige getan. Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.8 353 Lösungshinweise zu Kapitel 8 Aufgabe 1 1. In der angegeben Form erzeugt das Programm die Ausgabe 10, 10. 2. Wenn die Methode Basis::f als virtuelle Methode definiert class Basis { public: Basis () : x(10) {} virtual void f () { ++x; } int x; }; class Abgeleitet : public Basis { public: Abgeleitet () {} void f () { --x; } }; int main() { Abgeleitet * pa = new Abgeleitet; Basis * pb = pa; pa->f(); // Abgeleitet::f pb->f(); // Abgeleitet::f cout << pa->x << endl; // Ausgabe 8 cout << pb->x << endl; // Ausgabe 8 } wird, dann kommt es beim Aufruf pb->f() auf den Typ an, den *pb zur Laufzeit hat. Da das Objekt auf den pb zeigt den Typ Abgeleitet hat, wird Abgeleitet::f aktiviert. Die Ausgabe ist dann 8, 8 (x zweimal erniedrigen, statt einmal erniedrigen und einmal erhöhen). 3. Wenn Basis::f als rein virtuelle Methode definiert wird ergibt sich das gleiche Verhalten. (Im Programm werden keine Objekte vom Typ Basis erzeugt und Basis::f wird nicht aufgerufen.) Aufgabe 2 1. Selbstverständlich! 2. Der Defaultkonstruktor von Manager belegt sein Objekt mit leeren Strings (die vom Defaultkonstruktor von string erzeugt werden). 3. Nein, die Klassen erzeugen Speicherlöcher: delete mitarbeiter[i] räumt nur den Angestellt– Anteil von *mitarbeiter[i] weg – *mitarbeiter[i] hat ja den Typ Angestellt. Wenn mitarbeiter[i] aber auf einen Manager zeigt, dann bleibt der abt–String im Speicher liegen. Lösung: Angestellt sollte einen virtuellen Destuktor haben: class Angestellt { public: Angestellt(string name) : _name(name) {} Angestellt() {} Angestellt() {} private: string _name; }; ... Programmierung II 354 Aufgabe 3 1. Die Zuweisung *b1 = *b2; hat nicht zur Folge, dass b1 und b2 auf gleiche Objekte zeigen! b1 und b2 sind vom Typ Basis*, also wird der Zuweisungsoperator Basis::operator= aktiviert! Dieser kopiert aber nur den Basis–Anteil: das b, a bleibt unverändert. (Dies ist ein Grund mit Umschlagklassen und clone–Funktionen zu arbeiten!) 2. Wenn print nicht virtuell ist, erzeugt das Programm die Ausgabe: b= 1 b= 1 Es wird ja stets die Basisvariante aufgerufen. Aufgabe 4 Das Programm frisst den Speicher, obwohl jedes Objekt nach seiner Erzeugung sofort wieder vernichtet wird. Da die Zeiger vom Basistyp sind, wird der Destruktor des Basistyps aufgerufen. Die in der Ableitung angelegten Ints (*y) werden nicht weg geräumt und füllen den Speicher unaufhaltsam. Der Fehler ist schnell behoben: Der Destruktor des Basistyps muss lediglich als virtuell erklärt werden. Damit ruft delete den Destruktor der abgeleiteten Klasse Ab und dieser ruft (implizit) den Destruktor der Basisklasse. Aufgabe 5 Die typisch polymorphe Methode ist write. Wertausdrücke und Operatorausdrücke sollen ausgegeben werden werden können. Wir haben einen Zeiger, der entweder auf die eine oder die andere Art von Ausdruck zeigt und egel was es nun gerade ist, mit write soll es ausgegeben werden. Konstrurktoren und Zuweisungsoperatoren und freie Funktionen sind nicht polymorph. // Ausdruck.h -----------------------------------------------#ifndef AUSDRUCK_H_ #define AUSDRUCK_H_ #include <string> #include <iostream> class Ausdruck { friend std::ostream & operator<< (std::ostream &, const Ausdruck &); friend std::istream & operator>> (std::istream &, Ausdruck &); public: Ausdruck (); Ausdruck (const Ausdruck &); Ausdruck & operator=(const Ausdruck &); ˜Ausdruck (); private: class Knoten; class WertKnoten; class OpKnoten; Knoten * ak; }; std::ostream & operator<< (std::ostream &, const Ausdruck &); std::istream & operator>> (std::istream &, Ausdruck &); #endif // ---------------------------------------------------------- Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 355 // Ausdruck.cc ---------------------------------------------#include <Ausdruck.h> #include <stack> #include <Zahl.h> class Ausdruck::Knoten { public: virtual ˜Knoten () {} virtual std::ostream & write (std::ostream &) const = 0; virtual Knoten * clone () const = 0; }; class Ausdruck::WertKnoten : public Ausdruck::Knoten { public: WertKnoten (const Zahl & p_wert) : wert(p_wert) {} std::ostream & write (std::ostream &os ) const; WertKnoten * clone () const { return new WertKnoten (wert); } private: Zahl wert; }; class Ausdruck::OpKnoten : public Ausdruck::Knoten { public: OpKnoten (char p_op, Knoten *p_l, Knoten *p_r) : op(p_op), l(p_l), r(p_r) {} ˜OpKnoten () { delete l; delete r; } OpKnoten (const OpKnoten &); OpKnoten & operator= (const OpKnoten &); OpKnoten * clone () const { return (new OpKnoten (*this)); } std::ostream & write (std::ostream &) const; private: char op; Knoten *l, *r; }; Ausdruck::Ausdruck () : ak(0) {} Ausdruck::Ausdruck (const Ausdruck &a) : ak( a.ak->clone() ) {} Ausdruck::˜Ausdruck () { delete ak; } Ausdruck & Ausdruck::operator=(const Ausdruck &a) { if ( &a != this ) { delete ak; ak = a.ak->clone(); } return *this; } Ausdruck::OpKnoten::OpKnoten (const OpKnoten & k) { op = k.op; l = k.l->clone(); r = k.r->clone(); } Ausdruck::OpKnoten & Ausdruck::OpKnoten::operator=(const OpKnoten & k) { if (&k != this) { // keine Zuweisung an sich selbst delete(l); delete(r); l = k.l->clone(); Programmierung II 356 r = k.r->clone(); } return *this; } std::ostream & Ausdruck::WertKnoten::write(std::ostream & os) const { return os << ’[’ << wert << ’]’; } std::ostream & Ausdruck::OpKnoten::write(std::ostream & os) const { if ( (l==0 ) || (r==0) ){ cerr << "FEHLERHAFTE STRUKTUR 3\n"; exit (1); } os << ’(’; l->write(os); os << op; r->write(os); os << ’)’; return os; } std::ostream & operator<< (std::ostream &os, const Ausdruck &a) { return a.ak->write(os); } std::istream & operator>> (std::istream &is, Ausdruck &a) { stack<char> oSt; stack<Ausdruck::Knoten *> aSt; char z; Ausdruck::Knoten * a1, * a2; char op; do { if (!(is >> z)) break; switch (z){ case ’(’ : break; case ’)’ : { op = oSt.top(); oSt.pop(); a2 = aSt.top(); aSt.pop(); a1 = aSt.top(); aSt.pop(); aSt.push (new Ausdruck::OpKnoten(op, a1, a2)); break; } case ’+’: case ’-’: oSt.push (z); break; case ’[’: { Zahl r; char tmp; is >> r; is >> tmp; if ( tmp != ’]’ ) { cerr << "AusdrucksFEHLER 1\n"; exit (1); } aSt.push (new Ausdruck::WertKnoten(r)); break; } default: cerr << "ucksfehler 2\n"; exit (1); } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg } while (true); if ( !aSt.empty() ) { delete a.ak; a.ak = aSt.top(); return is; } else { cerr << "AUSWERTFEHLER\n"; exit(1); } } // ---------------------------------------------------------- Aufgabe 6 Wenn praemie in der Basisklasse virtuell ist, kann man sich alle dynamic cast Operationen sparen. Aufgabe 7 1. Kein Lösungshinweis. 2. Hinweis, Umwandlung des Orginals in Template–Form: class NTupel { public: virtual ˜NTupel () {} virtual NTupel * clone() = 0; virtual ostream & schreib (ostream &) = 0; }; template<class T> class Paar : public NTupel { public: Paar (T p_i, T p_j) : i(p_i), j(p_j) {} NTupel * clone() { return new Paar(*this); } ostream & schreib (ostream & os) { return os << "<" << i << "," << j << ">"; } private: T i, j; }; template<class T> class Tripel : public NTupel { public: Tripel (T p_i, T p_j, T p_k) : i(p_i), j(p_j), k(p_k) {} NTupel * clone() { return new Tripel(*this); } ostream & schreib (ostream & os) { return os << "<" << i << "," << j << "," << k << ">"; } private: T i, j, k; }; 357 Programmierung II 358 template<class T> class Tupel { friend istream & operator>> <T> (istream &, Tupel<T> &); friend ostream & operator<< <T> (ostream &, const Tupel<T> &); public: Tupel(T i, T j) : b(new Paar<T>(i,j)) {} Tupel(T i, T j, T k) : b(new Tripel<T>(i,j,k)) {} ˜Tupel() { delete b; } Tupel(const Tupel &p_tupel) : b(p_tupel.b->clone()) {} Tupel & operator= (const Tupel & p_tupel) { if ( this != &p_tupel ) { delete (b); if ( p_tupel.b == 0 ) b = 0; else b = p_tupel.b->clone(); } return *this; } private: NTupel * b; }; template<class T> istream & operator>> (istream & is, Tupel<T> & t) { .... t.b = new Paar<T>(a[0], a[1]); ... t.b = new Tripel<T>(a[0], a[1], a[2]); ... } template<class T> ostream & operator<< (ostream & is, const Tupel<T> & t) { return t.b->schreib(is); } Th Letschert, Fachbereich MNI, FH Giessen–Friedberg B.9 359 Lösungshinweise zu Kapitel 9 Aufgabe 1 1. #include <string> #include <fstream> int main (int argc, const char * argv[]) { if ( argc != 3 ) { cerr << "Falscher Aufruf" << endl; return 1; } ifstream ifs (argv[1]); if (!ifs) { cerr << "kann Datei " << argv[1] << " nicht oeffnen\n"; return 1;} ofstream ofs (argv[2]); if (!ofs) { cerr << "kann Datei " << argv[2] << " nicht oeffnen\n"; return 1;} string zeile; int i = 0; getline (ifs, zeile); while (ifs) { ++i; ifs.width (3); ofs << i << ": " << zeile << endl; getline (ifs, zeile); } ifs.close(); } 2. #include <fstream> #include <string> #include <sstream> class Wort { friend istream & operator>> (istream &, Wort &); public: Wort () {} private: string v; }; istream & operator>> (istream &s, Wort &w) { static const string weiseZeichen = " \t"; string str; //Wortanfang und erstes Zeichen hinter dem Wort string::size_type pos_1, pos_2; do { s >> str; pos_1 = str.find_first_not_of (weiseZeichen, 0); if (pos_1 != string::npos) break; } while (s); if (!s) return s; pos_2 = str.find_first_of (weiseZeichen, pos_1); if (pos_2 == string::npos) pos_2 = str.length(); w.v = str.substr (pos_1, pos_2-pos_1); Programmierung II 360 return s; } int main (int argc, const char * argv[]) { if (argc != 2) {cerr << "Falscher Aufruf\n"; return 1;} ifstream ifs (argv[1]); if (!ifs) {cerr << "kann Datei nicht oeffnen\n"; return 1;} int cZeilen = 0, cWorte = 0, cZeichen = 0; string zeile; getline (ifs, zeile); while (ifs) { ++cZeilen; cZeichen = cZeichen + zeile.length(); istringstream istr (zeile); Wort w; while (istr >> w) { ++cWorte; } getline (ifs, zeile); } cout << argv[1] << "\n\tZeichen: \t" << cZeichen << " (ohne Zeilenvorschub)" << endl << "\tWorte : \t" << cWorte << endl << "\tZeilen: \t" << cZeilen << endl; } 3. Die Größe einer Datei kann man feststellen, indem auf das Ende positioniert wird und dann die Position ausgegeben wird (seekg und tellg). Siehe Skript. 4. Siehe Beispiel im Skript. 5. int main (int argc, char *argv[]) { ifstream datei; istream & input = (argc < 2) ? cin : (datei.open(argv[1]), static_cast<istream &>(datei)); ... input >> ...; ... } Aufgabe 2 1. Die Operatoren können mit allen von istream bzw. ostream abgeleiteten Typen (z.B. Dateien) verwendet werden. Sie sind damit wesentlich vielseitiger. 2. Siehe Skript. 3. Mit endl wird der Puffer geleert und die zeichen auf das externe Medium geschrieben. 4. Die Breite der Ausgabe wird mit width gesetzt. Die Genausigkeit der Ausgabe kann mit precision eingestellt werden. #include <iostream> float a[10][10]; using namespace std; Th Letschert, Fachbereich MNI, FH Giessen–Friedberg 361 int main (int argc, const char * argv[]) { for (int i=0; i<10; ++i) for (int j=0; j<10; ++j) a[i][j] = 10*i+j+0.1*j+0.001*j; // Ausgabe im Standard-Format fuer Fliesskomma for (int i=0; i<10; ++i) { for (int j=0; j<10; ++j) { cout.width(7); // 7 Zeichen fuer die naechste Ausgabe cout.precision(4); // Genauigkeit: 4 Stellen pro Zahl cout.fill(’ ’); // Mit Blank auffuellen (Default) cout << a[i][j]; } cout << endl; } cout << endl; // Ausgabe im fixed-Format fuer Fliesskomma for (int i=0; i<10; ++i) { for (int j=0; j<10; ++j) { cout.width(6); // 6 Zeichen fuer die naechste Ausgabe cout.setf(ios::fixed); // Ausgabe im fixed-Format cout.precision(2); // Genauigkeit: 2 NACHKOMMA-Stellen cout.fill(’0’); // Mit 0 auffuellen cout << a[i][j]<<" "; // Zwischenraeume ausgeben } cout << endl; } } Aufgabe 3 Mengen halten ihre Elemente nach Größe sortiert. Multimengen sind Mengen in denen Elemente mehrfach vorkommen können. Mengen und Multimengen können mit Iteratoren durchlaufen werden. #include #include #include #include <fstream> <string> <sstream> <set> class Wort { friend istream & operator>> (istream &, Wort &); friend ostream & operator<< (ostream &, const Wort &); public: Wort () {} bool Wort::operator< (const Wort &w) const { return v bool Wort::operator> (const Wort &w) const { return v bool Wort::operator== (const Wort &w) const { return v bool Wort::operator<= (const Wort &w) const { return v bool Wort::operator>= (const Wort &w) const { return v private: string v; }; ostream & operator<< (ostream &s, const Wort &w) { return s << w.v; } istream & operator>> (istream &s, Wort &w) { < > == <= >= w.v; w.v; w.v; w.v; w.v; } } } } } Programmierung II 362 static const string weiseZeichen = " \t"; string str; //Wortanfang und erstes Zeichen hinter dem Wort string::size_type pos_1, pos_2; do { s >> str; pos_1 = str.find_first_not_of (weiseZeichen, 0); if (pos_1 != string::npos) break; } while (s); if (!s) return s; pos_2 = str.find_first_of (weiseZeichen, pos_1); if (pos_2 == string::npos) pos_2 = str.length(); w.v = str.substr (pos_1, pos_2-pos_1); return s; } multiset<Wort> warteSchlange; int main (int argc, const char * argv[]) { if (argc != 2) {cerr << "Falscher Aufruf\n"; return 1;} ifstream ifs (argv[1]); if (!ifs) {cerr << "kann Datei nicht oeffnen\n"; return 1;} string zeile; getline (ifs, zeile); while (ifs) { istringstream istr (zeile); Wort w; while (istr >> w) { warteSchlange.insert(w); } getline (ifs, zeile); } for (multiset<Wort>::iterator i = warteSchlange.begin(); i != warteSchlange.end(); ++i) cout << *i << endl; } Aufgabe 4 Kein Lösungshinweis. Aufgabe 5 Kein Lösungshinweis. Index cout, 39, 269 Cursor, 299 Abbildung, 307 Adresse, 60 Adressoperator, 60 ADT, 46 ar, 124 Archiv, 124 argc, 101 argv, 101 ASCII, 289 Assembler, 105 atoi, 101, 277, 297 Ausgabe -selbst definierter Typen, 39, 269 Datei, 110, 263 -Abstraktion, 293 -Flag, 266, 283 -Header, 296 -Modus, 285 -Offnen, 263, 283 -Position, 265, 281, 299 -Schliesen, 265 -Template, 296 -Verwaltung von Satzen, 300 -Zustand, 266 -clear, 283 -eof, 267 -flush, 285 -get, 267 -positionieren, 282 -put, 267 -read, 284 -seek, 282 -tell, 282 -write, 284 Binar-, 288 getline, 268 Konstruktor und-, 265 Offnen einer Binar-, 290 Satz loschen-, 300 sequentielle, 263 und Iterator, 299 Datenkomponente statische- eines Templates, 175 Datentyp Ableitung als konkreter-, 212 abstrakter-, 46 konkreter-, 46, 49, 138 delete, 73 -und Destruktoren, 83 -und Felder, 76 Dereferenzierung, 63 Dereferenzierungs–Operator, 63 Destruktor, 18, 23, 83, 138 -und Vererbung, 206 virtueller-, 227 Direktive bedingte-, 109 Definitions-, 109 Inklusions-, 106 DLL, 127 Dump-Tool, 292 dynamic cast, 243 bad, 266 badbit, 266 Basisklasse abstrakte-, 230 Baum Ableitungs-, 152 abstrakter Syntax-, 153 Syntax-, 152, 153, 214 Behälterklasse, 144 Benutzer, 4 Beziehung -zwischen Klassen, 53 Bibliothek, 124 dynamische-, 127 Objekt-, 124 binary, 291 Binder, 105, 111, 124 dynamischer-, 127 Bindung -dynamische, 227 -statische, 227 externe-, 112, 116, 118 interne-, 112, 120 Byteordnung, 292 Byteposition, 296 C–Datei, 108 C-String, 276, 290 Cast dynamischer, 243 statischer, 292 cerr, 269 cin, 39, 269 Compileroption, 108, 125 -Bibliotheksverzeichnis, 125 -Inklusionsverzeichnis, 108 const, 26–28, 85, 88 -und Initialisierer, 27 363 Programmierung II 364 Eingabe -selbst definierter Typen, 39, 269 failbit setzen bei der-, 273 zurück setzen, 272 endl, 285 eof, 266 eofbit, 266 explicit, 16 fail, 266 failbit, 266, 273 Feld, 76, 286 -Index, 78 -als Referenzparameter, 79 -als Wertparameter, 78 -im Heap, 76 -mehrdimensionales, im Heap, 81 flush, 285 Freund, 9 Funktion als Freund, 9, 271 I/O-Operator als-, 271 Klasse als Freund, 10, 149 friend, 9, 150 fstream, 285 Funktionstemplate -als Freund eines Klassentemplates, 174 Geheimnisprinzip, 7 generisch, 252 getline, 268, 269 Getrennte Ubersetzung, 112 -einer Funktion, 114 -und Klassendefinitionen, 117 -und Konstanten, 120 -und Typdefinitionen, 114 -und globale Variablen, 118 good, 266 goodbit, 266 Grammatik, 152 H–Datei, 108 Heap, 72 ifstream, 263, 265, 267 Implementierung, 4 include, 106 -Verzeichnis, 108 Indexberechnung, 78 Initialisierer, 18, 20, 27 Initialisierung, 14 -reihenfolge, 18 Initialisierungsliste, 17 Inklusionswächter, 109 inline, 13 Interface, 231 istream iterator, 278 istringstream, 102, 274 istrstream, 274 -und istringstream, 277 Iterator, 144 -und Datei, 277 konstanter-, 150 Kapselung, 7, 137 Klasse, 1 -Komponente, 1 -und Verbund, 5 abgeleitete-, 192 Basis-, 192 Behälter-, 141, 172 Behalter-, 151 geschachtelte-, 133 konstante Datenkomponente-, 26 konstante Methode-, 27 statische Datenkomponente-, 23, 120 statische Methode-, 25 variablenorientierte-, 138 wertartige-, 138 wertorientierte-, 40 zustandsorientierte-, 40 Klassenvariable, 24 -und getrennte Übersetzung, 120 Kommandozeile, 100, 101 Komposition, 54 Konstante, 26, 87, 122 interne Bindung einer-, 120 Konstruktor, 14, 82 -Aufruf, 16 -als Konversionsoperation, 16 -und Vererbung, 204 Comiler-generierter-, 22 Default-, 16, 47, 138, 174 expliziter-, 16 Klassen ohne-, 17 Kopier-, 47, 135, 136, 138 uberladener-, 15 und Polymorphismus-, 227 Vererbung und Kopier-, 207 Konversion -int nach string, 276 -string nach int, 276 Darstellungs-, 273 Typ-, 243 Kopie flache-, 135, 233 tiefe-, 135 Kopplung, 8, 213 l-Wert, 38, 49 Lader, 111 Th Letschert, Fachbereich MNI, FH Giessen–Friedberg Lebensdauer, 72 list, 305 Liste, 132, 305 -mit Position, 142 Kopie einer-, 135 sortierte-, 212 verkettete-, 75, 132 Zuweisung einer-, 135 Listeniterator, 144 main, 111 Make, 122, 125 -Makro, 126 Makedatei, 122 map, 307 Menge, 137, 152, 306 Methode konstante-, 27 rein virtuelle-, 230 statische-, 25 virtuelle-, 223 Mischen, 280 Modul, 293 Multimenge, 306 Multimethode, 248 multiset, 306 new, 73 -und Felder, 76 -und Konstruktoren, 82 -und mehrdimensionale Felder, 81 next permutation, 186 Null–Zeiger, 70 OD, 292 ofstream, 263, 265, 266 Operator, 11 -*, 147 -++, 147 -Template als Freund, 271 -als Freund, 179 -als Methode, 11 -als freie Funktion, 11 -binarer, 11 -template, 179 -unarer, 11 -und statische Methoden, 26 -<<, 39, 269, 273 -<< und Templates, 179 ->>, 39, 102, 269, 273 Adress-, 60, 63 Adresss-, 52 Ausgabe-, 39, 270 Bereichs-, 211 delete-, 72 365 Dereferenzierungs-, 63 Eingabe-, 39, 270 new-, 72 Pfeil-, 69 Shift-, 39 Stern-, 49 typeid-, 245 unärer, 12 Vererbung und Zuweisungs-, 209 Vergleichs-, 47, 49 vordefinierter-, 52 Zuweisungs-, 47, 48, 52, 135, 136, 138 Option, 108, 125 ostringstream, 274 ostrstream, 274 -und ends, 277 Packen, 294 Parameter konstanter-, 28, 88 Referenz-, 28, 37 Typ-, 163 Wert-, 28, 37 Permutation, 185 Persistenz, 285 Pfeiloperator, 69 Pointer, 60 Polymorphismus, 224 parametrischer-, 252 Präprozessor, 105, 106 Konstante, 109 private, 1, 203 -und Vererbung, 199 Problem des Handlungsreisenden, 186 Programm, 110 -Argumente, 100 -Aufteilung, 112 -Ergebnis, 100 -Status, 100 protected, 201, 203 public, 1, 192, 203 -und Vererbung, 199 putback, 272 r-Wert, 38, 60 ranlib, 125 read, 284 Record, 286 Referenz, 37, 38, 60 -Ergebnis, 37 -Initialisierung, 38 -Parameter, 37 -Variable, 38 Relation Benutzt-, 53 Programmierung II 366 Enthalt-, 54 Satz, 286 Schablone, 163 -Instanzierung, 164 -und Freunde, 174 Funktions-, 163 Schnittstelle, 4, 41, 231 Seiteneffekt, 138 set, 306 setstate, 266 Sichtbarkeit -privater Klassenkomponenten, 2 -und Vererbung, 199 Sichtbarkeitsregeln, 4 Sortieren, 165 Speicherallokation, 73 Speicherbereich, 72 statischer-, 72 sstream, 102, 274 Stack, 72 Standardausgabe, 269 Standardbibliothek, 183 Standardeingabe, 269 Standardfehlerausgabe, 269 Stapel, 42, 72, 132 static, 23, 25 static cast, 292 STL, 141, 183 -Abbildung, 185 -Listen, 183 -Vektor, 184 strcmp, 81 strcpy, 81 Stream String-, 273, 274 String, 276 -Stream, 102, 273, 290 -literal, 80 C-, 80, 101 getline, 269 Konversion von, nach-, 275 stringstream, 274 strlen, 81 strstream, 274 Suchpfad Bibliotheks-, 125 Verzeichnis-, 125 Syntax -Baum, 152, 214 Systemaufruf, 263 Template, 163 -Ein- / Ausgabe, 271 -Instanzierung, 164 -Parameter, 165 -Spezialisierung, 166, 169, 170 -und Freunde, 174 -und Vererbung, 252 Datei-, 296 Funktions-, 163, 174, 179, 271 Klassen-, 170 Listen-, 172, 180 Vektor-, 171 Template-Freund, 174 Funktionstemplate als gebundener-, 179, 271 gebundener-, 176, 181 nicht Template als-, 174 ungebundener-, 177 this, 48, 148 type info, 246 typeid, 245 Übersetzungseinheit, 106, 110, 124 getrennte-, 111 UML, 7 Ableitung in-, 192 Abstrakte Basisklasse in-, 231 Aggregation in-, 90 Benutztrelation in-, 54 Klassendiagramm in-, 56 Komposition in-, 54 Templates in-, 174 unget, 272 Variable externe Bindung einer-, 119 globale-, 72 statische-, 72 vector, 305 Vektor, 305 Verbund, 1 Vererbung, 194, 269 -und Kopierkonstruktor, 207 -und Zuweisung, 209 öffentliche, 203 geschützte-, 203 Implementierungs-, 203 Mehrfach-, 204 private-, 203 Typen der-, 203 Verfugbarkeitsliste, 301 Verweis, 60 virtuell, 223 Wahlfreier Zugriff, 281 Warteschlange, 132 Wiederverwendung, 139 write, 284 Th Letschert, Fachbereich MNI, FH Giessen–Friedberg Zeiger, 60 -und Felder, 77 -und const, 85 auf Funktionen, 66 Zeigertyp, 60 Zeilenvorschub -und Dateityp, 290 Zustand, 42 Zuweisung -und Polymorphismus, 228 -und Vererbung, 209 367